@superblocksteam/sdk 2.0.137-next.0 → 2.0.138-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -1,16 +1,23 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
 
3
- import { InitialInstallFailed } from "./dependency-install-classifier.mjs";
3
+ import {
4
+ InitialInstallFailed,
5
+ UpgradeFailed,
6
+ } from "./dependency-install-classifier.mjs";
4
7
  import { handleStartupError } from "./dev.mjs";
5
8
 
6
- // Mock the metrics module so we can assert recordInitialInstallFailure calls.
7
- // vi.mock is hoisted, so we use vi.hoisted to declare the spy before the factory.
8
- const { recordInitialInstallFailureMock } = vi.hoisted(() => ({
9
- recordInitialInstallFailureMock: vi.fn(),
10
- }));
9
+ // Mock the metrics module so we can assert recordInitialInstallFailure /
10
+ // recordUpgradeFailure calls. vi.mock is hoisted, so we use vi.hoisted to
11
+ // declare the spies before the factory.
12
+ const { recordInitialInstallFailureMock, recordUpgradeFailureMock } =
13
+ vi.hoisted(() => ({
14
+ recordInitialInstallFailureMock: vi.fn(),
15
+ recordUpgradeFailureMock: vi.fn(),
16
+ }));
11
17
  vi.mock("../dev-utils/dev-server-metrics.mjs", () => ({
12
18
  devServerMetrics: {
13
19
  recordInitialInstallFailure: recordInitialInstallFailureMock,
20
+ recordUpgradeFailure: recordUpgradeFailureMock,
14
21
  flush: vi.fn(),
15
22
  recordEndpoint: vi.fn(),
16
23
  recordSocketUpgrade: vi.fn(),
@@ -26,6 +33,7 @@ const makeLogger = () =>
26
33
 
27
34
  beforeEach(() => {
28
35
  recordInitialInstallFailureMock.mockClear();
36
+ recordUpgradeFailureMock.mockClear();
29
37
  });
30
38
 
31
39
  describe("handleStartupError", () => {
@@ -67,7 +75,7 @@ describe("handleStartupError", () => {
67
75
  expect(recordInitialInstallFailureMock).not.toHaveBeenCalled();
68
76
  });
69
77
 
70
- it("exits for a non-marker error (upgrade / lock / sync)", () => {
78
+ it("exits for a non-marker error (lock / sync / genuinely-unusable upgrade env)", () => {
71
79
  const status = { serverErrors: [] as any[] };
72
80
  const decision = handleStartupError(
73
81
  new Error("lock failed"),
@@ -77,4 +85,28 @@ describe("handleStartupError", () => {
77
85
  expect(decision).toBe("exit");
78
86
  expect(status.serverErrors).toHaveLength(0);
79
87
  });
88
+
89
+ it("records + does NOT exit for UpgradeFailed (best-effort upgrade degrades)", () => {
90
+ const status = { serverErrors: [] as any[] };
91
+ const marker = new UpgradeFailed({
92
+ type: "dev-server/dependency-upgrade",
93
+ timestamp: "t",
94
+ category: "not_in_registry",
95
+ npmErrorCode: "ETARGET",
96
+ hasAnyRegistryConfigured: true,
97
+ currentVersion: "2.0.133",
98
+ targetVersion: "2.0.134",
99
+ rawError: "x",
100
+ });
101
+ const decision = handleStartupError(marker, status, makeLogger());
102
+ expect(decision).toBe("degrade");
103
+ expect(status.serverErrors).toHaveLength(1);
104
+ expect(recordUpgradeFailureMock).toHaveBeenCalledWith({
105
+ category: "not_in_registry",
106
+ npmErrorCode: "ETARGET",
107
+ hasAnyRegistryConfigured: true,
108
+ });
109
+ // The upgrade failure must not be miscounted as an initial-install failure.
110
+ expect(recordInitialInstallFailureMock).not.toHaveBeenCalled();
111
+ });
80
112
  });
@@ -54,7 +54,7 @@ import type {
54
54
  TokenManager,
55
55
  SuperblocksSdk,
56
56
  } from "../index.js";
57
- import { getTracer } from "../telemetry/index.js";
57
+ import { getTracer, shutdownTelemetry } from "../telemetry/index.js";
58
58
  import { getErrorMeta, getLogger, type Logger } from "../telemetry/logging.js";
59
59
  import type {
60
60
  ApplicationConfig,
@@ -68,6 +68,7 @@ import {
68
68
  import {
69
69
  classifyInitialInstallError,
70
70
  InitialInstallFailed,
71
+ UpgradeFailed,
71
72
  } from "./dependency-install-classifier.mjs";
72
73
  import { getDiskSpace, recoverFromDiskFull } from "./disk-space.mjs";
73
74
  import {
@@ -89,6 +90,12 @@ import {
89
90
  } from "./package-json-snapshot.mjs";
90
91
  import { getCurrentCliVersion } from "./version-detection.js";
91
92
 
93
+ // APPS-5072: bound on the pre-restart telemetry flush. Deliberately well
94
+ // under the 10s metric-reader export interval so the flush is meaningful, yet
95
+ // short enough that a slow/unreachable OTel collector can't wedge the CLI
96
+ // restart.
97
+ const TELEMETRY_FLUSH_TIMEOUT_MS = 3000;
98
+
92
99
  const passErrorToVSCode = (message: string | undefined, logger: Logger) => {
93
100
  if (message && process.env.SUPERBLOCKS_VSCODE === "true") {
94
101
  // Prefixing with `clierr:` will make the VS code extension capture this message and show it to the user.
@@ -648,6 +655,39 @@ export async function installPackages(
648
655
  }
649
656
  }
650
657
 
658
+ /**
659
+ * Resolve whether the org routes installs through a configured npm registry, as
660
+ * a tri-state mirroring AppShell's `deriveHasAnyRegistryConfigured`
661
+ * (`shell.ts`): `configured`/`stale` → `true` (rows known to exist),
662
+ * `not-configured` → `false` (deliberate "no rows"), `unreachable` →
663
+ * `undefined` ("we don't know" — renderers fall back to the default variant).
664
+ * Left `undefined` when no client is wired in or the resolution itself throws.
665
+ *
666
+ * Single derivation site shared by the startup-install classifier
667
+ * (`buildInstallParseContext`) and the CLI auto-upgrade path, so both classify
668
+ * a registry-blocked failure the same way.
669
+ */
670
+ async function resolveHasAnyRegistryConfigured(
671
+ npmRegistryClient?: NpmRegistryClient,
672
+ ): Promise<boolean | undefined> {
673
+ if (!npmRegistryClient) return undefined;
674
+ try {
675
+ const result = await npmRegistryClient.getConfig();
676
+ switch (result.source) {
677
+ case "configured":
678
+ case "stale":
679
+ return true;
680
+ case "not-configured":
681
+ return false;
682
+ case "unreachable":
683
+ return undefined;
684
+ }
685
+ } catch {
686
+ /* leave undefined → renderers treat as "don't know" / default variant */
687
+ }
688
+ return undefined;
689
+ }
690
+
651
691
  /**
652
692
  * Build the `ParseContext` the dependency-install classifier needs from the
653
693
  * failing install's working directory + registry client:
@@ -655,13 +695,8 @@ export async function installPackages(
655
695
  * - `requestedPackages` — the union of `dependencies` + `devDependencies`
656
696
  * in the app's `package.json`, used by the registry-blocked renderers to
657
697
  * name the failing specs when npm's `--json` `detail` doesn't.
658
- * - `hasAnyRegistryConfigured` — the tri-state derived from the registry
659
- * client's most recent `getConfig()`, mirroring AppShell's
660
- * `deriveHasAnyRegistryConfigured` (`shell.ts`): `configured`/`stale`
661
- * → `true` (rows known to exist), `not-configured` → `false` (deliberate
662
- * "no rows"), `unreachable` → `undefined` ("we don't know" — the renderer
663
- * falls back to the default variant). Left `undefined` when no client is
664
- * wired in or the resolution itself throws.
698
+ * - `hasAnyRegistryConfigured` — the registry tri-state from
699
+ * `resolveHasAnyRegistryConfigured`.
665
700
  *
666
701
  * Best-effort throughout: a missing/unparseable package.json or a registry
667
702
  * outage must not mask the underlying install failure, so both lookups are
@@ -684,26 +719,8 @@ async function buildInstallParseContext(
684
719
  /* best-effort: a missing/unparseable package.json yields no specs */
685
720
  }
686
721
 
687
- let hasAnyRegistryConfigured: boolean | undefined;
688
- if (npmRegistryClient) {
689
- try {
690
- const result = await npmRegistryClient.getConfig();
691
- switch (result.source) {
692
- case "configured":
693
- case "stale":
694
- hasAnyRegistryConfigured = true;
695
- break;
696
- case "not-configured":
697
- hasAnyRegistryConfigured = false;
698
- break;
699
- case "unreachable":
700
- hasAnyRegistryConfigured = undefined;
701
- break;
702
- }
703
- } catch {
704
- /* leave undefined → renderers treat as "don't know" / default variant */
705
- }
706
- }
722
+ const hasAnyRegistryConfigured =
723
+ await resolveHasAnyRegistryConfigured(npmRegistryClient);
707
724
 
708
725
  return { requestedPackages, hasAnyRegistryConfigured };
709
726
  }
@@ -759,8 +776,10 @@ export interface DevServerStatus {
759
776
  }
760
777
 
761
778
  /** Decide how the startup catch handles an error: degrade (record, keep Vite up)
762
- * for an app-install failure (the InitialInstallFailed marker), or exit for
763
- * anything else (lock/sync/upgrade). Pure + unit-tested. Does NOT call process.exit. */
779
+ * for an app-install failure (`InitialInstallFailed`) or a best-effort
780
+ * CLI/library auto-upgrade failure (`UpgradeFailed`), or exit for anything else
781
+ * (lock/sync/genuinely-unusable upgrade env). Pure + unit-tested. Does NOT call
782
+ * process.exit. */
764
783
  export function handleStartupError(
765
784
  error: unknown,
766
785
  status: DevServerStatus,
@@ -779,6 +798,23 @@ export function handleStartupError(
779
798
  });
780
799
  return "degrade";
781
800
  }
801
+ if (error instanceof UpgradeFailed) {
802
+ // The auto-upgrade is best-effort and the pod is still running the current
803
+ // version, so a failed upgrade degrades instead of crash-looping. Recording
804
+ // here also makes the failure observable: the process survives to the next
805
+ // metrics export tick, which the pre-exit `process.exit(1)` always lost.
806
+ status.serverErrors.push(error.serverError);
807
+ logger.error(
808
+ "[dev-server] dependency upgrade failed; keeping the current version",
809
+ getErrorMeta(error),
810
+ );
811
+ devServerMetrics.recordUpgradeFailure({
812
+ category: error.serverError.category,
813
+ npmErrorCode: error.serverError.npmErrorCode,
814
+ hasAnyRegistryConfigured: error.serverError.hasAnyRegistryConfigured,
815
+ });
816
+ return "degrade";
817
+ }
782
818
  return "exit";
783
819
  }
784
820
 
@@ -872,24 +908,26 @@ export async function dev(options: {
872
908
  // We keep them SEPARATE so an install rejection cannot swallow an upgrade
873
909
  // rejection (or vice-versa) the way a single `Promise.all` would: that
874
910
  // bundle settles on the FIRST rejection and silently absorbs the other's
875
- // outcome, breaking APPS-4457's intent that auto-upgrade failures still
876
- // exit (not degrade) and CLI-restart-on-upgrade still happens even if the
877
- // app install fails.
911
+ // outcome. Separate handles let each outcome be observed on its own join, so
912
+ // CLI-restart-on-successful-upgrade still happens even if the app install
913
+ // fails, and each failure mode gets its own surfacing.
878
914
  //
879
- // - packageUpgradePromise: library upgrades (and their `npm install`
880
- // side-effects) from `checkVersionsAndWritePackageJson`. Rejection is
881
- // OUT of scope for graceful degrade (APPS-4457)`handleStartupError`
882
- // routes it to `process.exit(1)`.
915
+ // - packageUpgradePromise: CLI/library upgrades from
916
+ // `checkVersionsAndWritePackageJson`. Rejection is the `UpgradeFailed`
917
+ // marker, which `handleStartupError` routes to the degrade paththe
918
+ // auto-upgrade is best-effort, so the pod keeps the current version.
919
+ // A SUCCESSFUL upgrade instead resolves and triggers the CLI restart
920
+ // below.
883
921
  // - packageInstallPromise: the app's verification `npm install`.
884
922
  // Rejection MAY be the `InitialInstallFailed` marker, which
885
- // `handleStartupError` routes to the degrade path.
923
+ // `handleStartupError` also routes to the degrade path.
886
924
  let packageUpgradePromise: Promise<void> | undefined;
887
925
  let packageInstallPromise: Promise<void> | undefined;
888
926
  const tracer = getTracer();
889
927
  const logger = getLogger(options.logger);
890
- // Joins the in-flight upgrade. Rejection propagates so the caller's step
891
- // can abort cleanly (handleStartupError exits auto-upgrade graceful
892
- // degrade is OOS per APPS-4457).
928
+ // Joins the in-flight upgrade. Rejection propagates so the caller's step can
929
+ // abort cleanly (handleStartupError degrades for the `UpgradeFailed` marker,
930
+ // exits otherwise).
893
931
  const joinPackageUpgrade = async (reason: string): Promise<void> => {
894
932
  if (!packageUpgradePromise) {
895
933
  return;
@@ -924,9 +962,11 @@ export async function dev(options: {
924
962
  packageInstallPromise = undefined;
925
963
  await Promise.allSettled([promise]);
926
964
  };
927
- // Joins upgrade THEN install. Upgrade failures surface first so
928
- // handleStartupError exits (per APPS-4457) before an install rejection
929
- // gets a chance to route to degrade.
965
+ // Joins upgrade THEN install. Upgrade outcome is observed first: a failed
966
+ // upgrade (`UpgradeFailed`) degrades and skips the CLI restart below, while a
967
+ // SUCCESSFUL upgrade lets the `InitialInstallFailed` branch route to the
968
+ // restart (the new CLI re-runs install). Both markers degrade; the ordering
969
+ // is about which branch the catch sites see first, not exit-vs-degrade.
930
970
  const joinUpgradeThenInstall = async (reason: string): Promise<void> => {
931
971
  await joinPackageUpgrade(reason);
932
972
  await joinPackageInstall(reason);
@@ -1431,6 +1471,15 @@ export async function dev(options: {
1431
1471
  featureFlags: sdk.getFeatureFlagsForUser(currentUser),
1432
1472
  };
1433
1473
 
1474
+ // Derive the registry tri-state the same way the startup
1475
+ // install classifier does so a failed CLI auto-upgrade
1476
+ // classifies/renders with the right private-registry
1477
+ // guidance ("ask an org admin" vs "temporary") and records
1478
+ // the correct `configured` install metric. Resolved here,
1479
+ // before the upgrade promise launches, because the promise
1480
+ // outlives this call.
1481
+ const hasAnyRegistryConfigured =
1482
+ await resolveHasAnyRegistryConfigured(npmRegistryClient);
1434
1483
  const result = await checkVersionsAndWritePackageJson(
1435
1484
  lockService,
1436
1485
  applicationConfigWithTokenConfigAndUserInfo,
@@ -1439,6 +1488,7 @@ export async function dev(options: {
1439
1488
  // Route the CLI-upgrade install's npm debug log into the
1440
1489
  // same `<app>/.superblocks/logs` as the startup install.
1441
1490
  cwd,
1491
+ hasAnyRegistryConfigured,
1442
1492
  );
1443
1493
  hasCliUpdated = result.cliUpdated;
1444
1494
  upgradePromises = result.upgradePromises;
@@ -1541,11 +1591,13 @@ export async function dev(options: {
1541
1591
 
1542
1592
  // Launch upgrades and app install as INDEPENDENT promises (not a
1543
1593
  // single `Promise.all`) so each outcome is observed on its own
1544
- // join: upgrade failure exits (APPS-4457), install failure may
1545
- // degrade (InitialInstallFailed). A bundled `Promise.all` would
1546
- // settle on the first rejection and silently absorb the other's
1547
- // result. The `.catch` backstops convert "no join fired" cases
1548
- // into logged-then-handled rejections instead of unhandled ones.
1594
+ // join: a failed upgrade degrades (`UpgradeFailed`) and skips the
1595
+ // CLI restart, an install failure degrades (`InitialInstallFailed`),
1596
+ // and a SUCCESSFUL upgrade still restarts even if the install fails.
1597
+ // A bundled `Promise.all` would settle on the first rejection and
1598
+ // silently absorb the other's result. The `.catch` backstops convert
1599
+ // "no join fired" cases into logged-then-handled rejections instead
1600
+ // of unhandled ones.
1549
1601
  if (upgradePromises.length > 0) {
1550
1602
  logger.info("Starting package upgrade in background…");
1551
1603
  const launchedUpgradeCount = upgradePromises.length;
@@ -1612,8 +1664,9 @@ export async function dev(options: {
1612
1664
  ) {
1613
1665
  // Upload serializes the post-install lockfile + node_modules
1614
1666
  // tree to DBFS, so it must observe quiesced upgrade+install.
1615
- // Upgrade first — its rejection exits before an install
1616
- // rejection can take the degrade path (APPS-4457).
1667
+ // Upgrade first — a failed upgrade (`UpgradeFailed`) is seen
1668
+ // before the install join and degrades via the outer catch,
1669
+ // skipping both upload and restart.
1617
1670
  //
1618
1671
  // BUT: if a successful CLI upgrade is pending restart
1619
1672
  // (`hasCliUpdated`), the restart branch below MUST win over
@@ -1684,6 +1737,45 @@ export async function dev(options: {
1684
1737
  );
1685
1738
  }
1686
1739
  logger.info("CLI was updated, restarting the dev server…");
1740
+ // APPS-5072: the OTel metric reader exports on a 10s interval,
1741
+ // but this restart exits within ~1-3s of recording the
1742
+ // auto-upgrade install metric
1743
+ // (superblocks.npm.install.* runner=auto_upgrade). Without an
1744
+ // explicit flush the buffered sample dies with the process —
1745
+ // which is why that runner reports zero samples in every env
1746
+ // despite real upgrades emitting it (spans survive because they
1747
+ // flush on the faster batch processor; the metric does not).
1748
+ // shutdownTelemetry() never rejects; the catch is purely
1749
+ // defensive. It resolves to `true` only when a flush actually
1750
+ // ran — `false` on internal shutdown error or when telemetry was
1751
+ // disabled — and the timeout branch leaves the flag false. So
1752
+ // the log below reflects whether samples were really flushed, not
1753
+ // merely that the race settled.
1754
+ let telemetryFlushed = false;
1755
+ try {
1756
+ await Promise.race([
1757
+ shutdownTelemetry().then((flushed) => {
1758
+ telemetryFlushed = flushed;
1759
+ }),
1760
+ new Promise<void>((resolve) =>
1761
+ setTimeout(resolve, TELEMETRY_FLUSH_TIMEOUT_MS),
1762
+ ),
1763
+ ]);
1764
+ } catch (e) {
1765
+ logger.error(
1766
+ "Error flushing telemetry before CLI restart",
1767
+ getErrorMeta(e),
1768
+ );
1769
+ }
1770
+ if (telemetryFlushed) {
1771
+ logger.info("Telemetry flushed before CLI restart");
1772
+ } else {
1773
+ logger.warn(
1774
+ `Telemetry flush did not complete (timed out after ${TELEMETRY_FLUSH_TIMEOUT_MS}ms, ` +
1775
+ "errored, or telemetry disabled) before CLI restart; " +
1776
+ "some auto_upgrade metric samples may be dropped",
1777
+ );
1778
+ }
1687
1779
  process.exit(AUTO_UPGRADE_EXIT_CODE);
1688
1780
  }
1689
1781
  });
@@ -1719,18 +1811,18 @@ export async function dev(options: {
1719
1811
  // here costs at most the install's remaining wall time — the upload
1720
1812
  // and CLI-restart joins above usually drain it first.
1721
1813
  // The "before upload" / "before CLI restart" joins above run inside the
1722
- // sync/lock catch that degrades on `InitialInstallFailed`. But when the
1723
- // install ran yet none of those joins fired (e.g.
1814
+ // sync/lock catch that degrades on the install/upgrade markers. But when
1815
+ // the install ran yet none of those joins fired (e.g.
1724
1816
  // `packageJsonRequiresInstall && !forcePackageInstall && !upload &&
1725
- // !hasCliUpdated`), the install rejection first surfaces HERE — outside
1726
- // that catch. Route it through the SAME origin gate so an app-install
1727
- // failure still degrades (keep Vite up + record the error) instead of
1728
- // escaping `dev()` to `process.exit(1)`. Non-install errors (including
1729
- // any background upgrade rejection that escaped the prior joins) take
1730
- // the same explicit exit path as the sync/lock catch above so the
1731
- // process actually terminates (the startupSpan catch only rethrows,
1732
- // and an unhandled rejection at that depth doesn't shut the lock
1733
- // service down or guarantee `process.exit(1)`).
1817
+ // !hasCliUpdated`), the rejection first surfaces HERE — outside that
1818
+ // catch. Route it through the SAME origin gate so an app-install failure
1819
+ // (`InitialInstallFailed`) or a best-effort upgrade failure
1820
+ // (`UpgradeFailed`) still degrades (keep Vite up + record the error)
1821
+ // instead of escaping `dev()` to `process.exit(1)`. Genuinely fatal
1822
+ // errors (lock/sync) take the explicit exit path below so the process
1823
+ // actually terminates (the startupSpan catch only rethrows, and an
1824
+ // unhandled rejection at that depth doesn't shut the lock service down or
1825
+ // guarantee `process.exit(1)`).
1734
1826
  try {
1735
1827
  await joinUpgradeThenInstall("before Vite startup");
1736
1828
  } catch (error) {
@@ -8,12 +8,12 @@ import {
8
8
  bucketNpmRegistryHost,
9
9
  NPM_REGISTRY_SPAN,
10
10
  NPM_REGISTRY_SPAN_ATTR,
11
+ npmRegistryEmitter,
11
12
  withNpmRegistrySpan,
12
13
  } from "@superblocksteam/telemetry";
13
14
  import {
14
15
  type NpmRegistryClient,
15
16
  type NpmRegistryFetchResult,
16
- PRESERVE_NPMRC_SCOPES,
17
17
  restoreInitialNpmrc,
18
18
  snapshotInitialNpmrc,
19
19
  type SnapshotInitialNpmrcOutcome,
@@ -252,13 +252,19 @@ export async function syncHomeNpmrc(
252
252
  NPM_REGISTRY_SPAN.SYNC_HOME_NPMRC,
253
253
  deps.orgId ? { [NPM_REGISTRY_SPAN_ATTR.ORG_ID]: deps.orgId } : {},
254
254
  async (span) => {
255
+ // Time the sync so the span carries `duration_ms`, and emit a counter
256
+ // alongside it so the health of writing the npmrc at boot can be alerted
257
+ // on per service.name without querying traces.
258
+ const start = Date.now();
255
259
  const result = await syncHomeNpmrcImpl(deps);
256
260
  span.setAttributes({
257
261
  [NPM_REGISTRY_SPAN_ATTR.OUTCOME]: result.outcome,
262
+ [NPM_REGISTRY_SPAN_ATTR.DURATION_MS]: Date.now() - start,
258
263
  ...(result.source
259
264
  ? { [NPM_REGISTRY_SPAN_ATTR.SOURCE]: result.source }
260
265
  : {}),
261
266
  });
267
+ npmRegistryEmitter.recordSyncHomeNpmrc(result.outcome);
262
268
  return result;
263
269
  },
264
270
  );
@@ -272,7 +278,11 @@ async function syncHomeNpmrcImpl(
272
278
 
273
279
  let result: NpmRegistryFetchResult;
274
280
  try {
275
- result = await deps.npmRegistryClient.getConfig();
281
+ // Force-refresh so the home userconfig reflects an admin clearing or
282
+ // changing the registry, rather than inheriting a config a sibling
283
+ // startup step (e.g. `privateRegistryValidationResult`) may have just
284
+ // warmed into the client's TTL cache.
285
+ result = await deps.npmRegistryClient.getConfig({ forceRefresh: true });
276
286
  } catch (error) {
277
287
  // `NpmRegistryClient` only throws for the deliberate-denial branches
278
288
  // (RBAC 403, malformed request 400, double-401). All three signal a
@@ -316,7 +326,6 @@ async function syncHomeNpmrcImpl(
316
326
  try {
317
327
  await mkdir(path.dirname(targetPath), { recursive: true });
318
328
  await writeNpmrc(targetPath, result.config, {
319
- preserveScopeLines: PRESERVE_NPMRC_SCOPES,
320
329
  mode: HOME_NPMRC_MODE,
321
330
  });
322
331
  deps.logger.info(
@@ -435,7 +444,6 @@ async function syncHomeNpmrcImpl(
435
444
  }
436
445
 
437
446
  await writeNpmrc(targetPath, result.config, {
438
- preserveScopeLines: PRESERVE_NPMRC_SCOPES,
439
447
  mode: HOME_NPMRC_MODE,
440
448
  });
441
449
  deps.logger.info(`${LOG_PREFIX} wrote ~/.superblocks/npmrc`, {
@@ -20,6 +20,7 @@ import * as path from "node:path";
20
20
 
21
21
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
22
 
23
+ import { npmRegistryEmitter } from "@superblocksteam/telemetry";
23
24
  import * as npmRegistry from "@superblocksteam/vite-plugin-file-sync/npm-registry";
24
25
  import type {
25
26
  NpmRegistryClient,
@@ -150,7 +151,50 @@ describe("syncHomeNpmrc", () => {
150
151
  snapshotInitialNpmrcMock.mockReset();
151
152
  });
152
153
 
154
+ describe("outcome counter", () => {
155
+ it("emits sync_home_npmrc.outcome_total with the resolved outcome", async () => {
156
+ const spy = vi
157
+ .spyOn(npmRegistryEmitter, "recordSyncHomeNpmrc")
158
+ .mockImplementation(() => {});
159
+ try {
160
+ const { client } = makeClient(async () => CONFIGURED);
161
+ await syncHomeNpmrc({
162
+ npmRegistryClient: client,
163
+ logger: asLogger(makeLogger()),
164
+ homeDir,
165
+ });
166
+ expect(spy).toHaveBeenCalledWith("written");
167
+
168
+ spy.mockClear();
169
+ const { client: unreachableClient } = makeClient(
170
+ async () => UNREACHABLE,
171
+ );
172
+ await syncHomeNpmrc({
173
+ npmRegistryClient: unreachableClient,
174
+ logger: asLogger(makeLogger()),
175
+ homeDir,
176
+ });
177
+ expect(spy).toHaveBeenCalledWith("skipped-unreachable");
178
+ } finally {
179
+ spy.mockRestore();
180
+ }
181
+ });
182
+ });
183
+
153
184
  describe("configured → write", () => {
185
+ it("force-refreshes the registry config so a cleared registry is seen at sync time, not from a warmed TTL cache", async () => {
186
+ const { client, getConfig } = makeClient(async () => CONFIGURED);
187
+ const logger = makeLogger();
188
+
189
+ await syncHomeNpmrc({
190
+ npmRegistryClient: client,
191
+ logger: asLogger(logger),
192
+ homeDir,
193
+ });
194
+
195
+ expect(getConfig).toHaveBeenCalledWith({ forceRefresh: true });
196
+ });
197
+
154
198
  it("writes ~/.superblocks/npmrc with the registry config at mode 0o400", async () => {
155
199
  const { client } = makeClient(async () => CONFIGURED);
156
200
  const logger = makeLogger();
@@ -242,21 +286,15 @@ describe("syncHomeNpmrc", () => {
242
286
  expect(stats.mode & 0o777).toBe(HOME_NPMRC_MODE);
243
287
  });
244
288
 
245
- it("overwrites an image-baked default with the server config when set, but preserves the @superblocksteam scope and its auth", async () => {
246
- // EE pods ship with `/home/node/.superblocks/npmrc` pre-populated
247
- // with a GHPR scope mapping + token. When the runtime org
248
- // configures its own private registry:
249
- // - The server-fetched default registry wins over any baked
250
- // default (none here, but the assertion pins that
251
- // `registry=…artifactory…` line lands).
252
- // - The baked `@superblocksteam:registry=…npm.pkg.github.com/`
253
- // scope mapping must SURVIVE (it's in `PRESERVE_NPMRC_SCOPES`
254
- // so `@superblocksteam/*` packages keep flowing through GHPR).
255
- // - And its matching `//npm.pkg.github.com/:_authToken=…` line
256
- // must survive with it — otherwise every `@superblocksteam/*`
257
- // install 401s against GHPR (APPS-4300). The earlier shape of
258
- // this test asserted the auth line was dropped, which was the
259
- // bug.
289
+ it("overwrites an image-baked default with the server config, dropping the baked @superblocksteam scope and its auth", async () => {
290
+ // EE pods no longer ship a baked `@superblocksteam` scope or GHPR
291
+ // token. The npm proxy now serves `@superblocksteam` as a regular
292
+ // server-config row, so the live userconfig is rewritten purely from
293
+ // the server fetch. A leftover baked `@superblocksteam:registry=…
294
+ // npm.pkg.github.com/` line (e.g. on a pod still running an older
295
+ // image) is clobbered rather than carried forward — the proxy entry
296
+ // is the single source for the scope. The baked file is still
297
+ // captured to `~/.superblocks/npmrc.default` for the restore path.
260
298
  const baked =
261
299
  "//npm.pkg.github.com/:_authToken=ghpr-baked\n" +
262
300
  "@superblocksteam:registry=https://npm.pkg.github.com/\n";
@@ -277,10 +315,12 @@ describe("syncHomeNpmrc", () => {
277
315
  expect(content).toContain(
278
316
  "registry=https://artifactory.example.com/api/npm/npm/",
279
317
  );
280
- expect(content).toContain(
318
+ expect(content).not.toContain(
281
319
  "@superblocksteam:registry=https://npm.pkg.github.com/",
282
320
  );
283
- expect(content).toContain("//npm.pkg.github.com/:_authToken=ghpr-baked");
321
+ expect(content).not.toContain(
322
+ "//npm.pkg.github.com/:_authToken=ghpr-baked",
323
+ );
284
324
  });
285
325
  });
286
326
 
@@ -10,6 +10,8 @@ export const MANAGED_PACKAGE_DEPENDENCIES = [
10
10
  SUPERBLOCKS_SDK_API_PACKAGE,
11
11
  ] as const;
12
12
 
13
+ export const MANAGED_OVERRIDE_PACKAGES = ["zod"] as const;
14
+
13
15
  export const PACKAGE_DEPENDENCY_FIELDS = [
14
16
  "dependencies",
15
17
  "devDependencies",
@@ -197,6 +199,50 @@ function dependencySpecEquals(a: unknown, b: unknown): boolean {
197
199
  return JSON.stringify(a) === JSON.stringify(b);
198
200
  }
199
201
 
202
+ function overridesRecord(
203
+ packageJson: PackageJsonObject | null,
204
+ ): Record<string, unknown> | undefined {
205
+ const overrides = packageJson?.overrides;
206
+ if (overrides && typeof overrides === "object" && !Array.isArray(overrides)) {
207
+ return overrides as Record<string, unknown>;
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ function ensureManagedOverrides(
213
+ restoredPackageJson: PackageJsonObject,
214
+ warmPackageJson: PackageJsonObject | null,
215
+ ): boolean {
216
+ const warmOverrides = overridesRecord(warmPackageJson);
217
+ if (!warmOverrides) {
218
+ return false;
219
+ }
220
+
221
+ let changed = false;
222
+ for (const packageName of MANAGED_OVERRIDE_PACKAGES) {
223
+ const desired = warmOverrides[packageName];
224
+ if (desired === undefined) {
225
+ continue;
226
+ }
227
+
228
+ const existingOverrides = overridesRecord(restoredPackageJson);
229
+ if (
230
+ existingOverrides &&
231
+ dependencySpecEquals(existingOverrides[packageName], desired)
232
+ ) {
233
+ continue;
234
+ }
235
+
236
+ restoredPackageJson.overrides = {
237
+ ...(existingOverrides ?? {}),
238
+ [packageName]: desired,
239
+ };
240
+ changed = true;
241
+ }
242
+
243
+ return changed;
244
+ }
245
+
200
246
  async function readPackageJson(cwd: string): Promise<unknown | null> {
201
247
  return (await readPackageJsonFile(cwd))?.packageJson ?? null;
202
248
  }
@@ -311,6 +357,10 @@ export async function restoreManagedPackageDependencies(
311
357
  changed = true;
312
358
  }
313
359
 
360
+ if (ensureManagedOverrides(restoredPackageJson, warmPackageJsonObject)) {
361
+ changed = true;
362
+ }
363
+
314
364
  if (!changed) {
315
365
  return false;
316
366
  }