@superblocksteam/sdk 2.0.116-next.0 → 2.0.116-next.2

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 (34) 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 +16 -0
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/automatic-upgrades.test.js +78 -0
  6. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  7. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.d.mts +2 -0
  8. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.d.mts.map +1 -0
  9. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +341 -0
  10. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -0
  11. package/dist/cli-replacement/dev.d.mts.map +1 -1
  12. package/dist/cli-replacement/dev.mjs +121 -127
  13. package/dist/cli-replacement/dev.mjs.map +1 -1
  14. package/dist/cli-replacement/normalize-workspace-protocol.d.ts +15 -0
  15. package/dist/cli-replacement/normalize-workspace-protocol.d.ts.map +1 -0
  16. package/dist/cli-replacement/normalize-workspace-protocol.js +44 -0
  17. package/dist/cli-replacement/normalize-workspace-protocol.js.map +1 -0
  18. package/dist/cli-replacement/normalize-workspace-protocol.test.d.ts +2 -0
  19. package/dist/cli-replacement/normalize-workspace-protocol.test.d.ts.map +1 -0
  20. package/dist/cli-replacement/normalize-workspace-protocol.test.js +105 -0
  21. package/dist/cli-replacement/normalize-workspace-protocol.test.js.map +1 -0
  22. package/dist/dev-utils/dev-server.d.mts +2 -1
  23. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  24. package/dist/dev-utils/dev-server.mjs +6 -2
  25. package/dist/dev-utils/dev-server.mjs.map +1 -1
  26. package/package.json +12 -12
  27. package/src/cli-replacement/automatic-upgrades.test.ts +128 -0
  28. package/src/cli-replacement/automatic-upgrades.ts +17 -0
  29. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +407 -0
  30. package/src/cli-replacement/dev.mts +182 -170
  31. package/src/cli-replacement/normalize-workspace-protocol.test.ts +122 -0
  32. package/src/cli-replacement/normalize-workspace-protocol.ts +64 -0
  33. package/src/dev-utils/dev-server.mts +12 -1
  34. package/tsconfig.tsbuildinfo +1 -1
@@ -58,6 +58,7 @@ import {
58
58
  ensureRemoteHasDefaultBranch,
59
59
  getGitErrorFields,
60
60
  } from "./git-repo-setup.mjs";
61
+ import { normalizeWorkspaceProtocolForNpm } from "./normalize-workspace-protocol.js";
61
62
  import {
62
63
  didPackageJsonSnapshotChange,
63
64
  packageJsonSnapshot,
@@ -205,6 +206,45 @@ async function readPkgJson(cwd: string) {
205
206
  }
206
207
  }
207
208
 
209
+ async function normalizePackageJsonForNpm(cwd: string, logger: Logger) {
210
+ const packageJsonPath = path.join(cwd, "package.json");
211
+ let raw: string;
212
+ try {
213
+ raw = await nodeFs.readFile(packageJsonPath, "utf8");
214
+ } catch (err) {
215
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
216
+ throw err;
217
+ }
218
+ return;
219
+ }
220
+ let parsed: ReturnType<typeof JSON.parse>;
221
+ try {
222
+ parsed = JSON.parse(raw);
223
+ } catch (err) {
224
+ logger.warn(
225
+ `Could not parse package.json at ${packageJsonPath} for workspace-protocol normalization: ${err instanceof Error ? err.message : String(err)}`,
226
+ );
227
+ return;
228
+ }
229
+
230
+ const result = normalizeWorkspaceProtocolForNpm(parsed);
231
+ if (!result.changed) return;
232
+
233
+ for (const warning of result.warnings) {
234
+ logger.warn(warning);
235
+ }
236
+ try {
237
+ await nodeFs.writeFile(
238
+ packageJsonPath,
239
+ `${JSON.stringify(result.packageJson, null, 2)}\n`,
240
+ );
241
+ } catch (err) {
242
+ throw new Error(
243
+ `Failed to write normalized package.json at ${packageJsonPath}: ${err instanceof Error ? err.message : String(err)}`,
244
+ );
245
+ }
246
+ }
247
+
208
248
  async function installPackages(cwd: string, logger: Logger) {
209
249
  try {
210
250
  const pm = await detect({
@@ -224,6 +264,13 @@ async function installPackages(cwd: string, logger: Logger) {
224
264
  return;
225
265
  }
226
266
 
267
+ if (pm.agent === "npm") {
268
+ // npm rejects pnpm's `workspace:` protocol with EUNSUPPORTEDPROTOCOL,
269
+ // crash-looping the SABS dev-server pod. Strip the prefix so npm can
270
+ // resolve a published version instead.
271
+ await normalizePackageJsonForNpm(cwd, logger);
272
+ }
273
+
227
274
  const installCommand = resolveCommand(
228
275
  pm.agent,
229
276
  "install",
@@ -370,6 +417,10 @@ export async function dev(options: {
370
417
 
371
418
  const fsOperationQueue = new OperationQueue();
372
419
  let activeDbfsBranchName = applicationConfig?.branchName ?? "main";
420
+ let devStartupGitOutcome:
421
+ | "skipped_no_remote"
422
+ | "bootstrap_failed"
423
+ | "completed" = "skipped_no_remote";
373
424
  if (applicationConfig && !skipSync) {
374
425
  const rpcClient = new AutoConnectingRpcClient(
375
426
  tokenConfig.superblocksBaseUrl,
@@ -482,11 +533,11 @@ export async function dev(options: {
482
533
  );
483
534
  }
484
535
 
485
- const [localContents, serverHash, currentUser] =
486
- await tracer.startActiveSpan("fetchInitInfo", async (span) => {
536
+ const [serverHash, currentUser] = await tracer.startActiveSpan(
537
+ "fetchUserAndServerHash",
538
+ async (span) => {
487
539
  try {
488
- const results = await Promise.all([
489
- sdk.hashLocalDirectory(cwd),
540
+ return await Promise.all([
490
541
  getDraftOrLiveEditHash(
491
542
  sdk,
492
543
  applicationConfig.id,
@@ -495,11 +546,15 @@ export async function dev(options: {
495
546
  ),
496
547
  sdk.fetchCurrentUser(),
497
548
  ]);
498
- return results;
499
549
  } finally {
500
550
  span.end();
501
551
  }
502
- });
552
+ },
553
+ );
554
+
555
+ logger.info(
556
+ `[dev-startup] Fetched server hash for branch '${activeDbfsBranchName}'`,
557
+ );
503
558
 
504
559
  gitUserName = currentUser.user.name;
505
560
  gitUserEmail = currentUser.user.email;
@@ -558,60 +613,15 @@ export async function dev(options: {
558
613
  },
559
614
  );
560
615
 
561
- const isSynced = localContents.hash === serverHash;
562
-
563
- if (isSynced) {
564
- logger.info(
565
- `Local files are in sync with the server on branch '${activeDbfsBranchName}', local hash: ${localContents.hash}, server hash: ${serverHash}`,
566
- );
567
- } else {
568
- logger.info(
569
- `Local files are out of sync with the server on branch '${activeDbfsBranchName}', local hash: ${localContents.hash}, server hash: ${serverHash}`,
570
- );
571
- }
572
-
573
- if (!(downloadFirst || uploadFirst)) {
574
- throw new Error(
575
- "You must choose --download-first or --upload-first to use the dev command",
576
- );
577
- }
578
- if (downloadFirst && uploadFirst) {
579
- throw new Error(
580
- "Choose either --download-first or --upload-first",
581
- );
582
- }
583
-
584
- let hasPackageChanged = false;
585
- let packageJsonRequiresInstall = false;
586
- const hasPackageJsonSnapshotBeforeRestore =
587
- options.packageJsonSnapshotBeforeRestore !== undefined;
588
-
589
- const packageJsonBefore = await readPkgJson(cwd);
590
-
591
- if (downloadFirst && !isSynced) {
592
- await tracer.startActiveSpan("downloadFirst", async (span) => {
593
- logger.info(
594
- `Starting download of branch '${activeDbfsBranchName}'`,
595
- );
596
-
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
- }
609
- span.end();
610
- });
611
- }
616
+ logger.info(
617
+ `[dev-startup] Starting git sync (${downloadFirst ? "download" : "upload"}-first) on branch '${activeDbfsBranchName}'`,
618
+ );
612
619
 
613
- // Git sync step — runs after DBFS download, before version check.
614
- // Best-effort: never blocks startup on failure.
620
+ // Git sync step — runs before DBFS download so the working tree can
621
+ // be reconciled with remotes/branches first; DBFS download then
622
+ // overwrites app files with server state (source of truth). The
623
+ // .git directory is not managed by DBFS. Best-effort: never blocks
624
+ // startup on failure.
615
625
  await tracer.startActiveSpan("gitSync", async (gitSpan) => {
616
626
  try {
617
627
  const bootstrappedGitService = await bootstrapGitService({
@@ -625,6 +635,7 @@ export async function dev(options: {
625
635
  });
626
636
 
627
637
  if (!bootstrappedGitService) {
638
+ devStartupGitOutcome = "skipped_no_remote";
628
639
  logger.info("[git] startup git sync skipped", {
629
640
  gitCategory: "setup",
630
641
  gitOperation: "bootstrap",
@@ -633,104 +644,11 @@ export async function dev(options: {
633
644
  applicationId: applicationConfig.id,
634
645
  workDir: cwd,
635
646
  });
636
-
637
- // In CSB mode, schedule a background retry loop. The app's
638
- // git config may not be persisted yet at claim time (e.g. git
639
- // was connected moments before the CSB was assigned). Without
640
- // this, the CSB runs its entire lifetime without git — meaning
641
- // changes are only in DBFS and are lost if the CSB recycles.
642
- if (lockType === LockType.CSB) {
643
- const GIT_RETRY_DELAY_MS = 30_000;
644
- const GIT_RETRY_MAX_ATTEMPTS = 10;
645
- const scheduleRetry = (attempt: number): void => {
646
- if (attempt > GIT_RETRY_MAX_ATTEMPTS) {
647
- logger.info(
648
- "[git] background bootstrap retry exhausted, giving up",
649
- {
650
- gitCategory: "setup",
651
- gitOperation: "background-retry",
652
- gitOutcome: "exhausted",
653
- gitAttempt: attempt - 1,
654
- applicationId: applicationConfig.id,
655
- },
656
- );
657
- return;
658
- }
659
- const timer = setTimeout(() => {
660
- void (async () => {
661
- if (gitService) return;
662
- try {
663
- const svc = await bootstrapGitService({
664
- sdk,
665
- applicationId: applicationConfig.id,
666
- cwd,
667
- logger,
668
- userName: gitUserName,
669
- userEmail: gitUserEmail,
670
- superblocksBaseUrl:
671
- tokenConfig.superblocksBaseUrl,
672
- });
673
- if (!svc) {
674
- scheduleRetry(attempt + 1);
675
- return;
676
- }
677
-
678
- gitService = svc;
679
-
680
- try {
681
- await fetchAndEnsureLiveBranch(
682
- svc,
683
- "Git background retry fetch failed",
684
- );
685
- } catch {
686
- // non-fatal
687
- }
688
-
689
- activeDbfsBranchName =
690
- await ensureRuntimeDbfsBranchConsistency({
691
- sdk,
692
- applicationConfig,
693
- logger,
694
- lockService,
695
- syncService,
696
- currentBranchName: activeDbfsBranchName,
697
- });
698
-
699
- logger.info(
700
- "[git] background bootstrap retry succeeded",
701
- {
702
- gitCategory: "setup",
703
- gitOperation: "background-retry",
704
- gitOutcome: "success",
705
- gitAttempt: attempt,
706
- applicationId: applicationConfig.id,
707
- },
708
- );
709
- } catch (err) {
710
- logger.warn(
711
- "[git] background bootstrap retry failed",
712
- {
713
- gitCategory: "setup",
714
- gitOperation: "background-retry",
715
- gitOutcome: "failed",
716
- gitAttempt: attempt,
717
- applicationId: applicationConfig.id,
718
- ...getGitErrorFields(err),
719
- },
720
- );
721
- scheduleRetry(attempt + 1);
722
- }
723
- })();
724
- }, GIT_RETRY_DELAY_MS);
725
- timer.unref();
726
- };
727
- scheduleRetry(1);
728
- }
729
-
730
647
  gitSpan.end();
731
648
  return;
732
649
  }
733
650
 
651
+ devStartupGitOutcome = "completed";
734
652
  gitService = bootstrappedGitService;
735
653
 
736
654
  // At this point the local repo is usable for status(),
@@ -774,6 +692,7 @@ export async function dev(options: {
774
692
  }
775
693
  } catch (gitError) {
776
694
  // Init/configure/ensureGitRepo failed — repo is not usable
695
+ devStartupGitOutcome = "bootstrap_failed";
777
696
  gitService = undefined;
778
697
  logger.warn(
779
698
  "[git] startup git setup failed, continuing without git",
@@ -798,6 +717,93 @@ export async function dev(options: {
798
717
  }
799
718
  });
800
719
 
720
+ logger.info(
721
+ `[dev-startup] Git sync complete (outcome=${devStartupGitOutcome})`,
722
+ );
723
+
724
+ const localContents = await tracer.startActiveSpan(
725
+ "hashLocalDirectory",
726
+ async (span) => {
727
+ try {
728
+ return await sdk.hashLocalDirectory(cwd);
729
+ } finally {
730
+ span.end();
731
+ }
732
+ },
733
+ );
734
+
735
+ const isSynced = localContents.hash === serverHash;
736
+
737
+ logger.info(
738
+ `[dev-startup] Local directory ${isSynced ? "in sync" : "out of sync"} with server`,
739
+ );
740
+
741
+ if (!(downloadFirst || uploadFirst)) {
742
+ throw new Error(
743
+ "You must choose --download-first or --upload-first to use the dev command",
744
+ );
745
+ }
746
+ if (downloadFirst && uploadFirst) {
747
+ throw new Error(
748
+ "Choose either --download-first or --upload-first",
749
+ );
750
+ }
751
+
752
+ let hasPackageChanged = false;
753
+ let packageJsonRequiresInstall = false;
754
+ const hasPackageJsonSnapshotBeforeRestore =
755
+ options.packageJsonSnapshotBeforeRestore !== undefined;
756
+
757
+ const packageJsonBefore = await readPkgJson(cwd);
758
+
759
+ if (downloadFirst && !isSynced) {
760
+ await tracer.startActiveSpan("downloadFirst", async (span) => {
761
+ logger.info(
762
+ `Starting download of branch '${activeDbfsBranchName}'`,
763
+ );
764
+
765
+ await syncService!.downloadDirectory();
766
+ logger.info("[dev-startup] DBFS download complete");
767
+ try {
768
+ const postDownloadLocalContents =
769
+ await tracer.startActiveSpan(
770
+ "hashLocalDirectoryAfterDbfsDownload",
771
+ async (hashSpan) => {
772
+ try {
773
+ return await sdk.hashLocalDirectory(cwd);
774
+ } finally {
775
+ hashSpan.end();
776
+ }
777
+ },
778
+ );
779
+ const hashMatchesServer =
780
+ postDownloadLocalContents.hash === serverHash;
781
+ logger.info(
782
+ `[dev-startup] Post-download hash ${hashMatchesServer ? "matches" : "does not match"} server`,
783
+ );
784
+ } catch (hashError) {
785
+ logger.warn(
786
+ "[dev-startup] Failed to compute post-download hash",
787
+ getErrorMeta(hashError),
788
+ );
789
+ }
790
+ if (
791
+ options.normalizeManagedPackageDependencies &&
792
+ (await restoreManagedPackageDependencies(
793
+ cwd,
794
+ packageJsonBefore,
795
+ ))
796
+ ) {
797
+ logger.info(
798
+ "Restored managed package dependencies to the warm template versions after DBFS download",
799
+ );
800
+ }
801
+ span.end();
802
+ });
803
+ } else if (downloadFirst && isSynced) {
804
+ logger.info("[dev-startup] Skipping download, already in sync");
805
+ }
806
+
801
807
  let hasCliUpdated = false;
802
808
  let upgradePromises: Promise<void>[] = [];
803
809
  const forceUpgrade =
@@ -1085,6 +1091,7 @@ export async function dev(options: {
1085
1091
  lockService: lockService,
1086
1092
  aiService: aiService,
1087
1093
  gitService: gitService,
1094
+ gitBootstrapFailed: devStartupGitOutcome === "bootstrap_failed",
1088
1095
  activateGitService: activateRuntimeGitService,
1089
1096
  snapshotManager: snapshotManager,
1090
1097
  logger: options.logger,
@@ -1198,6 +1205,12 @@ async function bootstrapGitService({
1198
1205
  }
1199
1206
 
1200
1207
  await service.configure({ credentials, userName, userEmail });
1208
+ logger.info("[git] configure complete, proceeding to ensureGitRepo", {
1209
+ gitCategory: "setup",
1210
+ gitOperation: "bootstrap",
1211
+ applicationId,
1212
+ workDir: cwd,
1213
+ });
1201
1214
  await ensureGitRepo(service, gitConfig.gitRemoteUrl, superblocksBaseUrl);
1202
1215
 
1203
1216
  return service;
@@ -1241,6 +1254,18 @@ async function ensureGitRepo(
1241
1254
 
1242
1255
  if (!hasGit) {
1243
1256
  await git.init();
1257
+ const gitDirAfterInit = await nodeFs
1258
+ .access(path.join(git.workDir, ".git"))
1259
+ .then(
1260
+ () => true,
1261
+ () => false,
1262
+ );
1263
+ getLogger().info("[git] ensureGitRepo init complete", {
1264
+ gitCategory: "setup",
1265
+ gitOperation: "ensure-repo",
1266
+ gitDirCreated: gitDirAfterInit,
1267
+ workDir: git.workDir,
1268
+ });
1244
1269
  await git.addRemote("origin", remoteUrl);
1245
1270
 
1246
1271
  const remoteRefs = await git
@@ -1413,23 +1438,10 @@ async function ensureLiveBranchCheckedOutAfterFetch(
1413
1438
  // If remote live branch exists, initialize/switch local live branch from it.
1414
1439
  // This handles fresh repos where HEAD is unborn before first fetch.
1415
1440
  if (await canResolveRef(git, `origin/${SUPERBLOCKS_LIVE_GIT_BRANCH}`)) {
1416
- try {
1417
- await git.checkoutOrCreate(
1418
- SUPERBLOCKS_LIVE_GIT_BRANCH,
1419
- `origin/${SUPERBLOCKS_LIVE_GIT_BRANCH}`,
1420
- );
1421
- } catch {
1422
- // Pre-warmed CSBs download DBFS files before git is set up, leaving
1423
- // untracked working tree files that collide with the branch contents.
1424
- // Force-checkout is safe here — the files are identical.
1425
- await git.raw([
1426
- "checkout",
1427
- "-f",
1428
- "-B",
1429
- SUPERBLOCKS_LIVE_GIT_BRANCH,
1430
- `origin/${SUPERBLOCKS_LIVE_GIT_BRANCH}`,
1431
- ]);
1432
- }
1441
+ await git.checkoutOrCreate(
1442
+ SUPERBLOCKS_LIVE_GIT_BRANCH,
1443
+ `origin/${SUPERBLOCKS_LIVE_GIT_BRANCH}`,
1444
+ );
1433
1445
  return;
1434
1446
  }
1435
1447
 
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import { normalizeWorkspaceProtocolForNpm } from "./normalize-workspace-protocol.js";
4
+
5
+ describe("normalizeWorkspaceProtocolForNpm", () => {
6
+ it("returns unchanged when no workspace: deps are present", () => {
7
+ const pkg = {
8
+ dependencies: { react: "^18.0.0", "@superblocksteam/library": "2.5.0" },
9
+ devDependencies: { vitest: "^1.0.0" },
10
+ };
11
+ const result = normalizeWorkspaceProtocolForNpm(pkg);
12
+
13
+ expect(result.changed).toBe(false);
14
+ expect(result.warnings).toHaveLength(0);
15
+ expect(result.packageJson).toEqual(pkg);
16
+ });
17
+
18
+ it("strips workspace: prefix from dependencies and reports a warning per dep", () => {
19
+ const pkg = {
20
+ dependencies: {
21
+ "@superblocksteam/library": "workspace:*",
22
+ "@superblocksteam/sdk-api": "workspace:*",
23
+ react: "^18.0.0",
24
+ },
25
+ };
26
+ const result = normalizeWorkspaceProtocolForNpm(pkg);
27
+
28
+ expect(result.changed).toBe(true);
29
+ expect(result.packageJson.dependencies).toEqual({
30
+ "@superblocksteam/library": "*",
31
+ "@superblocksteam/sdk-api": "*",
32
+ react: "^18.0.0",
33
+ });
34
+ expect(result.warnings).toHaveLength(2);
35
+ expect(result.warnings.join(" ")).toContain("@superblocksteam/library");
36
+ expect(result.warnings.join(" ")).toContain("@superblocksteam/sdk-api");
37
+ });
38
+
39
+ it("preserves the version range after the workspace: prefix", () => {
40
+ const pkg = {
41
+ dependencies: {
42
+ a: "workspace:^1.2.3",
43
+ b: "workspace:~2.0.0",
44
+ c: "workspace:1.0.0",
45
+ },
46
+ };
47
+ const result = normalizeWorkspaceProtocolForNpm(pkg);
48
+
49
+ expect(result.changed).toBe(true);
50
+ expect(result.packageJson.dependencies).toEqual({
51
+ a: "^1.2.3",
52
+ b: "~2.0.0",
53
+ c: "1.0.0",
54
+ });
55
+ });
56
+
57
+ it("normalizes across all dependency buckets (devDependencies, optionalDependencies, peerDependencies)", () => {
58
+ const pkg = {
59
+ dependencies: { a: "workspace:*" },
60
+ devDependencies: { b: "workspace:*" },
61
+ optionalDependencies: { c: "workspace:*" },
62
+ peerDependencies: { d: "workspace:*" },
63
+ };
64
+ const result = normalizeWorkspaceProtocolForNpm(pkg);
65
+
66
+ expect(result.changed).toBe(true);
67
+ expect(result.packageJson.dependencies?.a).toBe("*");
68
+ expect(result.packageJson.devDependencies?.b).toBe("*");
69
+ expect(result.packageJson.optionalDependencies?.c).toBe("*");
70
+ expect(result.packageJson.peerDependencies?.d).toBe("*");
71
+ });
72
+
73
+ it("does not touch values that merely contain the substring 'workspace' but don't use the protocol", () => {
74
+ const pkg = {
75
+ dependencies: {
76
+ "some-workspace-tool": "1.0.0",
77
+ a: "git+ssh://git@github.com/foo/workspace.git",
78
+ },
79
+ };
80
+ const result = normalizeWorkspaceProtocolForNpm(pkg);
81
+
82
+ expect(result.changed).toBe(false);
83
+ expect(result.packageJson).toEqual(pkg);
84
+ });
85
+
86
+ it("does not mutate the input object", () => {
87
+ const pkg = {
88
+ dependencies: { a: "workspace:*" },
89
+ };
90
+ const original = JSON.parse(JSON.stringify(pkg));
91
+ normalizeWorkspaceProtocolForNpm(pkg);
92
+ expect(pkg).toEqual(original);
93
+ });
94
+
95
+ it("handles a package.json with no dependency buckets", () => {
96
+ const pkg = { name: "test", version: "1.0.0" };
97
+ const result = normalizeWorkspaceProtocolForNpm(pkg);
98
+
99
+ expect(result.changed).toBe(false);
100
+ expect(result.warnings).toHaveLength(0);
101
+ expect(result.packageJson).toEqual(pkg);
102
+ });
103
+
104
+ it("bare workspace: (no version after colon) falls back to *", () => {
105
+ const pkg = { dependencies: { a: "workspace:" } };
106
+ const result = normalizeWorkspaceProtocolForNpm(pkg);
107
+
108
+ expect(result.changed).toBe(true);
109
+ expect(result.packageJson.dependencies?.a).toBe("*");
110
+ });
111
+
112
+ it("operator-only forms (workspace:^ and workspace:~) preserve the operator as-is", () => {
113
+ const pkg = {
114
+ dependencies: { a: "workspace:^", b: "workspace:~" },
115
+ };
116
+ const result = normalizeWorkspaceProtocolForNpm(pkg);
117
+
118
+ expect(result.changed).toBe(true);
119
+ expect(result.packageJson.dependencies?.a).toBe("^");
120
+ expect(result.packageJson.dependencies?.b).toBe("~");
121
+ });
122
+ });
@@ -0,0 +1,64 @@
1
+ // pnpm's `workspace:` protocol is not understood by npm — `npm install` rejects
2
+ // it with EUNSUPPORTEDPROTOCOL. In the SABS dev-server pod the user app's
3
+ // package.json can arrive with `workspace:*` deps (build-time rewrite was
4
+ // missed, DBFS row predates the rewrite, etc.) and the install crash-loops the
5
+ // pod with no useful signal. This helper strips the `workspace:` prefix so npm
6
+ // can resolve a published version. It runs in the cloud pod only — local dev
7
+ // uses pnpm and needs the protocol intact for workspace linking.
8
+
9
+ type PackageJson = {
10
+ dependencies?: Record<string, string>;
11
+ devDependencies?: Record<string, string>;
12
+ optionalDependencies?: Record<string, string>;
13
+ peerDependencies?: Record<string, string>;
14
+ [key: string]: unknown;
15
+ };
16
+
17
+ const DEPENDENCY_BUCKETS = [
18
+ "dependencies",
19
+ "devDependencies",
20
+ "optionalDependencies",
21
+ "peerDependencies",
22
+ ] as const;
23
+
24
+ const WORKSPACE_PROTOCOL = "workspace:";
25
+
26
+ export interface NormalizeResult {
27
+ changed: boolean;
28
+ warnings: string[];
29
+ packageJson: PackageJson;
30
+ }
31
+
32
+ export function normalizeWorkspaceProtocolForNpm(
33
+ packageJson: PackageJson,
34
+ ): NormalizeResult {
35
+ const next: PackageJson = { ...packageJson };
36
+ const warnings: string[] = [];
37
+ let changed = false;
38
+
39
+ for (const bucket of DEPENDENCY_BUCKETS) {
40
+ const deps = packageJson[bucket];
41
+ if (!deps) continue;
42
+
43
+ const nextDeps: Record<string, string> = {};
44
+ for (const [name, value] of Object.entries(deps)) {
45
+ if (typeof value === "string" && value.startsWith(WORKSPACE_PROTOCOL)) {
46
+ const stripped = value.slice(WORKSPACE_PROTOCOL.length) || "*";
47
+ nextDeps[name] = stripped;
48
+ changed = true;
49
+ warnings.push(
50
+ // For @superblocksteam/sdk-api this falls back to the latest published
51
+ // non-ephemeral package. To pin an ephemeral version, fix the upstream
52
+ // rewrite path (scripts/setup-template.sh) so workspace:* never ships
53
+ // to the pod.
54
+ `Stripped pnpm "workspace:" protocol from ${bucket}.${name} (was "${value}", now "${stripped}") so npm install can resolve it.`,
55
+ );
56
+ } else {
57
+ nextDeps[name] = String(value);
58
+ }
59
+ }
60
+ next[bucket] = nextDeps;
61
+ }
62
+
63
+ return { changed, warnings, packageJson: next };
64
+ }