@superblocksteam/sdk 2.0.123 → 2.0.124-next.1

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.
Files changed (115) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts +37 -1
  3. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  4. package/dist/cli-replacement/automatic-upgrades.js +162 -10
  5. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  6. package/dist/cli-replacement/automatic-upgrades.test.js +377 -8
  7. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  8. package/dist/cli-replacement/dependency-install-classifier.d.mts +21 -0
  9. package/dist/cli-replacement/dependency-install-classifier.d.mts.map +1 -0
  10. package/dist/cli-replacement/dependency-install-classifier.mjs +83 -0
  11. package/dist/cli-replacement/dependency-install-classifier.mjs.map +1 -0
  12. package/dist/cli-replacement/dependency-install-classifier.test.d.mts +2 -0
  13. package/dist/cli-replacement/dependency-install-classifier.test.d.mts.map +1 -0
  14. package/dist/cli-replacement/dependency-install-classifier.test.mjs +51 -0
  15. package/dist/cli-replacement/dependency-install-classifier.test.mjs.map +1 -0
  16. package/dist/cli-replacement/dev-s3-restore.test.mjs +403 -14
  17. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  18. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +33 -2
  19. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  20. package/dist/cli-replacement/dev-token-priming.test.d.mts +31 -0
  21. package/dist/cli-replacement/dev-token-priming.test.d.mts.map +1 -0
  22. package/dist/cli-replacement/dev-token-priming.test.mjs +87 -0
  23. package/dist/cli-replacement/dev-token-priming.test.mjs.map +1 -0
  24. package/dist/cli-replacement/dev.d.mts +47 -0
  25. package/dist/cli-replacement/dev.d.mts.map +1 -1
  26. package/dist/cli-replacement/dev.interception.test.d.mts +2 -0
  27. package/dist/cli-replacement/dev.interception.test.d.mts.map +1 -0
  28. package/dist/cli-replacement/dev.interception.test.mjs +68 -0
  29. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -0
  30. package/dist/cli-replacement/dev.mjs +486 -65
  31. package/dist/cli-replacement/dev.mjs.map +1 -1
  32. package/dist/cli-replacement/home-npmrc.d.mts +180 -0
  33. package/dist/cli-replacement/home-npmrc.d.mts.map +1 -0
  34. package/dist/cli-replacement/home-npmrc.mjs +283 -0
  35. package/dist/cli-replacement/home-npmrc.mjs.map +1 -0
  36. package/dist/cli-replacement/home-npmrc.test.d.mts +10 -0
  37. package/dist/cli-replacement/home-npmrc.test.d.mts.map +1 -0
  38. package/dist/cli-replacement/home-npmrc.test.mjs +582 -0
  39. package/dist/cli-replacement/home-npmrc.test.mjs.map +1 -0
  40. package/dist/cli-replacement/install-packages.classify.test.d.mts +2 -0
  41. package/dist/cli-replacement/install-packages.classify.test.d.mts.map +1 -0
  42. package/dist/cli-replacement/install-packages.classify.test.mjs +125 -0
  43. package/dist/cli-replacement/install-packages.classify.test.mjs.map +1 -0
  44. package/dist/cli-replacement/install-packages.npm-registry.test.d.mts +2 -0
  45. package/dist/cli-replacement/install-packages.npm-registry.test.d.mts.map +1 -0
  46. package/dist/cli-replacement/install-packages.npm-registry.test.mjs +260 -0
  47. package/dist/cli-replacement/install-packages.npm-registry.test.mjs.map +1 -0
  48. package/dist/cli-replacement/post-upgrade-lockfile-strip.d.mts +58 -0
  49. package/dist/cli-replacement/post-upgrade-lockfile-strip.d.mts.map +1 -0
  50. package/dist/cli-replacement/post-upgrade-lockfile-strip.mjs +224 -0
  51. package/dist/cli-replacement/post-upgrade-lockfile-strip.mjs.map +1 -0
  52. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.d.mts +11 -0
  53. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.d.mts.map +1 -0
  54. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.mjs +317 -0
  55. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.mjs.map +1 -0
  56. package/dist/cli-replacement/userconfig-env.integration.test.d.mts +26 -0
  57. package/dist/cli-replacement/userconfig-env.integration.test.d.mts.map +1 -0
  58. package/dist/cli-replacement/userconfig-env.integration.test.mjs +148 -0
  59. package/dist/cli-replacement/userconfig-env.integration.test.mjs.map +1 -0
  60. package/dist/dev-utils/dev-server-metrics.d.mts +25 -0
  61. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  62. package/dist/dev-utils/dev-server-metrics.mjs +84 -0
  63. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  64. package/dist/dev-utils/dev-server-metrics.test.d.mts +2 -0
  65. package/dist/dev-utils/dev-server-metrics.test.d.mts.map +1 -0
  66. package/dist/dev-utils/dev-server-metrics.test.mjs +26 -0
  67. package/dist/dev-utils/dev-server-metrics.test.mjs.map +1 -0
  68. package/dist/dev-utils/dev-server.d.mts +23 -1
  69. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  70. package/dist/dev-utils/dev-server.mjs +21 -9
  71. package/dist/dev-utils/dev-server.mjs.map +1 -1
  72. package/dist/dev-utils/dev-server.status.test.d.mts +2 -0
  73. package/dist/dev-utils/dev-server.status.test.d.mts.map +1 -0
  74. package/dist/dev-utils/dev-server.status.test.mjs +41 -0
  75. package/dist/dev-utils/dev-server.status.test.mjs.map +1 -0
  76. package/dist/dev-utils/token-manager.d.ts +31 -0
  77. package/dist/dev-utils/token-manager.d.ts.map +1 -1
  78. package/dist/dev-utils/token-manager.js +34 -0
  79. package/dist/dev-utils/token-manager.js.map +1 -1
  80. package/dist/telemetry/local-obs.js +1 -1
  81. package/dist/telemetry/local-obs.js.map +1 -1
  82. package/dist/telemetry/util.js +1 -1
  83. package/dist/types/scoped-jwt-token-payload.d.ts +1 -0
  84. package/dist/types/scoped-jwt-token-payload.d.ts.map +1 -1
  85. package/dist/version-control.d.mts.map +1 -1
  86. package/dist/version-control.mjs +6 -7
  87. package/dist/version-control.mjs.map +1 -1
  88. package/package.json +12 -12
  89. package/src/cli-replacement/automatic-upgrades.test.ts +530 -8
  90. package/src/cli-replacement/automatic-upgrades.ts +179 -7
  91. package/src/cli-replacement/dependency-install-classifier.mts +118 -0
  92. package/src/cli-replacement/dependency-install-classifier.test.mts +72 -0
  93. package/src/cli-replacement/dev-s3-restore.test.mts +554 -14
  94. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +35 -2
  95. package/src/cli-replacement/dev-token-priming.test.mts +103 -0
  96. package/src/cli-replacement/dev.interception.test.mts +80 -0
  97. package/src/cli-replacement/dev.mts +597 -95
  98. package/src/cli-replacement/home-npmrc.mts +409 -0
  99. package/src/cli-replacement/home-npmrc.test.mts +757 -0
  100. package/src/cli-replacement/install-packages.classify.test.mts +168 -0
  101. package/src/cli-replacement/install-packages.npm-registry.test.mts +345 -0
  102. package/src/cli-replacement/post-upgrade-lockfile-strip.mts +296 -0
  103. package/src/cli-replacement/post-upgrade-lockfile-strip.test.mts +482 -0
  104. package/src/cli-replacement/userconfig-env.integration.test.mts +189 -0
  105. package/src/dev-utils/dev-server-metrics.mts +96 -0
  106. package/src/dev-utils/dev-server-metrics.test.mts +38 -0
  107. package/src/dev-utils/dev-server.mts +48 -8
  108. package/src/dev-utils/dev-server.status.test.mts +58 -0
  109. package/src/dev-utils/token-manager.ts +36 -0
  110. package/src/telemetry/local-obs.ts +1 -1
  111. package/src/telemetry/util.ts +1 -1
  112. package/src/types/scoped-jwt-token-payload.ts +1 -0
  113. package/src/version-control.mts +8 -6
  114. package/tsconfig.tsbuildinfo +1 -1
  115. package/.turbo/turbo-publish-package.log +0 -0
@@ -13,12 +13,19 @@ import {
13
13
  NON_SB_ORG_UPDATE_ERROR,
14
14
  traceFunction,
15
15
  } from "@superblocksteam/shared";
16
+ import {
17
+ bucketNpmRegistryHost,
18
+ redactNpmRegistryHostsFromText,
19
+ } from "@superblocksteam/telemetry";
16
20
  import type { LockService } from "@superblocksteam/vite-plugin-file-sync/lock-service";
21
+ import { classifyNpmExecStderr } from "@superblocksteam/vite-plugin-file-sync/npm-error-parser";
17
22
 
18
23
  import type { ResponseMeta } from "../socket/handlers.js";
19
24
  import { getTracer } from "../telemetry/index.js";
20
25
  import { getErrorMeta, getLogger } from "../telemetry/logging.js";
21
26
  import type { ApplicationConfigWithTokenConfigAndUserInfo } from "../types/common.js";
27
+ import { superblocksLogsPath, superblocksNpmrcPath } from "./home-npmrc.mjs";
28
+ import { stripUpgradedCliLockfiles } from "./post-upgrade-lockfile-strip.mjs";
22
29
  import {
23
30
  getCurrentCliVersion,
24
31
  clearCliVersionCache,
@@ -26,6 +33,70 @@ import {
26
33
 
27
34
  export const AUTO_UPGRADE_EXIT_CODE = 99;
28
35
 
36
+ /**
37
+ * Build the argument vector for the global CLI auto-upgrade install.
38
+ * Extracted so the build path can be tested without driving an exec
39
+ * subprocess.
40
+ *
41
+ * `--audit=false` is npm-only. Userconfig pinning lives in the spawned
42
+ * subprocess env (`NPM_CONFIG_USERCONFIG`) rather than a CLI flag,
43
+ * because pnpm does not accept `--userconfig` (open issue
44
+ * pnpm/pnpm#6036) but does honor the env var through its npm-config
45
+ * compatibility layer. Using the env var means a single mechanism
46
+ * works for both agents.
47
+ */
48
+ export function buildGlobalInstallArgs(
49
+ pm: { agent: DetectResult["agent"] },
50
+ packageName: string,
51
+ ): string[] {
52
+ const installArgs = ["--fund=false", "--save-exact"];
53
+ if (pm.agent === "npm") {
54
+ installArgs.push("--audit=false");
55
+ }
56
+ installArgs.push("--force");
57
+ installArgs.push(packageName);
58
+ return installArgs;
59
+ }
60
+
61
+ /**
62
+ * Build the env overlay for a Superblocks-spawned npm/pnpm subprocess.
63
+ * Pins both `NPM_CONFIG_USERCONFIG` (npm + pnpm <= 10) and
64
+ * `PNPM_CONFIG_USERCONFIG` (pnpm 11+) so the install reads its
65
+ * registry/auth lines from the Superblocks-owned `~/.superblocks/npmrc`
66
+ * instead of the default `~/.npmrc`.
67
+ *
68
+ * Why both: pnpm 11 stopped recognising `npm_config_*` env vars (the
69
+ * @pnpm/npm-conf compatibility layer was tightened) and now requires
70
+ * `pnpm_config_*` for its config keys. pnpm <= 10 honors only the
71
+ * `npm_config_*` form; npm has always honored only the `npm_config_*`
72
+ * form. Setting both is harmless on the agent that ignores one of
73
+ * them. Verified by `userconfig-env.integration.test.mts` against both
74
+ * pnpm 10 and pnpm 11 binaries.
75
+ *
76
+ * The CLI-flag alternative (`--userconfig=` for npm,
77
+ * `--config.userconfig=` for pnpm) would require branching on the
78
+ * agent at every call site; the env-overlay approach is uniform.
79
+ */
80
+ export function buildInstallEnv(
81
+ userconfigPath: string,
82
+ logsDir?: string,
83
+ baseEnv: NodeJS.ProcessEnv = process.env,
84
+ ): NodeJS.ProcessEnv {
85
+ return {
86
+ ...baseEnv,
87
+ NPM_CONFIG_USERCONFIG: userconfigPath,
88
+ PNPM_CONFIG_USERCONFIG: userconfigPath,
89
+ // Route npm's per-run debug log to `<app>/.superblocks/logs` so a
90
+ // failed install in the live-edit pod leaves a collectable log in a
91
+ // predictable place. npm writes `<logs-dir>/<ts>-debug-0.log` on every
92
+ // run (pruned to `logs-max`, default 10) and creates the dir if it is
93
+ // missing. pnpm has no equivalent logs-dir config and ignores this var
94
+ // (npm-only by design — see `superblocksLogsPath`). Only set when a
95
+ // logs dir is supplied so callers that don't opt in are unaffected.
96
+ ...(logsDir ? { NPM_CONFIG_LOGS_DIR: logsDir } : {}),
97
+ };
98
+ }
99
+
29
100
  const exec = promisify(child_process.exec);
30
101
  const logger = getLogger();
31
102
  const tracer = getTracer();
@@ -105,17 +176,17 @@ async function upgradeCliWithPackageManager(
105
176
  pm: DetectResult,
106
177
  targetVersion: string,
107
178
  alias?: string,
179
+ hasAnyRegistryConfigured?: boolean,
180
+ // Directory npm should drop its per-run debug log into (the app's
181
+ // `.superblocks/logs`). Optional so callers/tests that don't care keep
182
+ // npm's default `_logs` location.
183
+ logsDir?: string,
108
184
  ): Promise<void> {
109
185
  const packageName = alias
110
186
  ? `@superblocksteam/cli@npm:${alias}@${targetVersion}`
111
187
  : `@superblocksteam/cli@${targetVersion}`;
112
188
 
113
- const installArgs = ["--fund=false", "--save-exact"];
114
- if (pm.agent === "npm") {
115
- installArgs.push("--audit=false");
116
- }
117
- installArgs.push("--force");
118
- installArgs.push(packageName);
189
+ const installArgs = buildGlobalInstallArgs({ agent: pm.agent }, packageName);
119
190
 
120
191
  const installCommand = resolveCommand(pm.agent, "global", installArgs);
121
192
 
@@ -132,13 +203,84 @@ async function upgradeCliWithPackageManager(
132
203
  resolver = resolve;
133
204
  rejecter = reject;
134
205
  });
206
+ // Capture stderr alongside the streaming logger.warn so that, on failure,
207
+ // we can pattern-match the npm error code (APPS-4324) and emit a structured
208
+ // NpmInstallBlocked diagnostic before rejecting. The k8s crash-loop log
209
+ // then carries the reason code rather than just "Failed to upgrade".
210
+ let stderrBuffer = "";
135
211
  const cp = child_process.exec(
136
212
  `${command} ${args.join(" ")}`,
137
213
  {
138
214
  cwd: process.cwd(),
215
+ // Point the package manager at the Superblocks-owned userconfig so
216
+ // the global install resolves through whatever registry config
217
+ // `syncHomeNpmrc` materialised at startup, without touching the
218
+ // developer's `~/.npmrc`. Works for both npm and pnpm. `logsDir`
219
+ // also routes npm's debug log into the app's `.superblocks/logs`.
220
+ env: buildInstallEnv(superblocksNpmrcPath(), logsDir),
139
221
  },
140
222
  (error) => {
141
223
  if (error) {
224
+ // `child_process.exec` aggregates stderr on the rejected error (capped
225
+ // by `maxBuffer`); fall back to it when our `stderr.on('data', …)`
226
+ // listener missed the chunks — a subprocess that fails fast can emit
227
+ // and exit before the listener attaches, leaving `stderrBuffer` empty
228
+ // and dropping the structured diagnostic this PR exists to guarantee.
229
+ const errStderr = (error as { stderr?: unknown }).stderr;
230
+ const stderrText =
231
+ stderrBuffer || (typeof errStderr === "string" ? errStderr : "");
232
+ const blocked = classifyNpmExecStderr(stderrText, {
233
+ requestedPackages: [{ name: packageName }],
234
+ hasAnyRegistryConfigured,
235
+ });
236
+ if (blocked) {
237
+ // Structured fields are grep-able from the message body because
238
+ // the local logger contract limits the meta arg to
239
+ // `{ error: { kind, message, stack } }`. The encoding mirrors the
240
+ // `errorId=...` convention in `dev.mts` so operator alerts and
241
+ // log drains can key on `reason=...`. Logged ahead of the legacy
242
+ // "Failed to upgrade" line so the diagnostic still surfaces if a
243
+ // downstream sink truncates further lines.
244
+ // Pass the raw host through `bucketNpmRegistryHost` so this log
245
+ // emits a bounded enum (`public_npm` | `private` | `unknown`)
246
+ // instead of a customer's private registry hostname. Operational
247
+ // logs are a shared destination; raw hosts there leak customer
248
+ // infra identifiers and create unbounded log facets. This mirrors
249
+ // APPS-4189 (PR #19622) which bucketed the same field for llmobs
250
+ // tags via the same helper.
251
+ //
252
+ // `summary` is npm-emitted free text (first non-code error line —
253
+ // "401 Unauthorized - GET https://...", "self-signed certificate
254
+ // in chain", etc.). The phrasing carries diagnostic value beyond
255
+ // the structured `reason` / `subreason` / `npmErrorCode` facets
256
+ // (CA-bundle hints, proxy hints), but it also still echoes the
257
+ // raw URL/host from the npm stderr line. Redact URLs and bare
258
+ // hostnames before logging so this shared operational sink
259
+ // doesn't leak customer registry hostnames (APPS-4381 PR #19634
260
+ // re-review).
261
+ //
262
+ // We also omit the meta arg here: `getErrorMeta(blocked)` would
263
+ // synthesize `{ error: { kind, message, stack } }` from the
264
+ // `NpmInstallBlocked` instance, and the constructor builds
265
+ // `message` as `"npm install blocked (...) against ${host} [code]"`
266
+ // — same host leak, different field. The structured body fields
267
+ // above already convey the kind (`reason` + `subreason` +
268
+ // `npmErrorCode`) without the hostname, so the meta arg has no
269
+ // signal to add.
270
+ const sanitizedSummary = redactNpmRegistryHostsFromText(
271
+ blocked.summary ?? "",
272
+ );
273
+ logger.error(
274
+ `[npm-install-blocked] reason=${blocked.reason}` +
275
+ ` subreason=${blocked.subreason ?? "n/a"}` +
276
+ ` npmErrorCode=${blocked.npmErrorCode ?? "unknown"}` +
277
+ ` registryHost=${bucketNpmRegistryHost(blocked.registryHost)}` +
278
+ ` httpStatus=${blocked.httpStatus ?? "n/a"}` +
279
+ ` hasAnyRegistryConfigured=${blocked.hasAnyRegistryConfigured ?? "unknown"}` +
280
+ ` packages=${JSON.stringify(blocked.packages)}` +
281
+ ` summary=${JSON.stringify(sanitizedSummary)}`,
282
+ );
283
+ }
142
284
  logger.error(`Failed to upgrade packages to ${targetVersion}`);
143
285
  rejecter(error);
144
286
  } else {
@@ -150,6 +292,7 @@ async function upgradeCliWithPackageManager(
150
292
  logger.info(data);
151
293
  });
152
294
  cp.stderr?.on("data", (data) => {
295
+ stderrBuffer += String(data);
153
296
  logger.warn(data);
154
297
  });
155
298
 
@@ -182,6 +325,12 @@ export async function checkVersionsAndWritePackageJson(
182
325
  config: ApplicationConfigWithTokenConfigAndUserInfo,
183
326
  forceUpgrade: boolean = false,
184
327
  skipCliUpgrade: boolean = false,
328
+ // App working directory, used to route the CLI-upgrade install's npm
329
+ // debug log into `<app>/.superblocks/logs`. Defaults to `process.cwd()`
330
+ // (the dev-server's cwd === app root) so existing callers/tests are
331
+ // unaffected; `dev()` passes its `cwd` explicitly for consistency with
332
+ // the startup install.
333
+ appDir: string = process.cwd(),
185
334
  ): Promise<{
186
335
  cliUpdated: boolean;
187
336
  upgradePromises: Promise<void>[];
@@ -269,6 +418,16 @@ export async function checkVersionsAndWritePackageJson(
269
418
  return;
270
419
  }
271
420
 
421
+ // Failure-mode contrast with AppShell's per-app install path: AppShell
422
+ // routes exec failures through `classifyNpmExecError` and emits a
423
+ // structured `NpmInstallBlocked` diagnostic. The auto-upgrade subprocess
424
+ // below does NOT route through that classifier — it `exec`s
425
+ // `npm install -g @superblocksteam/cli@...` directly. A perimeter block
426
+ // (ECONNREFUSED) surfaces here as a generic `Error` caught by the outer
427
+ // try/catch (`hasFailedToUpgrade = true` → release lock → `process.exit`),
428
+ // which Kubernetes restarts. The operator sees a visible crash-loop with
429
+ // "Error upgrading packages" rather than a silent fallback. For symmetry
430
+ // with the install path, wrap the upgrade exec in the same classifier.
272
431
  try {
273
432
  if (
274
433
  !skipCliUpgrade &&
@@ -291,7 +450,6 @@ export async function checkVersionsAndWritePackageJson(
291
450
  });
292
451
  if (!oclifUpgradeSucceeded) {
293
452
  logger.info(`Falling back to package manager upgrade for CLI`);
294
- // Fall back to package manager upgrade
295
453
  await traceFunction({
296
454
  name: "upgradePackageWithPackageManager - cli",
297
455
  tracer,
@@ -300,11 +458,25 @@ export async function checkVersionsAndWritePackageJson(
300
458
  packageManager,
301
459
  targetVersions.cli,
302
460
  currentCliInfo.alias,
461
+ undefined,
462
+ superblocksLogsPath(appDir),
303
463
  );
304
464
  },
305
465
  });
306
466
  cliUpdated = true;
307
467
  }
468
+ // The freshly fetched tarball may ship bundled lockfiles with
469
+ // stale public-host `resolved` URLs; strip them so the next
470
+ // install does not bypass the active registry. Non-fatal — a
471
+ // failed post-step must not crash the pod after a working
472
+ // upgrade.
473
+ await traceFunction({
474
+ name: "stripUpgradedCliLockfiles",
475
+ tracer,
476
+ fn: async () => {
477
+ await stripUpgradedCliLockfiles(logger);
478
+ },
479
+ });
308
480
  },
309
481
  });
310
482
  upgradePromises.push(cliUpgradePromise);
@@ -0,0 +1,118 @@
1
+ import type { DependencyInstallError } from "@superblocksteam/library-shared/types";
2
+ import {
3
+ classifyNpmExecStderr,
4
+ parseNpmJsonDiagnostic,
5
+ parseNpmJsonError,
6
+ scrubSecrets,
7
+ type NpmInstallBlocked,
8
+ type NpmInstallBlockedReason,
9
+ type ParseContext,
10
+ } from "@superblocksteam/vite-plugin-file-sync/npm-registry";
11
+
12
+ /** Marker thrown by the INITIAL verification install so the dev.mts catch can
13
+ * degrade by ORIGIN (never on classifiability). Carries the classified error. */
14
+ export class InitialInstallFailed extends Error {
15
+ readonly serverError: DependencyInstallError;
16
+ constructor(serverError: DependencyInstallError) {
17
+ super(`initial dependency install failed (${serverError.category})`);
18
+ this.name = "InitialInstallFailed";
19
+ this.serverError = serverError;
20
+ Object.setPrototypeOf(this, new.target.prototype);
21
+ }
22
+ }
23
+
24
+ export interface ExecFailure {
25
+ stdout?: string;
26
+ stderr?: string;
27
+ message?: string;
28
+ }
29
+
30
+ const REASON_TO_CATEGORY: Record<
31
+ NpmInstallBlockedReason,
32
+ DependencyInstallError["category"]
33
+ > = {
34
+ not_in_registry: "not_in_registry",
35
+ registry_auth_failed: "registry_auth_failed",
36
+ registry_unreachable: "registry_unreachable",
37
+ tls_failed: "tls_failed",
38
+ };
39
+
40
+ const DEP_CONFLICT_CODES = /\b(ERESOLVE|ETARGET|EPEERINVALID)\b/;
41
+
42
+ /** Best-effort: pull package specs out of an ERESOLVE/ETARGET `--json` detail. */
43
+ export function parseDependencyConflict(
44
+ text: string,
45
+ ): { name: string; version?: string }[] {
46
+ const out: { name: string; version?: string }[] = [];
47
+ const re =
48
+ /(?:Found:|peer|resolve)\s+(@?[\w./-]+)@("?[^",\s]+"?|undefined)/gi;
49
+ let m: RegExpExecArray | null;
50
+ while ((m = re.exec(text)) !== null) {
51
+ const name = m[1];
52
+ const version = m[2] === "undefined" ? undefined : m[2].replace(/"/g, "");
53
+ if (!out.some((p) => p.name === name)) out.push({ name, version });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function fromBlocked(
59
+ b: NpmInstallBlocked,
60
+ rawError: string,
61
+ ): DependencyInstallError {
62
+ return {
63
+ type: "dev-server/dependency-install",
64
+ timestamp: new Date().toISOString(),
65
+ category: REASON_TO_CATEGORY[b.reason],
66
+ subreason: b.subreason,
67
+ packages: b.packages.map((name) => ({ name })),
68
+ registryHost: b.registryHost,
69
+ npmErrorCode: b.npmErrorCode,
70
+ hasAnyRegistryConfigured: b.hasAnyRegistryConfigured,
71
+ rawError,
72
+ };
73
+ }
74
+
75
+ /** Always returns a DependencyInstallError (never null). */
76
+ export function classifyInitialInstallError(
77
+ failure: ExecFailure,
78
+ ctx: ParseContext,
79
+ ): DependencyInstallError {
80
+ const stdout = failure.stdout ?? "";
81
+ const stderr = failure.stderr ?? "";
82
+ // Use `||` (not `??`) so an empty `stderr` ("" — the default on L:stderr, and
83
+ // common under `--json`) falls through to the exec rejection's `.message`
84
+ // ("Command failed: …") instead of yielding a blank rawError / Details panel.
85
+ const rawError = scrubSecrets(
86
+ parseNpmJsonDiagnostic(stdout) || stderr || failure.message || "",
87
+ );
88
+
89
+ const blockedFromJson = parseNpmJsonError(stdout, ctx);
90
+ if (blockedFromJson) return fromBlocked(blockedFromJson, rawError);
91
+
92
+ const combined = `${stdout}\n${stderr}`;
93
+ const codeMatch = combined.match(DEP_CONFLICT_CODES);
94
+ if (codeMatch) {
95
+ return {
96
+ type: "dev-server/dependency-install",
97
+ timestamp: new Date().toISOString(),
98
+ category: "dependency_conflict",
99
+ packages: parseDependencyConflict(combined),
100
+ npmErrorCode: codeMatch[1],
101
+ hasAnyRegistryConfigured: ctx.hasAnyRegistryConfigured,
102
+ rawError,
103
+ };
104
+ }
105
+
106
+ const blockedFromStderr = classifyNpmExecStderr(stderr, ctx);
107
+ if (blockedFromStderr) return fromBlocked(blockedFromStderr, rawError);
108
+
109
+ const unknownCode = combined.match(/npm (?:error|ERR!) code (\S+)/)?.[1];
110
+ return {
111
+ type: "dev-server/dependency-install",
112
+ timestamp: new Date().toISOString(),
113
+ category: "unknown",
114
+ npmErrorCode: unknownCode,
115
+ hasAnyRegistryConfigured: ctx.hasAnyRegistryConfigured,
116
+ rawError,
117
+ };
118
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import {
4
+ InitialInstallFailed,
5
+ classifyInitialInstallError,
6
+ } from "./dependency-install-classifier.mjs";
7
+
8
+ const ERESOLVE_JSON = JSON.stringify({
9
+ error: {
10
+ code: "ERESOLVE",
11
+ summary: "unable to resolve dependency tree",
12
+ detail:
13
+ 'Found: react-router@undefined\nCould not resolve dependency:\npeer react-router@">=6.4" from @superblocksteam/library@2.0.0-SNAPSHOT',
14
+ },
15
+ });
16
+
17
+ describe("classifyInitialInstallError", () => {
18
+ it("classifies ERESOLVE as dependency_conflict and extracts packages", () => {
19
+ const e = classifyInitialInstallError(
20
+ {
21
+ stdout: ERESOLVE_JSON,
22
+ stderr: "npm error code ERESOLVE",
23
+ message: "Command failed",
24
+ },
25
+ { hasAnyRegistryConfigured: true, requestedPackages: [] },
26
+ );
27
+ expect(e.type).toBe("dev-server/dependency-install");
28
+ expect(e.category).toBe("dependency_conflict");
29
+ expect(e.npmErrorCode).toBe("ERESOLVE");
30
+ expect(e.packages?.some((p) => p.name === "react-router")).toBe(true);
31
+ expect(e.rawError.length).toBeGreaterThan(0);
32
+ });
33
+
34
+ it("maps an E404 registry block to not_in_registry", () => {
35
+ const E404 = JSON.stringify({
36
+ error: {
37
+ code: "E404",
38
+ summary: "Not Found - GET https://reg.corp/foo",
39
+ detail: "'foo@1.0.0' is not in this registry.",
40
+ },
41
+ });
42
+ const e = classifyInitialInstallError(
43
+ { stdout: E404, stderr: "", message: "x" },
44
+ { hasAnyRegistryConfigured: true, requestedPackages: [{ name: "foo" }] },
45
+ );
46
+ expect(e.category).toBe("not_in_registry");
47
+ expect(e.registryHost).toBe("reg.corp");
48
+ });
49
+
50
+ it("falls back to unknown for an unmapped code (still a DependencyInstallError)", () => {
51
+ const e = classifyInitialInstallError(
52
+ {
53
+ stdout: "not json",
54
+ stderr: "npm error code EWEIRD\nsomething broke",
55
+ message: "x",
56
+ },
57
+ {},
58
+ );
59
+ expect(e.category).toBe("unknown");
60
+ expect(e.rawError).toContain("EWEIRD");
61
+ });
62
+
63
+ it("InitialInstallFailed carries the classified error", () => {
64
+ const se = classifyInitialInstallError(
65
+ { stdout: ERESOLVE_JSON, stderr: "", message: "x" },
66
+ {},
67
+ );
68
+ const marker = new InitialInstallFailed(se);
69
+ expect(marker).toBeInstanceOf(Error);
70
+ expect(marker.serverError).toBe(se);
71
+ });
72
+ });