@superblocksteam/sdk 2.0.137 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/automatic-upgrades.d.ts +1 -1
- package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
- package/dist/cli-replacement/automatic-upgrades.js +64 -59
- package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
- package/dist/cli-replacement/automatic-upgrades.test.js +54 -33
- package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
- package/dist/cli-replacement/dependency-install-classifier.d.mts +26 -1
- package/dist/cli-replacement/dependency-install-classifier.d.mts.map +1 -1
- package/dist/cli-replacement/dependency-install-classifier.mjs +58 -0
- package/dist/cli-replacement/dependency-install-classifier.mjs.map +1 -1
- package/dist/cli-replacement/dependency-install-classifier.test.mjs +33 -1
- package/dist/cli-replacement/dependency-install-classifier.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts +4 -2
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.interception.test.mjs +32 -5
- package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.mjs +142 -65
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/cli-replacement/home-npmrc.d.mts.map +1 -1
- package/dist/cli-replacement/home-npmrc.mjs +13 -5
- package/dist/cli-replacement/home-npmrc.mjs.map +1 -1
- package/dist/cli-replacement/home-npmrc.test.mjs +49 -17
- package/dist/cli-replacement/home-npmrc.test.mjs.map +1 -1
- package/dist/cli-replacement/package-json-snapshot.d.mts +1 -0
- package/dist/cli-replacement/package-json-snapshot.d.mts.map +1 -1
- package/dist/cli-replacement/package-json-snapshot.mjs +35 -0
- package/dist/cli-replacement/package-json-snapshot.mjs.map +1 -1
- package/dist/cli-replacement/package-json-snapshot.test.mjs +104 -0
- package/dist/cli-replacement/package-json-snapshot.test.mjs.map +1 -1
- package/dist/dev-utils/dev-server-metrics.d.mts +17 -7
- package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
- package/dist/dev-utils/dev-server-metrics.mjs +18 -12
- package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +15 -6
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/dist/dev-utils/prepare-in-place-restart.d.mts +31 -0
- package/dist/dev-utils/prepare-in-place-restart.d.mts.map +1 -0
- package/dist/dev-utils/prepare-in-place-restart.mjs +64 -0
- package/dist/dev-utils/prepare-in-place-restart.mjs.map +1 -0
- package/dist/dev-utils/prepare-in-place-restart.test.d.mts +2 -0
- package/dist/dev-utils/prepare-in-place-restart.test.d.mts.map +1 -0
- package/dist/dev-utils/prepare-in-place-restart.test.mjs +46 -0
- package/dist/dev-utils/prepare-in-place-restart.test.mjs.map +1 -0
- package/dist/telemetry/index.d.ts +12 -0
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +26 -12
- package/dist/telemetry/index.js.map +1 -1
- package/package.json +6 -6
- package/src/cli-replacement/automatic-upgrades.test.ts +68 -58
- package/src/cli-replacement/automatic-upgrades.ts +81 -59
- package/src/cli-replacement/dependency-install-classifier.mts +74 -1
- package/src/cli-replacement/dependency-install-classifier.test.mts +49 -0
- package/src/cli-replacement/dev.interception.test.mts +39 -7
- package/src/cli-replacement/dev.mts +154 -62
- package/src/cli-replacement/home-npmrc.mts +12 -4
- package/src/cli-replacement/home-npmrc.test.mts +57 -17
- package/src/cli-replacement/package-json-snapshot.mts +50 -0
- package/src/cli-replacement/package-json-snapshot.test.mts +121 -0
- package/src/dev-utils/dev-server-metrics.mts +48 -21
- package/src/dev-utils/dev-server.mts +22 -5
- package/src/dev-utils/prepare-in-place-restart.mts +99 -0
- package/src/dev-utils/prepare-in-place-restart.test.mts +69 -0
- package/src/telemetry/index.ts +24 -10
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
|
7
|
-
// vi.mock is hoisted, so we use vi.hoisted to
|
|
8
|
-
|
|
9
|
-
|
|
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 (
|
|
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
|
|
659
|
-
*
|
|
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
|
-
|
|
688
|
-
|
|
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 (
|
|
763
|
-
*
|
|
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
|
|
876
|
-
//
|
|
877
|
-
//
|
|
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
|
|
880
|
-
//
|
|
881
|
-
//
|
|
882
|
-
//
|
|
915
|
+
// - packageUpgradePromise: CLI/library upgrades from
|
|
916
|
+
// `checkVersionsAndWritePackageJson`. Rejection is the `UpgradeFailed`
|
|
917
|
+
// marker, which `handleStartupError` routes to the degrade path — the
|
|
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
|
-
//
|
|
892
|
-
//
|
|
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
|
|
928
|
-
//
|
|
929
|
-
//
|
|
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
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
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 —
|
|
1616
|
-
//
|
|
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
|
|
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
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
// the
|
|
1731
|
-
//
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
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
|
-
|
|
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
|
|
246
|
-
// EE pods ship
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
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(
|
|
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
|
}
|