@superblocksteam/sdk 2.0.137 → 2.0.138-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 (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts +1 -1
  3. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  4. package/dist/cli-replacement/automatic-upgrades.js +64 -59
  5. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  6. package/dist/cli-replacement/automatic-upgrades.test.js +54 -33
  7. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  8. package/dist/cli-replacement/dependency-install-classifier.d.mts +26 -1
  9. package/dist/cli-replacement/dependency-install-classifier.d.mts.map +1 -1
  10. package/dist/cli-replacement/dependency-install-classifier.mjs +58 -0
  11. package/dist/cli-replacement/dependency-install-classifier.mjs.map +1 -1
  12. package/dist/cli-replacement/dependency-install-classifier.test.mjs +33 -1
  13. package/dist/cli-replacement/dependency-install-classifier.test.mjs.map +1 -1
  14. package/dist/cli-replacement/dev.d.mts +4 -2
  15. package/dist/cli-replacement/dev.d.mts.map +1 -1
  16. package/dist/cli-replacement/dev.interception.test.mjs +32 -5
  17. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
  18. package/dist/cli-replacement/dev.mjs +142 -65
  19. package/dist/cli-replacement/dev.mjs.map +1 -1
  20. package/dist/cli-replacement/home-npmrc.d.mts.map +1 -1
  21. package/dist/cli-replacement/home-npmrc.mjs +13 -5
  22. package/dist/cli-replacement/home-npmrc.mjs.map +1 -1
  23. package/dist/cli-replacement/home-npmrc.test.mjs +49 -17
  24. package/dist/cli-replacement/home-npmrc.test.mjs.map +1 -1
  25. package/dist/cli-replacement/package-json-snapshot.d.mts +1 -0
  26. package/dist/cli-replacement/package-json-snapshot.d.mts.map +1 -1
  27. package/dist/cli-replacement/package-json-snapshot.mjs +35 -0
  28. package/dist/cli-replacement/package-json-snapshot.mjs.map +1 -1
  29. package/dist/cli-replacement/package-json-snapshot.test.mjs +104 -0
  30. package/dist/cli-replacement/package-json-snapshot.test.mjs.map +1 -1
  31. package/dist/dev-utils/dev-server-metrics.d.mts +17 -7
  32. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  33. package/dist/dev-utils/dev-server-metrics.mjs +18 -12
  34. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  35. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  36. package/dist/dev-utils/dev-server.mjs +15 -6
  37. package/dist/dev-utils/dev-server.mjs.map +1 -1
  38. package/dist/dev-utils/prepare-in-place-restart.d.mts +31 -0
  39. package/dist/dev-utils/prepare-in-place-restart.d.mts.map +1 -0
  40. package/dist/dev-utils/prepare-in-place-restart.mjs +64 -0
  41. package/dist/dev-utils/prepare-in-place-restart.mjs.map +1 -0
  42. package/dist/dev-utils/prepare-in-place-restart.test.d.mts +2 -0
  43. package/dist/dev-utils/prepare-in-place-restart.test.d.mts.map +1 -0
  44. package/dist/dev-utils/prepare-in-place-restart.test.mjs +46 -0
  45. package/dist/dev-utils/prepare-in-place-restart.test.mjs.map +1 -0
  46. package/dist/telemetry/index.d.ts +12 -0
  47. package/dist/telemetry/index.d.ts.map +1 -1
  48. package/dist/telemetry/index.js +26 -12
  49. package/dist/telemetry/index.js.map +1 -1
  50. package/package.json +6 -6
  51. package/src/cli-replacement/automatic-upgrades.test.ts +68 -58
  52. package/src/cli-replacement/automatic-upgrades.ts +81 -59
  53. package/src/cli-replacement/dependency-install-classifier.mts +74 -1
  54. package/src/cli-replacement/dependency-install-classifier.test.mts +49 -0
  55. package/src/cli-replacement/dev.interception.test.mts +39 -7
  56. package/src/cli-replacement/dev.mts +154 -62
  57. package/src/cli-replacement/home-npmrc.mts +12 -4
  58. package/src/cli-replacement/home-npmrc.test.mts +57 -17
  59. package/src/cli-replacement/package-json-snapshot.mts +50 -0
  60. package/src/cli-replacement/package-json-snapshot.test.mts +121 -0
  61. package/src/dev-utils/dev-server-metrics.mts +48 -21
  62. package/src/dev-utils/dev-server.mts +22 -5
  63. package/src/dev-utils/prepare-in-place-restart.mts +99 -0
  64. package/src/dev-utils/prepare-in-place-restart.test.mts +69 -0
  65. package/src/telemetry/index.ts +24 -10
  66. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superblocksteam/sdk",
3
- "version": "2.0.137",
3
+ "version": "2.0.138-next.1",
4
4
  "description": "Superblocks JS SDK",
5
5
  "homepage": "https://www.superblocks.com",
6
6
  "license": "Superblocks Community Software License",
@@ -48,11 +48,11 @@
48
48
  "vite-tsconfig-paths": "^6.0.4",
49
49
  "winston": "^3.17.0",
50
50
  "yaml": "^2.7.1",
51
- "@superblocksteam/shared": "0.9594.4",
52
- "@superblocksteam/telemetry": "2.0.137",
53
- "@superblocksteam/library-shared": "2.0.137",
54
- "@superblocksteam/util": "2.0.137",
55
- "@superblocksteam/vite-plugin-file-sync": "2.0.137"
51
+ "@superblocksteam/util": "2.0.138-next.1",
52
+ "@superblocksteam/shared": "0.9595.1",
53
+ "@superblocksteam/vite-plugin-file-sync": "2.0.138-next.1",
54
+ "@superblocksteam/library-shared": "2.0.138-next.1",
55
+ "@superblocksteam/telemetry": "2.0.138-next.1"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@eslint/js": "^9.39.2",
@@ -25,6 +25,7 @@ import {
25
25
  buildInstallEnv,
26
26
  checkVersionsAndWritePackageJson,
27
27
  } from "./automatic-upgrades.js";
28
+ import { UpgradeFailed } from "./dependency-install-classifier.mjs";
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Mocks
@@ -564,9 +565,7 @@ describe("checkVersionsAndWritePackageJson", () => {
564
565
  new Error("Command failed: pnpm install ..."),
565
566
  );
566
567
 
567
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
568
- /Command failed: pnpm install/,
569
- );
568
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
570
569
 
571
570
  // The structured log line fires before the rejection so the operator
572
571
  // sees the reason code in the crash-loop output. Reason is encoded
@@ -604,9 +603,7 @@ describe("checkVersionsAndWritePackageJson", () => {
604
603
  new Error("Command failed: pnpm install ..."),
605
604
  );
606
605
 
607
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
608
- /Command failed: pnpm install/,
609
- );
606
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
610
607
 
611
608
  expect(loggerMock.error).toHaveBeenCalledWith(
612
609
  expect.stringMatching(
@@ -640,9 +637,7 @@ describe("checkVersionsAndWritePackageJson", () => {
640
637
  new Error("Command failed: pnpm install ..."),
641
638
  );
642
639
 
643
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
644
- /Command failed: pnpm install/,
645
- );
640
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
646
641
 
647
642
  const blockedCall = loggerMock.error.mock.calls.find(
648
643
  (c) =>
@@ -670,9 +665,7 @@ describe("checkVersionsAndWritePackageJson", () => {
670
665
  new Error("Command failed: pnpm install ..."),
671
666
  );
672
667
 
673
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
674
- /Command failed: pnpm install/,
675
- );
668
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
676
669
 
677
670
  const blockedCall = loggerMock.error.mock.calls.find(
678
671
  (c) =>
@@ -717,9 +710,7 @@ describe("checkVersionsAndWritePackageJson", () => {
717
710
  new Error("Command failed: pnpm install ..."),
718
711
  );
719
712
 
720
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
721
- /Command failed: pnpm install/,
722
- );
713
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
723
714
 
724
715
  const blockedCall = loggerMock.error.mock.calls.find(
725
716
  (c) =>
@@ -758,9 +749,7 @@ describe("checkVersionsAndWritePackageJson", () => {
758
749
  new Error("Command failed: pnpm install ..."),
759
750
  );
760
751
 
761
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
762
- /Command failed: pnpm install/,
763
- );
752
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
764
753
 
765
754
  expect(loggerMock.error).toHaveBeenCalledWith(
766
755
  expect.stringMatching(/\[npm-install-blocked\] reason=not_in_registry/),
@@ -776,9 +765,7 @@ describe("checkVersionsAndWritePackageJson", () => {
776
765
  new Error("Command failed: pnpm install ..."),
777
766
  );
778
767
 
779
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
780
- /Command failed: pnpm install/,
781
- );
768
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
782
769
 
783
770
  // The classifier returns null for ERESOLVE — no structured log fires.
784
771
  expect(loggerMock.error).not.toHaveBeenCalledWith(
@@ -809,9 +796,7 @@ describe("checkVersionsAndWritePackageJson", () => {
809
796
  ].join("\n"),
810
797
  });
811
798
 
812
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
813
- /Command failed: pnpm install/,
814
- );
799
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
815
800
 
816
801
  expect(loggerMock.error).toHaveBeenCalledWith(
817
802
  expect.stringMatching(
@@ -834,9 +819,7 @@ describe("checkVersionsAndWritePackageJson", () => {
834
819
  new Error("Command failed: pnpm install ..."),
835
820
  );
836
821
 
837
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
838
- /Command failed: pnpm install/,
839
- );
822
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
840
823
 
841
824
  expect(loggerMock.error).toHaveBeenCalledWith(
842
825
  expect.stringMatching(
@@ -928,9 +911,7 @@ describe("checkVersionsAndWritePackageJson", () => {
928
911
  new Error("Command failed: pnpm install ..."),
929
912
  );
930
913
 
931
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
932
- /Command failed: pnpm install/,
933
- );
914
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
934
915
 
935
916
  expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
936
917
  const args = recordNpmInstallMock.mock.calls[0]![0];
@@ -958,9 +939,7 @@ describe("checkVersionsAndWritePackageJson", () => {
958
939
  new Error("Command failed: pnpm install ..."),
959
940
  );
960
941
 
961
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
962
- /Command failed: pnpm install/,
963
- );
942
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
964
943
 
965
944
  expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
966
945
  const args = recordNpmInstallMock.mock.calls[0]![0];
@@ -974,6 +953,44 @@ describe("checkVersionsAndWritePackageJson", () => {
974
953
  expect(args.registryHost).toBe(privateHost);
975
954
  });
976
955
 
956
+ it("threads hasAnyRegistryConfigured into the classified error and the metric so private-registry upgrade failures get the admin guidance, not the public-npm copy", async () => {
957
+ // E404 against a private host = the org has a registry configured but it
958
+ // can't serve the target version. The caller derives
959
+ // hasAnyRegistryConfigured=true and forwards it here; without the
960
+ // threading both the overlay copy and the metric would wrongly read
961
+ // "no registry configured".
962
+ await mockNpmFallbackExec(
963
+ [
964
+ "npm error code E404",
965
+ "npm error 404 Not Found - GET https://npm.private.example.com/@superblocksteam%2fcli - Not found",
966
+ ].join("\n"),
967
+ new Error("Command failed: pnpm install ..."),
968
+ );
969
+
970
+ const result = await checkVersionsAndWritePackageJson(
971
+ mockLockService,
972
+ mockConfig,
973
+ false,
974
+ false,
975
+ // appDir omitted (defaults to process.cwd()); pass the registry
976
+ // tri-state in the new trailing position.
977
+ undefined,
978
+ true,
979
+ );
980
+ const err = await Promise.all(result.upgradePromises).catch((e) => e);
981
+
982
+ // The classified error drives the overlay copy: true → "ask an org
983
+ // admin to add the version to your registry".
984
+ expect(err).toBeInstanceOf(UpgradeFailed);
985
+ expect(err.serverError.category).toBe("not_in_registry");
986
+ expect(err.serverError.hasAnyRegistryConfigured).toBe(true);
987
+
988
+ // The same signal lands on the install metric so `auto_upgrade`
989
+ // failures attribute to the configured-registry bucket.
990
+ expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
991
+ expect(recordNpmInstallMock.mock.calls[0]![0].configured).toBe(true);
992
+ });
993
+
977
994
  it("APPS-4544: emits outcome=other for an unclassified failure (ERESOLVE) so the metric reflects the failure even without a NpmInstallBlocked match", async () => {
978
995
  await mockNpmFallbackExec(
979
996
  [
@@ -983,9 +1000,7 @@ describe("checkVersionsAndWritePackageJson", () => {
983
1000
  new Error("Command failed: pnpm install ..."),
984
1001
  );
985
1002
 
986
- await expect(runAndAwaitUpgrade()).rejects.toThrow(
987
- /Command failed: pnpm install/,
988
- );
1003
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(UpgradeFailed);
989
1004
 
990
1005
  expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
991
1006
  const args = recordNpmInstallMock.mock.calls[0]![0];
@@ -1053,7 +1068,7 @@ describe("checkVersionsAndWritePackageJson", () => {
1053
1068
  // The validation failure rejects the upgrade promise (so dev.mts
1054
1069
  // won't restart into a stale CLI)...
1055
1070
  await expect(Promise.all(result.upgradePromises)).rejects.toThrow(
1056
- /CLI version mismatch after upgrade/,
1071
+ UpgradeFailed,
1057
1072
  );
1058
1073
 
1059
1074
  // ...and the metric still fires exactly once, flipped to `other`.
@@ -1240,26 +1255,21 @@ describe("checkVersionsAndWritePackageJson", () => {
1240
1255
  // it so the test doesn't surface as an unhandled rejection,
1241
1256
  // then assert on the span attribute the `catch` set.
1242
1257
  await expect(Promise.all(result.upgradePromises)).rejects.toThrow(
1243
- /Command failed: pnpm install/,
1258
+ UpgradeFailed,
1244
1259
  );
1245
1260
 
1246
1261
  expect(spanSetAttributeMock).toHaveBeenCalledWith("outcome", "failure");
1247
1262
  });
1248
1263
 
1249
- // APPS-4544 (Bugbot 613adfc7): when the detected package manager can't be
1250
- // turned into a global install command, `resolveCommand` returns
1251
- // undefined. The old code logged and returned without throwing, so the
1252
- // npm fallback became a silent no-op the `upgradeCli` span still
1253
- // recorded `outcome=success` and no `recordNpmInstall` fired, producing a
1254
- // trace that claimed a successful auto-upgrade with no matching install
1255
- // metric. The fix emits a `runner=auto_upgrade` failure sample and flips
1256
- // the span to `outcome=failure` so the no-op is visible in metrics and
1257
- // traces but it does NOT throw, because a rejected upgrade promise
1258
- // routes through `dev.mts` to `process.exit(1)` and crash-loops the
1259
- // live-edit pod. The no-op dev-server restart that still follows, graceful
1260
- // degrade, and user surfacing of the failed upgrade are tracked by
1261
- // APPS-4457, so this test asserts the promise RESOLVES.
1262
- it("APPS-4544: emits outcome=other and fails the span (without throwing) when no global install command can be resolved", async () => {
1264
+ // When the detected package manager can't be turned into a global install
1265
+ // command, `resolveCommand` returns undefined. It still emits a
1266
+ // `runner=auto_upgrade` failure sample and flips the `upgradeCli` span to
1267
+ // `outcome=failure` so the no-op stays visible in metrics and traces, and
1268
+ // it throws `UpgradeFailed` so the upgrade promise REJECTS. The dev.mts
1269
+ // join routes that marker to the degrade path (keep serving the current
1270
+ // version) NOT `process.exit(1)` and the rejection also stops the
1271
+ // `cliUpdated=true` enqueue from triggering a pointless restart loop.
1272
+ it("emits outcome=other, fails the span, and rejects with UpgradeFailed when no global install command can be resolved", async () => {
1263
1273
  const { exec } = await import("node:child_process");
1264
1274
  const execMock = vi.mocked(exec);
1265
1275
  execMock.mockImplementation(((
@@ -1302,12 +1312,12 @@ describe("checkVersionsAndWritePackageJson", () => {
1302
1312
  false,
1303
1313
  );
1304
1314
 
1305
- // The upgrade promise must RESOLVE, not reject: a no-resolvable-command
1306
- // upgrade is a no-op the live-edit pod degrades through, not a throw that
1307
- // `dev.mts` would route to `process.exit(1)` (crash-loop). Graceful
1308
- // degrade + user surfacing of the failed upgrade is tracked by APPS-4457.
1309
- await expect(Promise.all(result.upgradePromises)).resolves.toHaveLength(
1310
- 1,
1315
+ // The upgrade promise must REJECT with the `UpgradeFailed` marker so the
1316
+ // dev.mts join degrades (keep serving the current version) instead of
1317
+ // restarting on the no-op. `handleStartupError` maps the marker to
1318
+ // "degrade", so this rejection never reaches `process.exit(1)`.
1319
+ await expect(Promise.all(result.upgradePromises)).rejects.toBeInstanceOf(
1320
+ UpgradeFailed,
1311
1321
  );
1312
1322
 
1313
1323
  // A failure sample is still recorded for the auto_upgrade runner so the
@@ -25,6 +25,10 @@ import type { ResponseMeta } from "../socket/handlers.js";
25
25
  import { getTracer } from "../telemetry/index.js";
26
26
  import { getErrorMeta, getLogger } from "../telemetry/logging.js";
27
27
  import type { ApplicationConfigWithTokenConfigAndUserInfo } from "../types/common.js";
28
+ import {
29
+ classifyUpgradeError,
30
+ UpgradeFailed,
31
+ } from "./dependency-install-classifier.mjs";
28
32
  import { superblocksLogsPath, superblocksNpmrcPath } from "./home-npmrc.mjs";
29
33
  import { stripUpgradedCliLockfiles } from "./post-upgrade-lockfile-strip.mjs";
30
34
  import {
@@ -176,12 +180,14 @@ async function upgradeCliWithOclif(targetVersion: string): Promise<boolean> {
176
180
  }
177
181
  }
178
182
 
179
- // Returns `true` when an install command actually ran and post-install
180
- // validation passed (every other failure throws). Returns `false` only when no
181
- // global install command could be resolved for the detected package manager — a
182
- // no-op the caller marks as a failed span WITHOUT throwing, so the live-edit
183
- // pod degrades on the current version instead of crash-looping. Proper graceful
184
- // degrade + surfacing the failed upgrade to the user is tracked by APPS-4457.
183
+ // Resolves when an install command ran and post-install validation passed.
184
+ // Every failure throws `UpgradeFailed` carrying a classified, non-blocking
185
+ // `DependencyUpgradeError` the no-command branch, an exec failure, and a
186
+ // post-install version mismatch all degrade the same way. The dev.mts join
187
+ // routes that marker to the degrade path, so the live-edit pod keeps serving
188
+ // the currently installed version instead of crash-looping. The auto-upgrade
189
+ // is best-effort: the running version still works, so a failed upgrade is
190
+ // never fatal.
185
191
  async function upgradeCliWithPackageManager(
186
192
  pm: DetectResult,
187
193
  targetVersion: string,
@@ -191,35 +197,33 @@ async function upgradeCliWithPackageManager(
191
197
  // `.superblocks/logs`). Optional so callers/tests that don't care keep
192
198
  // npm's default `_logs` location.
193
199
  logsDir?: string,
194
- ): Promise<boolean> {
200
+ // Currently installed version, carried into the classified error so the
201
+ // overlay can say "still on X; couldn't reach Y". Best-effort context.
202
+ currentVersion?: string,
203
+ ): Promise<void> {
195
204
  const packageName = alias
196
205
  ? `@superblocksteam/cli@npm:${alias}@${targetVersion}`
197
206
  : `@superblocksteam/cli@${targetVersion}`;
198
207
 
208
+ // Context shared by every classification path below: the package being
209
+ // upgraded plus whether a registry is configured.
210
+ const parseCtx = {
211
+ requestedPackages: [{ name: packageName }],
212
+ hasAnyRegistryConfigured,
213
+ };
214
+ const versions = { currentVersion, targetVersion };
215
+
199
216
  const installArgs = buildGlobalInstallArgs({ agent: pm.agent }, packageName);
200
217
 
201
218
  const installCommand = resolveCommand(pm.agent, "global", installArgs);
202
219
 
203
220
  if (!installCommand) {
204
- // APPS-4544 (Bugbot 613adfc7): we couldn't even build a global install
205
- // command for the detected package manager, so no upgrade happened.
206
- // Before this PR added the `upgradeCli` span `outcome` attribute and the
207
- // `superblocks.npm.install.*` metric, this no-op left no telemetry: the
208
- // span kept its default `outcome=success` and no install metric was
209
- // emitted, so an auto-upgrade that did nothing looked successful.
210
- //
211
- // Emit a failure sample so the histogram carries a `runner="auto_upgrade"`
212
- // failure sample for this path, then return `false` so the caller flips
213
- // the `upgradeCli` span to `outcome=failure`. `durationMs` is 0 because no
214
- // install process ran; `outcome="other"` mirrors the unclassified-failure
215
- // fallback used by the exec/validation paths below.
216
- //
217
- // We deliberately do NOT throw: a rejected upgrade promise routes through
218
- // `dev.mts` to `process.exit(1)`, crash-looping the live-edit pod. The
219
- // no-op dev-server restart that still follows (`cliUpdated` is set
220
- // unconditionally at enqueue time in `checkVersionsAndWritePackageJson`),
221
- // plus degrading gracefully (continue on the current version) and
222
- // surfacing the failed upgrade to the user, are tracked by APPS-4457.
221
+ // We couldn't build a global install command for the detected package
222
+ // manager, so no upgrade ran. Emit a failure sample (durationMs 0 — nothing
223
+ // ran) so the metric doesn't read this no-op as a success, then throw
224
+ // `UpgradeFailed` so the dev.mts join degrades (keep serving the current
225
+ // version) instead of leaving `cliUpdated=true` to trigger a pointless
226
+ // dev-server restart loop.
223
227
  logger.error("Could not determine how to upgrade Superblocks packages.");
224
228
  recordNpmInstall({
225
229
  runner: "auto_upgrade",
@@ -228,7 +232,15 @@ async function upgradeCliWithPackageManager(
228
232
  registryHost: undefined,
229
233
  durationMs: 0,
230
234
  });
231
- return false;
235
+ throw new UpgradeFailed(
236
+ classifyUpgradeError(
237
+ {
238
+ message: "Could not determine how to upgrade Superblocks packages.",
239
+ },
240
+ parseCtx,
241
+ versions,
242
+ ),
243
+ );
232
244
  }
233
245
 
234
246
  const { command, args } = installCommand;
@@ -284,10 +296,7 @@ async function upgradeCliWithPackageManager(
284
296
  const errStderr = (error as { stderr?: unknown }).stderr;
285
297
  const stderrText =
286
298
  stderrBuffer || (typeof errStderr === "string" ? errStderr : "");
287
- const blocked = classifyNpmExecStderr(stderrText, {
288
- requestedPackages: [{ name: packageName }],
289
- hasAnyRegistryConfigured,
290
- });
299
+ const blocked = classifyNpmExecStderr(stderrText, parseCtx);
291
300
  // Surface the structured `NpmInstallBlocked.reason` (e.g.
292
301
  // `registry_unreachable`, `registry_auth_failed`) and raw host to
293
302
  // the install metric below; the emitter coerces both at the emit
@@ -399,7 +408,24 @@ async function upgradeCliWithPackageManager(
399
408
  if (outcome === "success") {
400
409
  outcome = "other";
401
410
  }
402
- throw err;
411
+ // Both the exec failure (rejected with the raw npm error, re-thrown by
412
+ // `await promise`) and a post-install version mismatch land here. Classify
413
+ // into a non-blocking `DependencyUpgradeError` and throw the `UpgradeFailed`
414
+ // marker so the dev.mts join degrades instead of crash-looping. `stderrBuffer`
415
+ // holds the streamed npm stderr; fall back to the error's own fields when a
416
+ // fast-failing subprocess exited before the listener attached.
417
+ const e = err as { stdout?: string; stderr?: string; message?: string };
418
+ throw new UpgradeFailed(
419
+ classifyUpgradeError(
420
+ {
421
+ stdout: e.stdout,
422
+ stderr: stderrBuffer || e.stderr,
423
+ message: e.message,
424
+ },
425
+ parseCtx,
426
+ versions,
427
+ ),
428
+ );
403
429
  } finally {
404
430
  // Both the happy path and every throw above (exec failure → callback
405
431
  // sets outcome, post-install validation throw → catch sets outcome to
@@ -418,9 +444,7 @@ async function upgradeCliWithPackageManager(
418
444
  }
419
445
 
420
446
  // Reached only when the install ran and post-install validation passed
421
- // (every failure above throws). Signals the caller that a real upgrade
422
- // happened so it leaves the `upgradeCli` span `outcome=success`.
423
- return true;
447
+ // (every failure above throws `UpgradeFailed`).
424
448
  }
425
449
 
426
450
  export async function checkVersionsAndWritePackageJson(
@@ -434,6 +458,14 @@ export async function checkVersionsAndWritePackageJson(
434
458
  // unaffected; `dev()` passes its `cwd` explicitly for consistency with
435
459
  // the startup install.
436
460
  appDir: string = process.cwd(),
461
+ // Whether the org routes installs through a configured npm registry, as a
462
+ // tri-state: `true` (configured/stale), `false` (deliberately none),
463
+ // `undefined` (couldn't resolve). The caller derives it from the same
464
+ // registry config the startup install classifier uses and forwards it into
465
+ // the CLI auto-upgrade, so a failure classifies/renders with the right
466
+ // guidance ("ask an org admin" vs "temporary") and records the correct
467
+ // `configured` install metric instead of always defaulting to `false`.
468
+ hasAnyRegistryConfigured?: boolean,
437
469
  ): Promise<{
438
470
  cliUpdated: boolean;
439
471
  upgradePromises: Promise<void>[];
@@ -521,16 +553,16 @@ export async function checkVersionsAndWritePackageJson(
521
553
  return;
522
554
  }
523
555
 
524
- // Failure-mode contrast with AppShell's per-app install path: AppShell
525
- // routes exec failures through `classifyNpmExecError` and emits a
526
- // structured `NpmInstallBlocked` diagnostic. The auto-upgrade subprocess
527
- // below does NOT route through that classifier it `exec`s
528
- // `npm install -g @superblocksteam/cli@...` directly. A perimeter block
529
- // (ECONNREFUSED) surfaces here as a generic `Error` caught by the outer
530
- // try/catch (`hasFailedToUpgrade = true` release lock `process.exit`),
531
- // which Kubernetes restarts. The operator sees a visible crash-loop with
532
- // "Error upgrading packages" rather than a silent fallback. For symmetry
533
- // with the install path, wrap the upgrade exec in the same classifier.
556
+ // The auto-upgrade runs the global `npm install -g @superblocksteam/cli@...`
557
+ // and classifies its failures the same way the per-app install does. On
558
+ // any failure (network block, registry/auth/TLS error, version mismatch,
559
+ // or no resolvable install command) it throws the `UpgradeFailed` marker
560
+ // carrying a classified `DependencyUpgradeError`. The dev.mts join routes
561
+ // that marker to the degrade path, so the pod keeps serving the current
562
+ // version instead of crash-looping the upgrade is best-effort. The
563
+ // early `process.exit(1)` below is reserved for genuinely-unusable cases
564
+ // (no package manager, an unparseable version, a corrupt package.json)
565
+ // that are not upgrade failures.
534
566
  try {
535
567
  if (
536
568
  !skipCliUpgrade &&
@@ -600,30 +632,20 @@ export async function checkVersionsAndWritePackageJson(
600
632
  logger.info(
601
633
  `Falling back to package manager upgrade for CLI`,
602
634
  );
603
- const cliUpgradeRan = await traceFunction({
635
+ await traceFunction({
604
636
  name: "upgradePackageWithPackageManager - cli",
605
637
  tracer,
606
638
  fn: async () => {
607
- return await upgradeCliWithPackageManager(
639
+ await upgradeCliWithPackageManager(
608
640
  packageManager,
609
641
  targetVersions.cli,
610
642
  currentCliInfo.alias,
611
- undefined,
643
+ hasAnyRegistryConfigured,
612
644
  superblocksLogsPath(appDir),
645
+ currentCliInfo.version,
613
646
  );
614
647
  },
615
648
  });
616
- if (!cliUpgradeRan) {
617
- // No global install command could be resolved → the CLI
618
- // upgrade was a no-op (the failure metric was already
619
- // emitted inline). Flip the span to `failure` so the trace
620
- // is accurate, but do NOT throw: throwing routes through
621
- // `dev.mts` to `process.exit(1)` and crash-loops the pod.
622
- // The no-op dev-server restart that still follows
623
- // (`cliUpdated = true` below), graceful degrade, and user
624
- // surfacing of the failed upgrade are tracked by APPS-4457.
625
- outcome = "failure";
626
- }
627
649
  }
628
650
  // The freshly fetched tarball may ship bundled lockfiles with
629
651
  // stale public-host `resolved` URLs; strip them so the next
@@ -1,4 +1,7 @@
1
- import type { DependencyInstallError } from "@superblocksteam/library-shared/types";
1
+ import type {
2
+ DependencyInstallError,
3
+ DependencyUpgradeError,
4
+ } from "@superblocksteam/library-shared/types";
2
5
  import {
3
6
  classifyNpmExecStderr,
4
7
  parseNpmJsonDiagnostic,
@@ -23,6 +26,19 @@ export class InitialInstallFailed extends Error {
23
26
  }
24
27
  }
25
28
 
29
+ /** Marker thrown by the best-effort CLI/library auto-upgrade so the dev.mts
30
+ * catch can DEGRADE (keep serving the current version) instead of crash-looping
31
+ * the pod. Carries the classified, non-blocking upgrade error. */
32
+ export class UpgradeFailed extends Error {
33
+ readonly serverError: DependencyUpgradeError;
34
+ constructor(serverError: DependencyUpgradeError) {
35
+ super(`dependency upgrade failed (${serverError.category})`);
36
+ this.name = "UpgradeFailed";
37
+ this.serverError = serverError;
38
+ Object.setPrototypeOf(this, new.target.prototype);
39
+ }
40
+ }
41
+
26
42
  export interface ExecFailure {
27
43
  stdout?: string;
28
44
  stderr?: string;
@@ -130,3 +146,60 @@ export function classifyInitialInstallError(
130
146
  rawError,
131
147
  };
132
148
  }
149
+
150
+ /**
151
+ * Classify a failed CLI/library auto-upgrade exec into a non-blocking
152
+ * `DependencyUpgradeError`. Always returns an error (never null) — every
153
+ * upgrade failure degrades, because the pod keeps running the current version.
154
+ *
155
+ * Mirrors `classifyInitialInstallError` but maps to the upgrade error's
156
+ * narrower category set: registry/network reasons keep their classification;
157
+ * everything else (no install command, post-install version mismatch,
158
+ * unclassified exec error) folds to `unknown`. We deliberately do NOT surface
159
+ * `dependency_conflict` / `disk_full` here — a global `-g` CLI install does not
160
+ * resolve the app's dependency tree, and the remediation is uniform (an org
161
+ * admin makes the target version installable), so finer buckets would not
162
+ * change the user-facing guidance.
163
+ */
164
+ export function classifyUpgradeError(
165
+ failure: ExecFailure,
166
+ ctx: ParseContext,
167
+ versions: { currentVersion?: string; targetVersion?: string } = {},
168
+ ): DependencyUpgradeError {
169
+ const stdout = failure.stdout ?? "";
170
+ const stderr = failure.stderr ?? "";
171
+ const rawError = scrubSecrets(
172
+ parseNpmJsonDiagnostic(stdout) || stderr || failure.message || "",
173
+ );
174
+
175
+ const base = {
176
+ type: "dev-server/dependency-upgrade" as const,
177
+ timestamp: new Date().toISOString(),
178
+ currentVersion: versions.currentVersion,
179
+ targetVersion: versions.targetVersion,
180
+ rawError,
181
+ };
182
+
183
+ const blocked =
184
+ parseNpmJsonError(stdout, ctx) ?? classifyNpmExecStderr(stderr, ctx);
185
+ if (blocked) {
186
+ return {
187
+ ...base,
188
+ // `NpmInstallBlockedReason` is exactly the four registry/network reasons,
189
+ // each of which is also a `DependencyUpgradeError` category, so the reason
190
+ // doubles as the category directly (REASON_TO_CATEGORY is identity here).
191
+ category: blocked.reason,
192
+ npmErrorCode: blocked.npmErrorCode,
193
+ hasAnyRegistryConfigured: blocked.hasAnyRegistryConfigured,
194
+ };
195
+ }
196
+
197
+ const combined = `${stdout}\n${stderr}`;
198
+ const unknownCode = combined.match(/npm (?:error|ERR!) code (\S+)/)?.[1];
199
+ return {
200
+ ...base,
201
+ category: "unknown",
202
+ npmErrorCode: unknownCode,
203
+ hasAnyRegistryConfigured: ctx.hasAnyRegistryConfigured,
204
+ };
205
+ }
@@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest";
2
2
 
3
3
  import {
4
4
  InitialInstallFailed,
5
+ UpgradeFailed,
5
6
  classifyInitialInstallError,
7
+ classifyUpgradeError,
6
8
  } from "./dependency-install-classifier.mjs";
7
9
 
8
10
  const ERESOLVE_JSON = JSON.stringify({
@@ -109,3 +111,50 @@ describe("classifyInitialInstallError", () => {
109
111
  expect(marker.serverError).toBe(se);
110
112
  });
111
113
  });
114
+
115
+ describe("classifyUpgradeError", () => {
116
+ it("maps a registry block to its reason and carries the versions", () => {
117
+ const e = classifyUpgradeError(
118
+ {
119
+ stdout: "",
120
+ stderr:
121
+ "npm error code E401\nnpm error 401 Unauthorized - GET https://reg.corp/@superblocksteam%2fcli",
122
+ message: "Command failed",
123
+ },
124
+ { hasAnyRegistryConfigured: true },
125
+ { currentVersion: "2.0.133", targetVersion: "2.0.134" },
126
+ );
127
+ expect(e.type).toBe("dev-server/dependency-upgrade");
128
+ expect(e.category).toBe("registry_auth_failed");
129
+ expect(e.currentVersion).toBe("2.0.133");
130
+ expect(e.targetVersion).toBe("2.0.134");
131
+ expect(e.rawError.length).toBeGreaterThan(0);
132
+ });
133
+
134
+ it("folds anything unclassified to unknown — an odd exec code or the no-command message", () => {
135
+ const exec = classifyUpgradeError(
136
+ {
137
+ stdout: "",
138
+ stderr: "npm error code EWEIRD\nsomething broke",
139
+ message: "Command failed",
140
+ },
141
+ {},
142
+ );
143
+ expect(exec.category).toBe("unknown");
144
+ expect(exec.npmErrorCode).toBe("EWEIRD");
145
+
146
+ const noCommand = classifyUpgradeError(
147
+ { message: "Could not determine how to upgrade Superblocks packages." },
148
+ {},
149
+ );
150
+ expect(noCommand.category).toBe("unknown");
151
+ expect(noCommand.rawError).toContain("Could not determine");
152
+ });
153
+
154
+ it("UpgradeFailed carries the classified error", () => {
155
+ const se = classifyUpgradeError({ stderr: "npm error code EWEIRD" }, {});
156
+ const marker = new UpgradeFailed(se);
157
+ expect(marker).toBeInstanceOf(Error);
158
+ expect(marker.serverError).toBe(se);
159
+ });
160
+ });