@superblocksteam/sdk 2.0.129 → 2.0.130-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 (75) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  3. package/dist/cli-replacement/automatic-upgrades.js +235 -42
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/automatic-upgrades.test.js +406 -3
  6. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  7. package/dist/cli-replacement/dev.d.mts.map +1 -1
  8. package/dist/cli-replacement/dev.mjs +8 -9
  9. package/dist/cli-replacement/dev.mjs.map +1 -1
  10. package/dist/cli-replacement/home-npmrc.d.mts +6 -0
  11. package/dist/cli-replacement/home-npmrc.d.mts.map +1 -1
  12. package/dist/cli-replacement/home-npmrc.mjs +22 -0
  13. package/dist/cli-replacement/home-npmrc.mjs.map +1 -1
  14. package/dist/client.billing-usage.test.d.ts +2 -0
  15. package/dist/client.billing-usage.test.d.ts.map +1 -0
  16. package/dist/client.billing-usage.test.js +66 -0
  17. package/dist/client.billing-usage.test.js.map +1 -0
  18. package/dist/client.d.ts +41 -0
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +15 -1
  21. package/dist/client.js.map +1 -1
  22. package/dist/dev-utils/dev-server-metrics.boot.test.d.mts +2 -0
  23. package/dist/dev-utils/dev-server-metrics.boot.test.d.mts.map +1 -0
  24. package/dist/dev-utils/dev-server-metrics.boot.test.mjs +55 -0
  25. package/dist/dev-utils/dev-server-metrics.boot.test.mjs.map +1 -0
  26. package/dist/dev-utils/dev-server-metrics.d.mts +32 -0
  27. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  28. package/dist/dev-utils/dev-server-metrics.mjs +60 -0
  29. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  30. package/dist/dev-utils/dev-server-metrics.test.mjs +18 -1
  31. package/dist/dev-utils/dev-server-metrics.test.mjs.map +1 -1
  32. package/dist/dev-utils/dev-server.d.mts +28 -2
  33. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  34. package/dist/dev-utils/dev-server.mjs +68 -7
  35. package/dist/dev-utils/dev-server.mjs.map +1 -1
  36. package/dist/dev-utils/dev-server.status.test.mjs +28 -1
  37. package/dist/dev-utils/dev-server.status.test.mjs.map +1 -1
  38. package/dist/flag.d.ts +14 -0
  39. package/dist/flag.d.ts.map +1 -1
  40. package/dist/flag.js +17 -0
  41. package/dist/flag.js.map +1 -1
  42. package/dist/flag.test.d.ts +2 -0
  43. package/dist/flag.test.d.ts.map +1 -0
  44. package/dist/flag.test.js +54 -0
  45. package/dist/flag.test.js.map +1 -0
  46. package/dist/index.d.ts +2 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/sdk.d.ts +3 -1
  51. package/dist/sdk.d.ts.map +1 -1
  52. package/dist/sdk.js +11 -1
  53. package/dist/sdk.js.map +1 -1
  54. package/dist/types/common.d.ts +1 -0
  55. package/dist/types/common.d.ts.map +1 -1
  56. package/dist/types/common.js.map +1 -1
  57. package/package.json +8 -8
  58. package/src/cli-replacement/automatic-upgrades.test.ts +556 -3
  59. package/src/cli-replacement/automatic-upgrades.ts +251 -54
  60. package/src/cli-replacement/dev.mts +9 -10
  61. package/src/cli-replacement/home-npmrc.mts +45 -0
  62. package/src/client.billing-usage.test.ts +73 -0
  63. package/src/client.ts +66 -1
  64. package/src/dev-utils/dev-server-metrics.boot.test.mts +83 -0
  65. package/src/dev-utils/dev-server-metrics.mts +65 -0
  66. package/src/dev-utils/dev-server-metrics.test.mts +24 -1
  67. package/src/dev-utils/dev-server.mts +96 -7
  68. package/src/dev-utils/dev-server.status.test.mts +35 -1
  69. package/src/flag.test.ts +63 -0
  70. package/src/flag.ts +24 -0
  71. package/src/index.ts +9 -0
  72. package/src/sdk.ts +20 -0
  73. package/src/types/common.ts +1 -0
  74. package/tsconfig.tsbuildinfo +1 -1
  75. package/turbo.json +2 -1
@@ -15,6 +15,11 @@ import * as path from "node:path";
15
15
 
16
16
  import { describe, it, expect, beforeEach, vi } from "vitest";
17
17
 
18
+ import type {
19
+ NpmInstallMetricInput,
20
+ NpmInstallSpanAttributes,
21
+ } from "@superblocksteam/telemetry";
22
+
18
23
  import {
19
24
  buildGlobalInstallArgs,
20
25
  buildInstallEnv,
@@ -65,7 +70,12 @@ vi.mock("./version-detection.js", () => ({
65
70
  // either declare its mocks inline OR use `vi.hoisted()` to hoist the mock
66
71
  // fns alongside the mock registration. The latter lets us reuse the mocks
67
72
  // inside test bodies (e.g. `.mockReturnValue(...)`).
68
- const { stripUpgradedCliLockfilesMock, loggerMock } = vi.hoisted(() => {
73
+ const {
74
+ stripUpgradedCliLockfilesMock,
75
+ loggerMock,
76
+ recordNpmInstallMock,
77
+ spanSetAttributeMock,
78
+ } = vi.hoisted(() => {
69
79
  return {
70
80
  stripUpgradedCliLockfilesMock: vi.fn(async () => ({})),
71
81
  loggerMock: {
@@ -74,6 +84,29 @@ const { stripUpgradedCliLockfilesMock, loggerMock } = vi.hoisted(() => {
74
84
  error: vi.fn(),
75
85
  debug: vi.fn(),
76
86
  },
87
+ // APPS-4544: spy on the npm-install metric emit. The source calls
88
+ // `recordNpmInstall` once per `upgradeCliWithPackageManager` invocation
89
+ // (in a `finally`), so a test asserting the metric attributes is the
90
+ // contract we want pinned. We mock only this symbol — the rest of
91
+ // `@superblocksteam/telemetry` (sanitize/redact helpers, structured
92
+ // logger shims) is left untouched via `importOriginal` below.
93
+ //
94
+ // Signature is pinned to the real `recordNpmInstall` shape so the
95
+ // type-aware mock.calls[0] tuple resolves to `[NpmInstallMetricInput]`
96
+ // — otherwise `vi.fn(() => …)` infers `[]` and the test assertions
97
+ // can't index `[0]` without a cast.
98
+ recordNpmInstallMock: vi.fn<
99
+ (input: NpmInstallMetricInput) => NpmInstallSpanAttributes
100
+ >(() => ({
101
+ "npm.registry_host": "unknown",
102
+ "npm.outcome": "success",
103
+ })),
104
+ // APPS-4544: capture `span.setAttribute(...)` calls so the
105
+ // `upgradeCli`-span `outcome` attribute assertion can verify the
106
+ // emitted trace fact. The real OTel `Span#setAttribute` returns
107
+ // the span for chaining, but the source under test never chains,
108
+ // so a vi.fn with default `undefined` return is fine.
109
+ spanSetAttributeMock: vi.fn(),
77
110
  };
78
111
  });
79
112
 
@@ -106,12 +139,47 @@ vi.mock("@superblocksteam/shared", async (importOriginal) => {
106
139
  ...actual,
107
140
  NON_SB_ORG_UPDATE_ERROR: "non_sb_org_update_error",
108
141
  // traceFunction in tests is a passthrough — we just want the fn to run.
109
- traceFunction: async ({ fn }: { fn: () => Promise<unknown> }) => {
110
- return await fn();
142
+ // APPS-4544: pass a fake span so callbacks that set attributes
143
+ // (e.g. the `upgradeCli` outcome attribute) have somewhere to write,
144
+ // and the test can assert on the recorded calls. Callbacks that
145
+ // ignore the parameter are unaffected.
146
+ //
147
+ // ASSUMPTION (APPS-4544, scout F6): the SAME `spanSetAttributeMock` is
148
+ // handed to every nested `traceFunction` callback (`upgradeCli`,
149
+ // `upgradeCliWithOclif`, `upgradePackageWithPackageManager - cli`,
150
+ // `stripUpgradedCliLockfiles`, …). Today only the outer `upgradeCli`
151
+ // callback calls `span.setAttribute`, so assertions like
152
+ // `toHaveBeenCalledWith("outcome", "success")` are unambiguous. If a
153
+ // future inner fn starts setting span attributes, give it a dedicated
154
+ // span mock (or assert by call index) so its writes don't pollute the
155
+ // outcome assertions here.
156
+ traceFunction: async ({
157
+ fn,
158
+ }: {
159
+ fn: (span: {
160
+ setAttribute: typeof spanSetAttributeMock;
161
+ }) => Promise<unknown>;
162
+ }) => {
163
+ return await fn({ setAttribute: spanSetAttributeMock });
111
164
  },
112
165
  };
113
166
  });
114
167
 
168
+ // APPS-4544: override only `recordNpmInstall`. The other npm-registry helpers
169
+ // (`bucketNpmRegistryHost`, `normalizeInstallOutcome`,
170
+ // `redactNpmRegistryHostsFromText`) the source still consumes must keep their
171
+ // real implementations so the existing `[npm-install-blocked]` log assertions
172
+ // continue to exercise the real bucket + redaction paths — a mocked
173
+ // `bucketNpmRegistryHost` would silently mask a regression of the original
174
+ // APPS-4381 fix.
175
+ vi.mock("@superblocksteam/telemetry", async (importOriginal) => {
176
+ const actual = (await importOriginal()) as Record<string, unknown>;
177
+ return {
178
+ ...actual,
179
+ recordNpmInstall: recordNpmInstallMock,
180
+ };
181
+ });
182
+
115
183
  // ---------------------------------------------------------------------------
116
184
  // Test fixtures
117
185
  // ---------------------------------------------------------------------------
@@ -140,6 +208,13 @@ beforeEach(() => {
140
208
  upgradeWithPackageManagerCalls.length = 0;
141
209
  // Restore default mock return values after `clearAllMocks` resets them.
142
210
  stripUpgradedCliLockfilesMock.mockImplementation(async () => ({}));
211
+ // APPS-4544: `clearAllMocks` wipes the implementation set in `vi.hoisted`,
212
+ // so re-pin the typed default so callers see a `NpmInstallSpanAttributes`
213
+ // return value instead of `undefined`.
214
+ recordNpmInstallMock.mockImplementation(() => ({
215
+ "npm.registry_host": "unknown",
216
+ "npm.outcome": "success",
217
+ }));
143
218
 
144
219
  // Mock global fetch used by getRemoteVersions
145
220
  global.fetch = vi.fn(async () => ({
@@ -770,6 +845,484 @@ describe("checkVersionsAndWritePackageJson", () => {
770
845
  );
771
846
  });
772
847
 
848
+ // -----------------------------------------------------------------
849
+ // APPS-4544: superblocks.npm.install.* metric emission for the
850
+ // CLI auto-upgrade runner. The structured `[npm-install-blocked]`
851
+ // log line above only fires on a perimeter block, so operators
852
+ // had no metric to alert on "auto-upgrade just failed everywhere"
853
+ // (vs. an isolated install in the app shell). These tests pin
854
+ // the metric attribute contract so a future refactor of
855
+ // `upgradeCliWithPackageManager` can't silently drop it.
856
+ // -----------------------------------------------------------------
857
+ it("APPS-4544: emits recordNpmInstall on the happy path with runner=auto_upgrade, outcome=success", async () => {
858
+ const { exec } = await import("node:child_process");
859
+ const execMock = vi.mocked(exec);
860
+ // Drive the same flow as `mockNpmFallbackExec` but with a clean
861
+ // (no-error) exit so the metric records outcome="success".
862
+ execMock.mockImplementation(((
863
+ cmd: string,
864
+ ...rest: unknown[]
865
+ ): unknown => {
866
+ const cb = (
867
+ typeof rest[rest.length - 1] === "function"
868
+ ? rest[rest.length - 1]
869
+ : undefined
870
+ ) as ((err: Error | null, result?: unknown) => void) | undefined;
871
+ if (cmd === "which superblocks" || cmd === "where superblocks") {
872
+ cb?.(null, { stdout: "/usr/local/bin/superblocks\n", stderr: "" });
873
+ return {};
874
+ }
875
+ if (cmd.includes("superblocks update")) {
876
+ // Force fallback to the package-manager path.
877
+ cb?.(null, { stdout: "this version is not updatable\n", stderr: "" });
878
+ return {};
879
+ }
880
+ // Successful install — no error.
881
+ queueMicrotask(() => cb?.(null));
882
+ return { stdout: new EventEmitter(), stderr: new EventEmitter() };
883
+ }) as unknown as Parameters<typeof execMock.mockImplementation>[0]);
884
+
885
+ // Post-install version validation must report the target version,
886
+ // otherwise `upgradeCliWithPackageManager` throws and the metric
887
+ // flips to `other` via the catch.
888
+ const versionDetection = await import("./version-detection.js");
889
+ vi.mocked(versionDetection.getCurrentCliVersion)
890
+ .mockResolvedValueOnce({ version: "2.0.0", alias: undefined }) // initial check (stale)
891
+ .mockResolvedValueOnce({
892
+ version: TARGET_CLI_VERSION,
893
+ alias: undefined,
894
+ }); // post-install validation
895
+
896
+ const result = await checkVersionsAndWritePackageJson(
897
+ mockLockService,
898
+ mockConfig,
899
+ false,
900
+ false,
901
+ );
902
+ await Promise.all(result.upgradePromises);
903
+
904
+ expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
905
+ const args = recordNpmInstallMock.mock.calls[0]![0];
906
+ expect(args).toMatchObject({
907
+ runner: "auto_upgrade",
908
+ outcome: "success",
909
+ // No private registry was configured for this test, so the
910
+ // `configured` boolean falls through to its default (false).
911
+ configured: false,
912
+ });
913
+ expect(typeof args.durationMs).toBe("number");
914
+ expect(args.durationMs).toBeGreaterThanOrEqual(0);
915
+ // On the happy path no classifier ever ran, so the call site
916
+ // leaves `registryHost` undefined; the emitter coerces it to
917
+ // `unknown` via `bucketNpmRegistryHost`.
918
+ expect(args.registryHost).toBeUndefined();
919
+ });
920
+
921
+ it("APPS-4544: emits outcome=registry_unreachable on ECONNREFUSED so the auto_upgrade runner is alertable separately from happy upgrades", async () => {
922
+ await mockNpmFallbackExec(
923
+ [
924
+ "npm error code ECONNREFUSED",
925
+ "npm error errno ECONNREFUSED",
926
+ "npm error network request to https://registry.npmjs.org/@superblocksteam%2fcli failed, reason: connect ECONNREFUSED 127.0.0.1:443",
927
+ ].join("\n"),
928
+ new Error("Command failed: pnpm install ..."),
929
+ );
930
+
931
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
932
+ /Command failed: pnpm install/,
933
+ );
934
+
935
+ expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
936
+ const args = recordNpmInstallMock.mock.calls[0]![0];
937
+ expect(args.runner).toBe("auto_upgrade");
938
+ // Raw `NpmInstallBlocked.reason` flows through; the emitter
939
+ // aliases it to the short `unreachable` outcome internally
940
+ // (`normalizeInstallOutcome`), which the cardinality test in
941
+ // `emitter.test.ts` already pins.
942
+ expect(args.outcome).toBe("registry_unreachable");
943
+ // The classifier extracted the public-npm host from the stderr
944
+ // line, so it appears here unbucketed (`recordNpmInstall` does
945
+ // the bucketing). This pins that we DO pass it through, since
946
+ // otherwise the public/private split on the metric would be
947
+ // dropped for genuine registry failures.
948
+ expect(args.registryHost).toMatch(/registry\.npmjs\.org/);
949
+ });
950
+
951
+ it("APPS-4544: emits outcome=registry_auth_failed and the private registry host on E401 (the emitter buckets host at the emit boundary)", async () => {
952
+ const privateHost = "customer.jfrog.io";
953
+ await mockNpmFallbackExec(
954
+ [
955
+ "npm error code E401",
956
+ `npm error 401 Unauthorized - GET https://${privateHost}/@superblocksteam%2fcli`,
957
+ ].join("\n"),
958
+ new Error("Command failed: pnpm install ..."),
959
+ );
960
+
961
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
962
+ /Command failed: pnpm install/,
963
+ );
964
+
965
+ expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
966
+ const args = recordNpmInstallMock.mock.calls[0]![0];
967
+ expect(args.runner).toBe("auto_upgrade");
968
+ expect(args.outcome).toBe("registry_auth_failed");
969
+ // The call-site passes the raw host through; `recordNpmInstall`
970
+ // (the real implementation, mocked here only to capture args)
971
+ // is what coerces it to `private`. The emitter cardinality
972
+ // test in `emitter.test.ts` pins that side; this test pins
973
+ // that the upgrade path actually feeds the host in.
974
+ expect(args.registryHost).toBe(privateHost);
975
+ });
976
+
977
+ it("APPS-4544: emits outcome=other for an unclassified failure (ERESOLVE) so the metric reflects the failure even without a NpmInstallBlocked match", async () => {
978
+ await mockNpmFallbackExec(
979
+ [
980
+ "npm error code ERESOLVE",
981
+ "npm error ERESOLVE unable to resolve dependency tree",
982
+ ].join("\n"),
983
+ new Error("Command failed: pnpm install ..."),
984
+ );
985
+
986
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
987
+ /Command failed: pnpm install/,
988
+ );
989
+
990
+ expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
991
+ const args = recordNpmInstallMock.mock.calls[0]![0];
992
+ expect(args.runner).toBe("auto_upgrade");
993
+ // No classifier match → `other`. The previous behaviour (before
994
+ // this test was added) was that the install promise rejected but
995
+ // no metric fired at all on the auto_upgrade runner, so a future
996
+ // refactor that drops the unclassified branch from the call site
997
+ // would silently turn this back into a metric gap.
998
+ expect(args.outcome).toBe("other");
999
+ });
1000
+
1001
+ // APPS-4544 (scout F2): the exec can SUCCEED (no error → outcome stays
1002
+ // `success`) yet the upgrade still be broken — npm/pnpm reported success
1003
+ // but the new binary isn't on PATH, a shadowing binary wins, or the
1004
+ // install was corrupted. `upgradeCliWithPackageManager` validates the
1005
+ // post-install version and, on mismatch/missing, flips `outcome` from
1006
+ // `success` to `other` in its `catch` before re-throwing. That
1007
+ // `success → other` flip is the ONLY transition of its kind and has no
1008
+ // other coverage, so pin it: a future refactor that drops the flip would
1009
+ // otherwise emit `outcome=success` for a CLI that's still stale.
1010
+ it("APPS-4544: emits outcome=other when the exec succeeds but post-install version validation fails", async () => {
1011
+ const { exec } = await import("node:child_process");
1012
+ const execMock = vi.mocked(exec);
1013
+ execMock.mockImplementation(((
1014
+ cmd: string,
1015
+ ...rest: unknown[]
1016
+ ): unknown => {
1017
+ const cb = (
1018
+ typeof rest[rest.length - 1] === "function"
1019
+ ? rest[rest.length - 1]
1020
+ : undefined
1021
+ ) as ((err: Error | null, result?: unknown) => void) | undefined;
1022
+ if (cmd === "which superblocks" || cmd === "where superblocks") {
1023
+ cb?.(null, { stdout: "/usr/local/bin/superblocks\n", stderr: "" });
1024
+ return {};
1025
+ }
1026
+ if (cmd.includes("superblocks update")) {
1027
+ // Force the package-manager fallback (where post-install
1028
+ // validation runs).
1029
+ cb?.(null, { stdout: "this version is not updatable\n", stderr: "" });
1030
+ return {};
1031
+ }
1032
+ // Install exec SUCCEEDS (no error) — so the classifier never runs
1033
+ // and `outcome` stays `success` until post-install validation.
1034
+ queueMicrotask(() => cb?.(null));
1035
+ return { stdout: new EventEmitter(), stderr: new EventEmitter() };
1036
+ }) as unknown as Parameters<typeof execMock.mockImplementation>[0]);
1037
+
1038
+ // Initial check reports stale (triggers upgrade); post-install
1039
+ // validation reports a version that does NOT match the target, so
1040
+ // `upgradeCliWithPackageManager` throws "CLI version mismatch".
1041
+ const versionDetection = await import("./version-detection.js");
1042
+ vi.mocked(versionDetection.getCurrentCliVersion)
1043
+ .mockResolvedValueOnce({ version: "2.0.0", alias: undefined }) // initial (stale)
1044
+ .mockResolvedValueOnce({ version: "1.9.0", alias: undefined }); // post-install (mismatch)
1045
+
1046
+ const result = await checkVersionsAndWritePackageJson(
1047
+ mockLockService,
1048
+ mockConfig,
1049
+ false,
1050
+ false,
1051
+ );
1052
+
1053
+ // The validation failure rejects the upgrade promise (so dev.mts
1054
+ // won't restart into a stale CLI)...
1055
+ await expect(Promise.all(result.upgradePromises)).rejects.toThrow(
1056
+ /CLI version mismatch after upgrade/,
1057
+ );
1058
+
1059
+ // ...and the metric still fires exactly once, flipped to `other`.
1060
+ expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
1061
+ const args = recordNpmInstallMock.mock.calls[0]![0];
1062
+ expect(args.runner).toBe("auto_upgrade");
1063
+ expect(args.outcome).toBe("other");
1064
+ });
1065
+
1066
+ // APPS-4544: ticket explicitly calls out "emit from both
1067
+ // `upgradeCliWithPackageManager` and the oclif path". Without an
1068
+ // oclif-success emission, dashboards undercount successful upgrades
1069
+ // whenever the binary was installed via the oclif tarball channel —
1070
+ // that path skips the pnpm fallback entirely, so there is no other
1071
+ // call site that could fire the metric.
1072
+ it("APPS-4544: emits recordNpmInstall on oclif success without falling back to the package manager", async () => {
1073
+ const { exec } = await import("node:child_process");
1074
+ const execMock = vi.mocked(exec);
1075
+ execMock.mockImplementation(((
1076
+ cmd: string,
1077
+ ...rest: unknown[]
1078
+ ): unknown => {
1079
+ const cb = (
1080
+ typeof rest[rest.length - 1] === "function"
1081
+ ? rest[rest.length - 1]
1082
+ : undefined
1083
+ ) as ((err: Error | null, result?: unknown) => void) | undefined;
1084
+ if (cmd === "which superblocks" || cmd === "where superblocks") {
1085
+ cb?.(null, { stdout: "/usr/local/bin/superblocks\n", stderr: "" });
1086
+ return {};
1087
+ }
1088
+ if (cmd.includes("superblocks update")) {
1089
+ // No "not updatable" in the output → oclif considers itself
1090
+ // the canonical upgrader and the package-manager fallback does
1091
+ // NOT run.
1092
+ cb?.(null, { stdout: "Upgraded to 2.1.0\n", stderr: "" });
1093
+ return {};
1094
+ }
1095
+ // Any other exec call is unexpected for the oclif-only path.
1096
+ cb?.(new Error(`unexpected exec: ${cmd}`));
1097
+ return {};
1098
+ }) as unknown as Parameters<typeof execMock.mockImplementation>[0]);
1099
+
1100
+ const versionDetection = await import("./version-detection.js");
1101
+ vi.mocked(versionDetection.getCurrentCliVersion).mockResolvedValue({
1102
+ version: "2.0.0",
1103
+ alias: undefined,
1104
+ });
1105
+
1106
+ const result = await checkVersionsAndWritePackageJson(
1107
+ mockLockService,
1108
+ mockConfig,
1109
+ false,
1110
+ false,
1111
+ );
1112
+ // The oclif `recordNpmInstall` fires inside the `upgradeCli`
1113
+ // promise, which is pushed to `upgradePromises` and NOT awaited by
1114
+ // `checkVersionsAndWritePackageJson`. Await it explicitly (matching
1115
+ // the happy-path test) so the assertion can't race the microtask that
1116
+ // emits the metric if the fixture's I/O ordering ever changes.
1117
+ await Promise.all(result.upgradePromises);
1118
+
1119
+ expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
1120
+ const args = recordNpmInstallMock.mock.calls[0]![0];
1121
+ expect(args).toMatchObject({
1122
+ runner: "auto_upgrade",
1123
+ outcome: "success",
1124
+ });
1125
+ });
1126
+
1127
+ // APPS-4554: a successful oclif upgrade replaces the on-disk CLI tarball
1128
+ // but the running process keeps the OLD code, so the dev server must
1129
+ // restart to pick it up. `dev.mts` gates that restart on
1130
+ // `result.cliUpdated` (`hasCliUpdated`). Before this fix `cliUpdated` was
1131
+ // only set in the package-manager fallback branch, so an oclif-only
1132
+ // upgrade left the dev server running stale code until a manual restart.
1133
+ // This test pins that the oclif-success branch flips the flag too, so the
1134
+ // two upgrade paths stay symmetric.
1135
+ it("APPS-4554: sets cliUpdated=true on oclif success so the dev server restarts", async () => {
1136
+ const { exec } = await import("node:child_process");
1137
+ const execMock = vi.mocked(exec);
1138
+ execMock.mockImplementation(((
1139
+ cmd: string,
1140
+ ...rest: unknown[]
1141
+ ): unknown => {
1142
+ const cb = (
1143
+ typeof rest[rest.length - 1] === "function"
1144
+ ? rest[rest.length - 1]
1145
+ : undefined
1146
+ ) as ((err: Error | null, result?: unknown) => void) | undefined;
1147
+ if (cmd === "which superblocks" || cmd === "where superblocks") {
1148
+ cb?.(null, { stdout: "/usr/local/bin/superblocks\n", stderr: "" });
1149
+ return {};
1150
+ }
1151
+ if (cmd.includes("superblocks update")) {
1152
+ // No "not updatable" in the output → oclif succeeds outright and
1153
+ // the package-manager fallback (which historically set the flag)
1154
+ // never runs.
1155
+ cb?.(null, { stdout: "Upgraded to 2.1.0\n", stderr: "" });
1156
+ return {};
1157
+ }
1158
+ cb?.(new Error(`unexpected exec: ${cmd}`));
1159
+ return {};
1160
+ }) as unknown as Parameters<typeof execMock.mockImplementation>[0]);
1161
+
1162
+ const versionDetection = await import("./version-detection.js");
1163
+ vi.mocked(versionDetection.getCurrentCliVersion).mockResolvedValue({
1164
+ version: "2.0.0",
1165
+ alias: undefined,
1166
+ });
1167
+
1168
+ const result = await checkVersionsAndWritePackageJson(
1169
+ mockLockService,
1170
+ mockConfig,
1171
+ false,
1172
+ false,
1173
+ );
1174
+
1175
+ expect(result.cliUpdated).toBe(true);
1176
+ });
1177
+
1178
+ // APPS-4544: the ticket calls for an `outcome` attribute on the
1179
+ // existing `upgradeCli` span so traces carry the upgrade result.
1180
+ // This is the trace-side complement to the install metric — a
1181
+ // single attribute lets operators correlate a failed CLI upgrade
1182
+ // span with the pod that crash-looped, without correlating
1183
+ // metric series back to traces by host+timestamp.
1184
+ it("APPS-4544: sets outcome=success on the upgradeCli span when the upgrade completes", async () => {
1185
+ const { exec } = await import("node:child_process");
1186
+ const execMock = vi.mocked(exec);
1187
+ execMock.mockImplementation(((
1188
+ cmd: string,
1189
+ ...rest: unknown[]
1190
+ ): unknown => {
1191
+ const cb = (
1192
+ typeof rest[rest.length - 1] === "function"
1193
+ ? rest[rest.length - 1]
1194
+ : undefined
1195
+ ) as ((err: Error | null, result?: unknown) => void) | undefined;
1196
+ if (cmd === "which superblocks" || cmd === "where superblocks") {
1197
+ cb?.(null, { stdout: "/usr/local/bin/superblocks\n", stderr: "" });
1198
+ return {};
1199
+ }
1200
+ if (cmd.includes("superblocks update")) {
1201
+ cb?.(null, { stdout: "Upgraded to 2.1.0\n", stderr: "" });
1202
+ return {};
1203
+ }
1204
+ cb?.(null);
1205
+ return { stdout: new EventEmitter(), stderr: new EventEmitter() };
1206
+ }) as unknown as Parameters<typeof execMock.mockImplementation>[0]);
1207
+
1208
+ const result = await checkVersionsAndWritePackageJson(
1209
+ mockLockService,
1210
+ mockConfig,
1211
+ false,
1212
+ false,
1213
+ );
1214
+ // The `upgradeCli` span callback runs inside one of the
1215
+ // `upgradePromises` (the per-package upgrade futures pushed
1216
+ // during the outer `checkVersionsAndUpgrade` span). The
1217
+ // top-level `checkVersionsAndWritePackageJson` resolves before
1218
+ // those finish — match the product call site that consumes
1219
+ // `result.upgradePromises` directly.
1220
+ await Promise.all(result.upgradePromises);
1221
+
1222
+ expect(spanSetAttributeMock).toHaveBeenCalledWith("outcome", "success");
1223
+ });
1224
+
1225
+ it("APPS-4544: sets outcome=failure on the upgradeCli span when the package manager throws", async () => {
1226
+ await mockNpmFallbackExec(
1227
+ ["npm error code ECONNREFUSED", "npm error errno ECONNREFUSED"].join(
1228
+ "\n",
1229
+ ),
1230
+ new Error("Command failed: pnpm install ..."),
1231
+ );
1232
+
1233
+ const result = await checkVersionsAndWritePackageJson(
1234
+ mockLockService,
1235
+ mockConfig,
1236
+ false,
1237
+ false,
1238
+ );
1239
+ // The pkg-manager fallback rejects the upgrade promise; capture
1240
+ // it so the test doesn't surface as an unhandled rejection,
1241
+ // then assert on the span attribute the `catch` set.
1242
+ await expect(Promise.all(result.upgradePromises)).rejects.toThrow(
1243
+ /Command failed: pnpm install/,
1244
+ );
1245
+
1246
+ expect(spanSetAttributeMock).toHaveBeenCalledWith("outcome", "failure");
1247
+ });
1248
+
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 () => {
1263
+ const { exec } = await import("node:child_process");
1264
+ const execMock = vi.mocked(exec);
1265
+ execMock.mockImplementation(((
1266
+ cmd: string,
1267
+ ...rest: unknown[]
1268
+ ): unknown => {
1269
+ const cb = (
1270
+ typeof rest[rest.length - 1] === "function"
1271
+ ? rest[rest.length - 1]
1272
+ : undefined
1273
+ ) as ((err: Error | null, result?: unknown) => void) | undefined;
1274
+ if (cmd === "which superblocks" || cmd === "where superblocks") {
1275
+ cb?.(null, { stdout: "/usr/local/bin/superblocks\n", stderr: "" });
1276
+ return {};
1277
+ }
1278
+ if (cmd.includes("superblocks update")) {
1279
+ // "not updatable" → oclif defers to the package-manager fallback,
1280
+ // where `resolveCommand` is consulted.
1281
+ cb?.(null, { stdout: "this version is not updatable\n", stderr: "" });
1282
+ return {};
1283
+ }
1284
+ // The install exec must never run: `resolveCommand` fails first.
1285
+ cb?.(new Error(`unexpected exec: ${cmd}`));
1286
+ return {};
1287
+ }) as unknown as Parameters<typeof execMock.mockImplementation>[0]);
1288
+
1289
+ // Force the single source-side `resolveCommand` call (the global
1290
+ // install command builder) to fail to resolve a command. `*Once`
1291
+ // takes precedence over the default mock implementation for the next
1292
+ // call only, which is exactly the one we want to break.
1293
+ const { resolveCommand } = await import("package-manager-detector");
1294
+ vi.mocked(resolveCommand).mockReturnValueOnce(
1295
+ undefined as unknown as ReturnType<typeof resolveCommand>,
1296
+ );
1297
+
1298
+ const result = await checkVersionsAndWritePackageJson(
1299
+ mockLockService,
1300
+ mockConfig,
1301
+ false,
1302
+ false,
1303
+ );
1304
+
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,
1311
+ );
1312
+
1313
+ // A failure sample is still recorded for the auto_upgrade runner so the
1314
+ // metric isn't silently missing for this path.
1315
+ expect(recordNpmInstallMock).toHaveBeenCalledTimes(1);
1316
+ const args = recordNpmInstallMock.mock.calls[0]![0];
1317
+ expect(args.runner).toBe("auto_upgrade");
1318
+ expect(args.outcome).toBe("other");
1319
+
1320
+ // The span outcome must still reflect the failure, not the default
1321
+ // success, so the no-op upgrade stays visible in traces even though the
1322
+ // promise resolves.
1323
+ expect(spanSetAttributeMock).toHaveBeenCalledWith("outcome", "failure");
1324
+ });
1325
+
773
1326
  it("leaves sdk-api alone when the server response omits sdkApi (older deployment)", async () => {
774
1327
  global.fetch = vi.fn(async () => ({
775
1328
  ok: true,