@superblocksteam/sdk 2.0.115-next.1 → 2.0.115

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 (38) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/dev-s3-restore.test.d.mts +2 -0
  3. package/dist/cli-replacement/dev-s3-restore.test.d.mts.map +1 -0
  4. package/dist/cli-replacement/dev-s3-restore.test.mjs +457 -0
  5. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -0
  6. package/dist/cli-replacement/dev.d.mts +7 -0
  7. package/dist/cli-replacement/dev.d.mts.map +1 -1
  8. package/dist/cli-replacement/dev.mjs +49 -2
  9. package/dist/cli-replacement/dev.mjs.map +1 -1
  10. package/dist/cli-replacement/package-json-snapshot.d.mts +26 -0
  11. package/dist/cli-replacement/package-json-snapshot.d.mts.map +1 -0
  12. package/dist/cli-replacement/package-json-snapshot.mjs +222 -0
  13. package/dist/cli-replacement/package-json-snapshot.mjs.map +1 -0
  14. package/dist/cli-replacement/package-json-snapshot.test.d.mts +2 -0
  15. package/dist/cli-replacement/package-json-snapshot.test.d.mts.map +1 -0
  16. package/dist/cli-replacement/package-json-snapshot.test.mjs +207 -0
  17. package/dist/cli-replacement/package-json-snapshot.test.mjs.map +1 -0
  18. package/dist/dev-utils/dev-server-persist.test.d.mts +2 -0
  19. package/dist/dev-utils/dev-server-persist.test.d.mts.map +1 -0
  20. package/dist/dev-utils/dev-server-persist.test.mjs +77 -0
  21. package/dist/dev-utils/dev-server-persist.test.mjs.map +1 -0
  22. package/dist/dev-utils/dev-server.d.mts +1 -0
  23. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  24. package/dist/dev-utils/dev-server.mjs +75 -53
  25. package/dist/dev-utils/dev-server.mjs.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/package.json +6 -6
  31. package/src/cli-replacement/dev-s3-restore.test.mts +599 -0
  32. package/src/cli-replacement/dev.mts +91 -2
  33. package/src/cli-replacement/package-json-snapshot.mts +328 -0
  34. package/src/cli-replacement/package-json-snapshot.test.mts +250 -0
  35. package/src/dev-utils/dev-server-persist.test.mts +96 -0
  36. package/src/dev-utils/dev-server.mts +91 -73
  37. package/src/index.ts +15 -0
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -58,6 +58,13 @@ import {
58
58
  ensureRemoteHasDefaultBranch,
59
59
  getGitErrorFields,
60
60
  } from "./git-repo-setup.mjs";
61
+ import {
62
+ didPackageJsonSnapshotChange,
63
+ packageJsonSnapshot,
64
+ packageJsonSnapshotDiagnostic,
65
+ type PackageJsonSnapshot,
66
+ restoreManagedPackageDependencies,
67
+ } from "./package-json-snapshot.mjs";
61
68
  import { getCurrentCliVersion } from "./version-detection.js";
62
69
 
63
70
  const exec = promisify(child_process.exec);
@@ -296,6 +303,15 @@ export async function dev(options: {
296
303
 
297
304
  /** Pre-existing HTTP server from warm standby mode (avoids port gap on transition). */
298
305
  existingServer?: HttpServer;
306
+
307
+ /** Force package installation when the caller has detected dependency drift. */
308
+ forcePackageInstall?: boolean;
309
+
310
+ /** Package snapshot before S3 workspace restore; used to refresh forced install after DBFS sync. */
311
+ packageJsonSnapshotBeforeRestore?: PackageJsonSnapshot | null;
312
+
313
+ /** Restore managed warm-template package pins after DBFS download in S3 warm restore flow. */
314
+ normalizeManagedPackageDependencies?: boolean;
299
315
  }) {
300
316
  const {
301
317
  cwd,
@@ -566,6 +582,9 @@ export async function dev(options: {
566
582
  }
567
583
 
568
584
  let hasPackageChanged = false;
585
+ let packageJsonRequiresInstall = false;
586
+ const hasPackageJsonSnapshotBeforeRestore =
587
+ options.packageJsonSnapshotBeforeRestore !== undefined;
569
588
 
570
589
  const packageJsonBefore = await readPkgJson(cwd);
571
590
 
@@ -576,6 +595,17 @@ export async function dev(options: {
576
595
  );
577
596
 
578
597
  await syncService!.downloadDirectory();
598
+ if (
599
+ options.normalizeManagedPackageDependencies &&
600
+ (await restoreManagedPackageDependencies(
601
+ cwd,
602
+ packageJsonBefore,
603
+ ))
604
+ ) {
605
+ logger.info(
606
+ "Restored managed package dependencies to the warm template versions after DBFS download",
607
+ );
608
+ }
579
609
  span.end();
580
610
  });
581
611
  }
@@ -814,16 +844,73 @@ export async function dev(options: {
814
844
 
815
845
  const packageJsonAfter = await readPkgJson(cwd);
816
846
 
847
+ let packageJsonSnapshotBefore: PackageJsonSnapshot | null = null;
848
+ let packageJsonSnapshotAfter: PackageJsonSnapshot | null = null;
849
+ let packageJsonInstallBaselineSnapshot: PackageJsonSnapshot | null =
850
+ null;
851
+
817
852
  if (packageJsonBefore && packageJsonAfter) {
818
853
  hasPackageChanged =
819
854
  JSON.stringify(packageJsonBefore, null, 2) !==
820
855
  JSON.stringify(packageJsonAfter, null, 2);
821
856
  } else if (packageJsonAfter) {
822
857
  hasPackageChanged = true;
858
+ }
859
+
860
+ if (packageJsonBefore) {
861
+ packageJsonSnapshotBefore =
862
+ packageJsonSnapshot(packageJsonBefore);
863
+ }
864
+ if (packageJsonAfter) {
865
+ packageJsonSnapshotAfter = packageJsonSnapshot(packageJsonAfter);
866
+ }
867
+
868
+ packageJsonInstallBaselineSnapshot =
869
+ hasPackageJsonSnapshotBeforeRestore
870
+ ? (options.packageJsonSnapshotBeforeRestore ?? null)
871
+ : packageJsonSnapshotBefore;
872
+ if (packageJsonAfter) {
873
+ packageJsonRequiresInstall = didPackageJsonSnapshotChange(
874
+ packageJsonInstallBaselineSnapshot,
875
+ packageJsonSnapshotAfter,
876
+ );
877
+ }
878
+ if (!packageJsonBefore && packageJsonRequiresInstall) {
823
879
  logger.info("package.json was created, installing packages…");
824
880
  }
881
+ const forcePackageInstallRequested = !!options.forcePackageInstall;
882
+ let forcePackageInstall = forcePackageInstallRequested;
883
+ if (
884
+ forcePackageInstallRequested &&
885
+ hasPackageJsonSnapshotBeforeRestore
886
+ ) {
887
+ forcePackageInstall = packageJsonRequiresInstall;
888
+ }
889
+
890
+ logger.info("Package install decision", {
891
+ packageJsonBeforePresent: !!packageJsonBefore,
892
+ packageJsonAfterPresent: !!packageJsonAfter,
893
+ hasPackageChanged,
894
+ packageJsonRequiresInstall,
895
+ forcePackageInstall,
896
+ forcePackageInstallRequested,
897
+ upgradePromiseCount: upgradePromises.length,
898
+ packageJsonSnapshotBefore: packageJsonSnapshotDiagnostic(
899
+ packageJsonSnapshotBefore,
900
+ ),
901
+ packageJsonInstallBaselineSnapshot: packageJsonSnapshotDiagnostic(
902
+ packageJsonInstallBaselineSnapshot,
903
+ ),
904
+ packageJsonSnapshotAfter: packageJsonSnapshotDiagnostic(
905
+ packageJsonSnapshotAfter,
906
+ ),
907
+ });
825
908
 
826
- if (hasPackageChanged || upgradePromises.length > 0) {
909
+ if (
910
+ packageJsonRequiresInstall ||
911
+ upgradePromises.length > 0 ||
912
+ forcePackageInstall
913
+ ) {
827
914
  logger.info("Installing packages…");
828
915
  await tracer.startActiveSpan("installPackages", async (span) => {
829
916
  try {
@@ -842,7 +929,9 @@ export async function dev(options: {
842
929
  );
843
930
  }
844
931
 
845
- if (hasPackageChanged || uploadFirst) {
932
+ const shouldUploadPackageState =
933
+ hasPackageChanged || forcePackageInstall;
934
+ if (shouldUploadPackageState || uploadFirst) {
846
935
  logger.info(
847
936
  `Uploading local files to branch '${activeDbfsBranchName}' on server before starting`,
848
937
  );
@@ -0,0 +1,328 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ export const SUPERBLOCKS_LIBRARY_PACKAGE = "@superblocksteam/library";
6
+ export const SUPERBLOCKS_SDK_API_PACKAGE = "@superblocksteam/sdk-api";
7
+
8
+ export const MANAGED_PACKAGE_DEPENDENCIES = [
9
+ SUPERBLOCKS_LIBRARY_PACKAGE,
10
+ SUPERBLOCKS_SDK_API_PACKAGE,
11
+ ] as const;
12
+
13
+ export const PACKAGE_DEPENDENCY_FIELDS = [
14
+ "dependencies",
15
+ "devDependencies",
16
+ "peerDependencies",
17
+ "optionalDependencies",
18
+ ] as const;
19
+
20
+ const PACKAGE_INSTALL_METADATA_FIELDS = [
21
+ "dependenciesMeta",
22
+ "devEngines",
23
+ "engines",
24
+ "overrides",
25
+ "packageManager",
26
+ "peerDependenciesMeta",
27
+ "pnpm",
28
+ "resolutions",
29
+ "workspaces",
30
+ ] as const;
31
+
32
+ const PACKAGE_BUNDLE_DEPENDENCIES_FIELDS = [
33
+ "bundleDependencies",
34
+ "bundledDependencies",
35
+ ] as const;
36
+
37
+ export type PackageJsonSnapshot = {
38
+ value: string;
39
+ diagnostic: {
40
+ sha256: string;
41
+ bytes: number;
42
+ };
43
+ };
44
+
45
+ export type PackageJsonSnapshotReadResult = {
46
+ packageJson: unknown | null;
47
+ snapshot: PackageJsonSnapshot | null;
48
+ };
49
+
50
+ export function packageJsonSnapshot(packageJson: unknown): PackageJsonSnapshot {
51
+ const packageJsonObject = packageJsonRecord(packageJson);
52
+ const normalized = PACKAGE_DEPENDENCY_FIELDS.reduce<Record<string, unknown>>(
53
+ (acc, field) => {
54
+ const dependencies = packageJsonObject
55
+ ? dependencyMap(packageJsonObject, field)
56
+ : undefined;
57
+ if (dependencies) {
58
+ acc[field] = dependencies;
59
+ }
60
+ return acc;
61
+ },
62
+ {},
63
+ );
64
+ if (packageJsonObject) {
65
+ const bundleDependenciesField = PACKAGE_BUNDLE_DEPENDENCIES_FIELDS.find(
66
+ (field) => Object.prototype.hasOwnProperty.call(packageJsonObject, field),
67
+ );
68
+ if (bundleDependenciesField) {
69
+ normalized.bundleDependencies =
70
+ packageJsonObject[bundleDependenciesField];
71
+ }
72
+
73
+ for (const field of PACKAGE_INSTALL_METADATA_FIELDS) {
74
+ if (Object.prototype.hasOwnProperty.call(packageJsonObject, field)) {
75
+ normalized[field] = packageJsonObject[field];
76
+ }
77
+ }
78
+ }
79
+ const value = stableStringify(normalized);
80
+
81
+ return {
82
+ value,
83
+ diagnostic: {
84
+ sha256: createHash("sha256").update(value).digest("hex"),
85
+ bytes: Buffer.byteLength(value),
86
+ },
87
+ };
88
+ }
89
+
90
+ export function didPackageJsonSnapshotChange(
91
+ before: PackageJsonSnapshot | null,
92
+ after: PackageJsonSnapshot | null,
93
+ ): boolean {
94
+ if (before?.value === after?.value) {
95
+ return false;
96
+ }
97
+
98
+ // If restore removes package.json, there is no manifest to install from.
99
+ // Treat that as no install work instead of forcing an install that cannot
100
+ // converge until DBFS or another source recreates the manifest.
101
+ return after !== null;
102
+ }
103
+
104
+ export function packageJsonSnapshotDiagnostic(
105
+ snapshot: PackageJsonSnapshot | null,
106
+ ): { present: boolean; sha256?: string; bytes?: number } {
107
+ if (snapshot === null) {
108
+ return { present: false };
109
+ }
110
+
111
+ return {
112
+ present: true,
113
+ ...snapshot.diagnostic,
114
+ };
115
+ }
116
+
117
+ type PackageJsonObject = Record<string, unknown>;
118
+ type PackageDependencySpec = { field: string; value: unknown };
119
+
120
+ function stableStringify(value: unknown): string {
121
+ return stableStringifyValue(value) ?? "null";
122
+ }
123
+
124
+ function stableStringifyValue(value: unknown): string | undefined {
125
+ if (value === undefined) {
126
+ return undefined;
127
+ }
128
+
129
+ if (Array.isArray(value)) {
130
+ const items = value.map((item) => stableStringifyValue(item) ?? "null");
131
+ return `[${items.join(",")}]`;
132
+ }
133
+
134
+ if (value && typeof value === "object") {
135
+ const object = value as Record<string, unknown>;
136
+ const entries = Object.keys(object)
137
+ .sort()
138
+ .flatMap((key) => {
139
+ const serializedValue = stableStringifyValue(object[key]);
140
+ return serializedValue === undefined
141
+ ? []
142
+ : [`${JSON.stringify(key)}:${serializedValue}`];
143
+ });
144
+ return `{${entries.join(",")}}`;
145
+ }
146
+
147
+ const serialized = JSON.stringify(value);
148
+ return serialized === undefined ? undefined : serialized;
149
+ }
150
+
151
+ function packageJsonRecord(packageJson: unknown): PackageJsonObject | null {
152
+ if (
153
+ !packageJson ||
154
+ typeof packageJson !== "object" ||
155
+ Array.isArray(packageJson)
156
+ ) {
157
+ return null;
158
+ }
159
+
160
+ return packageJson as PackageJsonObject;
161
+ }
162
+
163
+ function dependencyMap(
164
+ packageJson: PackageJsonObject,
165
+ field: string,
166
+ ): Record<string, unknown> | undefined {
167
+ const dependencies = packageJson[field];
168
+ if (
169
+ dependencies &&
170
+ typeof dependencies === "object" &&
171
+ !Array.isArray(dependencies)
172
+ ) {
173
+ return dependencies as Record<string, unknown>;
174
+ }
175
+
176
+ return undefined;
177
+ }
178
+
179
+ function packageDependencySpecs(
180
+ packageJson: PackageJsonObject,
181
+ packageName: string,
182
+ ): PackageDependencySpec[] {
183
+ return PACKAGE_DEPENDENCY_FIELDS.flatMap((field) => {
184
+ const dependencies = dependencyMap(packageJson, field);
185
+ if (
186
+ dependencies &&
187
+ Object.prototype.hasOwnProperty.call(dependencies, packageName)
188
+ ) {
189
+ return [{ field, value: dependencies[packageName] }];
190
+ }
191
+
192
+ return [];
193
+ });
194
+ }
195
+
196
+ function dependencySpecEquals(a: unknown, b: unknown): boolean {
197
+ return JSON.stringify(a) === JSON.stringify(b);
198
+ }
199
+
200
+ async function readPackageJson(cwd: string): Promise<unknown | null> {
201
+ return (await readPackageJsonFile(cwd))?.packageJson ?? null;
202
+ }
203
+
204
+ async function readPackageJsonFile(
205
+ cwd: string,
206
+ ): Promise<{ packageJson: unknown; source: string } | null> {
207
+ try {
208
+ const source = await fs.readFile(join(cwd, "package.json"), "utf-8");
209
+ return {
210
+ packageJson: JSON.parse(source),
211
+ source,
212
+ };
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function detectJsonIndent(source: string): number | string {
219
+ return source.match(/\n([ \t]+)"/)?.[1] ?? 2;
220
+ }
221
+
222
+ function hasFinalNewline(source: string): boolean {
223
+ return source.endsWith("\n");
224
+ }
225
+
226
+ export async function readPackageJsonSnapshot(
227
+ cwd: string,
228
+ ): Promise<PackageJsonSnapshot | null> {
229
+ const packageJson = await readPackageJson(cwd);
230
+ if (!packageJson) {
231
+ return null;
232
+ }
233
+ return packageJsonSnapshot(packageJson);
234
+ }
235
+
236
+ export async function readPackageJsonSnapshotWithSource(
237
+ cwd: string,
238
+ ): Promise<PackageJsonSnapshotReadResult> {
239
+ const packageJson = await readPackageJson(cwd);
240
+ return {
241
+ packageJson,
242
+ snapshot: packageJson ? packageJsonSnapshot(packageJson) : null,
243
+ };
244
+ }
245
+
246
+ export async function restoreManagedPackageDependencies(
247
+ cwd: string,
248
+ warmPackageJson: unknown | null,
249
+ ): Promise<boolean> {
250
+ const warmPackageJsonObject = packageJsonRecord(warmPackageJson);
251
+ if (!warmPackageJsonObject) {
252
+ return false;
253
+ }
254
+
255
+ const restoredPackageJsonFile = await readPackageJsonFile(cwd);
256
+ if (!restoredPackageJsonFile) {
257
+ return false;
258
+ }
259
+
260
+ const restoredPackageJson = packageJsonRecord(
261
+ restoredPackageJsonFile.packageJson,
262
+ );
263
+ if (!restoredPackageJson) {
264
+ return false;
265
+ }
266
+ const restoredPackageJsonSource = restoredPackageJsonFile.source;
267
+
268
+ let changed = false;
269
+ for (const packageName of MANAGED_PACKAGE_DEPENDENCIES) {
270
+ const [warmPackageSpec] = packageDependencySpecs(
271
+ warmPackageJsonObject,
272
+ packageName,
273
+ );
274
+ if (!warmPackageSpec) {
275
+ continue;
276
+ }
277
+
278
+ const restoredPackageSpecs = packageDependencySpecs(
279
+ restoredPackageJson,
280
+ packageName,
281
+ );
282
+ if (
283
+ restoredPackageSpecs.length === 1 &&
284
+ restoredPackageSpecs[0].field === warmPackageSpec.field &&
285
+ dependencySpecEquals(restoredPackageSpecs[0].value, warmPackageSpec.value)
286
+ ) {
287
+ continue;
288
+ }
289
+
290
+ if (
291
+ restoredPackageSpecs.length === 1 &&
292
+ restoredPackageSpecs[0].field === warmPackageSpec.field
293
+ ) {
294
+ dependencyMap(restoredPackageJson, warmPackageSpec.field)![packageName] =
295
+ warmPackageSpec.value;
296
+ } else {
297
+ for (const field of PACKAGE_DEPENDENCY_FIELDS) {
298
+ delete dependencyMap(restoredPackageJson, field)?.[packageName];
299
+ }
300
+
301
+ let dependencies = dependencyMap(
302
+ restoredPackageJson,
303
+ warmPackageSpec.field,
304
+ );
305
+ if (!dependencies) {
306
+ dependencies = {};
307
+ restoredPackageJson[warmPackageSpec.field] = dependencies;
308
+ }
309
+ dependencies[packageName] = warmPackageSpec.value;
310
+ }
311
+ changed = true;
312
+ }
313
+
314
+ if (!changed) {
315
+ return false;
316
+ }
317
+
318
+ await fs.writeFile(
319
+ join(cwd, "package.json"),
320
+ JSON.stringify(
321
+ restoredPackageJson,
322
+ null,
323
+ detectJsonIndent(restoredPackageJsonSource),
324
+ ) + (hasFinalNewline(restoredPackageJsonSource) ? "\n" : ""),
325
+ );
326
+
327
+ return true;
328
+ }
@@ -0,0 +1,250 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import {
8
+ didPackageJsonSnapshotChange,
9
+ packageJsonSnapshot,
10
+ readPackageJsonSnapshotWithSource,
11
+ restoreManagedPackageDependencies,
12
+ } from "./package-json-snapshot.mjs";
13
+
14
+ describe("packageJsonSnapshot", () => {
15
+ it("is stable for object key order changes", () => {
16
+ const before = packageJsonSnapshot({
17
+ dependencies: {
18
+ "@superblocksteam/library": "2.0.0",
19
+ react: "19.0.0",
20
+ },
21
+ scripts: {
22
+ dev: "vite",
23
+ build: "vite build",
24
+ },
25
+ });
26
+
27
+ const after = packageJsonSnapshot({
28
+ scripts: {
29
+ build: "vite build",
30
+ dev: "vite",
31
+ },
32
+ dependencies: {
33
+ react: "19.0.0",
34
+ "@superblocksteam/library": "2.0.0",
35
+ },
36
+ });
37
+
38
+ expect(didPackageJsonSnapshotChange(before, after)).toBe(false);
39
+ expect(before.diagnostic.sha256).toBe(after.diagnostic.sha256);
40
+ });
41
+
42
+ it("detects dependency version changes", () => {
43
+ const before = packageJsonSnapshot({
44
+ dependencies: {
45
+ "@superblocksteam/library": "1.0.0",
46
+ },
47
+ });
48
+ const after = packageJsonSnapshot({
49
+ dependencies: {
50
+ "@superblocksteam/library": "2.0.0",
51
+ },
52
+ });
53
+
54
+ expect(didPackageJsonSnapshotChange(before, after)).toBe(true);
55
+ });
56
+
57
+ it("ignores non-dependency package metadata", () => {
58
+ const before = packageJsonSnapshot({
59
+ name: "warm-template",
60
+ version: "1.0.0",
61
+ scripts: {
62
+ dev: "vite",
63
+ },
64
+ dependencies: {
65
+ react: "19.0.0",
66
+ },
67
+ });
68
+ const after = packageJsonSnapshot({
69
+ name: "restored-app",
70
+ version: "2.0.0",
71
+ scripts: {
72
+ dev: "vite --host 0.0.0.0",
73
+ },
74
+ dependencies: {
75
+ react: "19.0.0",
76
+ },
77
+ });
78
+
79
+ expect(didPackageJsonSnapshotChange(before, after)).toBe(false);
80
+ });
81
+
82
+ it("detects install-relevant package metadata changes", () => {
83
+ const basePackageJson = {
84
+ dependencies: {
85
+ react: "19.0.0",
86
+ },
87
+ overrides: {
88
+ lodash: "4.17.20",
89
+ },
90
+ pnpm: {
91
+ overrides: {
92
+ axios: "1.12.0",
93
+ },
94
+ },
95
+ };
96
+
97
+ const before = packageJsonSnapshot(basePackageJson);
98
+ const after = packageJsonSnapshot({
99
+ ...basePackageJson,
100
+ overrides: {
101
+ lodash: "4.17.21",
102
+ },
103
+ });
104
+
105
+ expect(didPackageJsonSnapshotChange(before, after)).toBe(true);
106
+ });
107
+
108
+ it("detects package-manager-specific install metadata changes", () => {
109
+ const before = packageJsonSnapshot({
110
+ dependencies: {
111
+ react: "19.0.0",
112
+ },
113
+ pnpm: {
114
+ overrides: {
115
+ axios: "1.12.0",
116
+ },
117
+ },
118
+ });
119
+
120
+ const after = packageJsonSnapshot({
121
+ dependencies: {
122
+ react: "19.0.0",
123
+ },
124
+ pnpm: {
125
+ overrides: {
126
+ axios: "1.13.2",
127
+ },
128
+ },
129
+ });
130
+
131
+ expect(didPackageJsonSnapshotChange(before, after)).toBe(true);
132
+ });
133
+
134
+ it("detects devEngines package manager changes", () => {
135
+ const before = packageJsonSnapshot({
136
+ dependencies: {
137
+ react: "19.0.0",
138
+ },
139
+ devEngines: {
140
+ packageManager: {
141
+ name: "npm",
142
+ version: "10.0.0",
143
+ },
144
+ },
145
+ });
146
+
147
+ const after = packageJsonSnapshot({
148
+ dependencies: {
149
+ react: "19.0.0",
150
+ },
151
+ devEngines: {
152
+ packageManager: {
153
+ name: "pnpm",
154
+ version: "10.0.0",
155
+ },
156
+ },
157
+ });
158
+
159
+ expect(didPackageJsonSnapshotChange(before, after)).toBe(true);
160
+ });
161
+
162
+ it("omits undefined object values when serializing snapshots", () => {
163
+ const before = packageJsonSnapshot({
164
+ dependencies: {
165
+ react: "19.0.0",
166
+ removed: undefined,
167
+ },
168
+ pnpm: {
169
+ onlyBuiltDependencies: ["esbuild", undefined],
170
+ overrides: undefined,
171
+ },
172
+ });
173
+ const after = packageJsonSnapshot({
174
+ dependencies: {
175
+ react: "19.0.0",
176
+ },
177
+ pnpm: {
178
+ onlyBuiltDependencies: ["esbuild", null],
179
+ },
180
+ });
181
+
182
+ expect(before.value).not.toContain("undefined");
183
+ expect(didPackageJsonSnapshotChange(before, after)).toBe(false);
184
+ });
185
+
186
+ it("matches read-pkg bundle dependency alias normalization", async () => {
187
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pkg-json-"));
188
+
189
+ try {
190
+ await fs.writeFile(
191
+ path.join(tmpDir, "package.json"),
192
+ JSON.stringify({
193
+ name: "test-app",
194
+ version: "1.0.0",
195
+ dependencies: {
196
+ react: "19.0.0",
197
+ },
198
+ bundledDependencies: ["react"],
199
+ }),
200
+ );
201
+
202
+ const before = await readPackageJsonSnapshotWithSource(tmpDir);
203
+ const { readPackage } = await import("read-pkg");
204
+ const after = packageJsonSnapshot(await readPackage({ cwd: tmpDir }));
205
+
206
+ expect(didPackageJsonSnapshotChange(before.snapshot, after)).toBe(false);
207
+ } finally {
208
+ await fs.rm(tmpDir, { recursive: true, force: true });
209
+ }
210
+ });
211
+ });
212
+
213
+ describe("restoreManagedPackageDependencies", () => {
214
+ it("preserves the restored package.json indentation when rewriting managed packages", async () => {
215
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pkg-json-"));
216
+
217
+ try {
218
+ await fs.writeFile(
219
+ path.join(tmpDir, "package.json"),
220
+ JSON.stringify(
221
+ {
222
+ name: "restored-app",
223
+ dependencies: {
224
+ "@superblocksteam/library": "1.0.0",
225
+ react: "19.0.0",
226
+ },
227
+ },
228
+ null,
229
+ 4,
230
+ ) + "\n",
231
+ );
232
+
233
+ await restoreManagedPackageDependencies(tmpDir, {
234
+ dependencies: {
235
+ "@superblocksteam/library": "2.0.0",
236
+ },
237
+ });
238
+
239
+ const restored = await fs.readFile(
240
+ path.join(tmpDir, "package.json"),
241
+ "utf-8",
242
+ );
243
+ expect(restored).toContain('\n "dependencies"');
244
+ expect(restored).toContain('\n "@superblocksteam/library"');
245
+ expect(restored.endsWith("\n")).toBe(true);
246
+ } finally {
247
+ await fs.rm(tmpDir, { recursive: true, force: true });
248
+ }
249
+ });
250
+ });