@superblocksteam/sdk 2.0.123 → 2.0.124-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 (115) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts +37 -1
  3. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  4. package/dist/cli-replacement/automatic-upgrades.js +162 -10
  5. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  6. package/dist/cli-replacement/automatic-upgrades.test.js +377 -8
  7. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  8. package/dist/cli-replacement/dependency-install-classifier.d.mts +21 -0
  9. package/dist/cli-replacement/dependency-install-classifier.d.mts.map +1 -0
  10. package/dist/cli-replacement/dependency-install-classifier.mjs +83 -0
  11. package/dist/cli-replacement/dependency-install-classifier.mjs.map +1 -0
  12. package/dist/cli-replacement/dependency-install-classifier.test.d.mts +2 -0
  13. package/dist/cli-replacement/dependency-install-classifier.test.d.mts.map +1 -0
  14. package/dist/cli-replacement/dependency-install-classifier.test.mjs +51 -0
  15. package/dist/cli-replacement/dependency-install-classifier.test.mjs.map +1 -0
  16. package/dist/cli-replacement/dev-s3-restore.test.mjs +403 -14
  17. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  18. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +33 -2
  19. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  20. package/dist/cli-replacement/dev-token-priming.test.d.mts +31 -0
  21. package/dist/cli-replacement/dev-token-priming.test.d.mts.map +1 -0
  22. package/dist/cli-replacement/dev-token-priming.test.mjs +87 -0
  23. package/dist/cli-replacement/dev-token-priming.test.mjs.map +1 -0
  24. package/dist/cli-replacement/dev.d.mts +47 -0
  25. package/dist/cli-replacement/dev.d.mts.map +1 -1
  26. package/dist/cli-replacement/dev.interception.test.d.mts +2 -0
  27. package/dist/cli-replacement/dev.interception.test.d.mts.map +1 -0
  28. package/dist/cli-replacement/dev.interception.test.mjs +68 -0
  29. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -0
  30. package/dist/cli-replacement/dev.mjs +486 -65
  31. package/dist/cli-replacement/dev.mjs.map +1 -1
  32. package/dist/cli-replacement/home-npmrc.d.mts +180 -0
  33. package/dist/cli-replacement/home-npmrc.d.mts.map +1 -0
  34. package/dist/cli-replacement/home-npmrc.mjs +283 -0
  35. package/dist/cli-replacement/home-npmrc.mjs.map +1 -0
  36. package/dist/cli-replacement/home-npmrc.test.d.mts +10 -0
  37. package/dist/cli-replacement/home-npmrc.test.d.mts.map +1 -0
  38. package/dist/cli-replacement/home-npmrc.test.mjs +582 -0
  39. package/dist/cli-replacement/home-npmrc.test.mjs.map +1 -0
  40. package/dist/cli-replacement/install-packages.classify.test.d.mts +2 -0
  41. package/dist/cli-replacement/install-packages.classify.test.d.mts.map +1 -0
  42. package/dist/cli-replacement/install-packages.classify.test.mjs +125 -0
  43. package/dist/cli-replacement/install-packages.classify.test.mjs.map +1 -0
  44. package/dist/cli-replacement/install-packages.npm-registry.test.d.mts +2 -0
  45. package/dist/cli-replacement/install-packages.npm-registry.test.d.mts.map +1 -0
  46. package/dist/cli-replacement/install-packages.npm-registry.test.mjs +260 -0
  47. package/dist/cli-replacement/install-packages.npm-registry.test.mjs.map +1 -0
  48. package/dist/cli-replacement/post-upgrade-lockfile-strip.d.mts +58 -0
  49. package/dist/cli-replacement/post-upgrade-lockfile-strip.d.mts.map +1 -0
  50. package/dist/cli-replacement/post-upgrade-lockfile-strip.mjs +224 -0
  51. package/dist/cli-replacement/post-upgrade-lockfile-strip.mjs.map +1 -0
  52. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.d.mts +11 -0
  53. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.d.mts.map +1 -0
  54. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.mjs +317 -0
  55. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.mjs.map +1 -0
  56. package/dist/cli-replacement/userconfig-env.integration.test.d.mts +26 -0
  57. package/dist/cli-replacement/userconfig-env.integration.test.d.mts.map +1 -0
  58. package/dist/cli-replacement/userconfig-env.integration.test.mjs +148 -0
  59. package/dist/cli-replacement/userconfig-env.integration.test.mjs.map +1 -0
  60. package/dist/dev-utils/dev-server-metrics.d.mts +25 -0
  61. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  62. package/dist/dev-utils/dev-server-metrics.mjs +84 -0
  63. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  64. package/dist/dev-utils/dev-server-metrics.test.d.mts +2 -0
  65. package/dist/dev-utils/dev-server-metrics.test.d.mts.map +1 -0
  66. package/dist/dev-utils/dev-server-metrics.test.mjs +26 -0
  67. package/dist/dev-utils/dev-server-metrics.test.mjs.map +1 -0
  68. package/dist/dev-utils/dev-server.d.mts +23 -1
  69. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  70. package/dist/dev-utils/dev-server.mjs +21 -9
  71. package/dist/dev-utils/dev-server.mjs.map +1 -1
  72. package/dist/dev-utils/dev-server.status.test.d.mts +2 -0
  73. package/dist/dev-utils/dev-server.status.test.d.mts.map +1 -0
  74. package/dist/dev-utils/dev-server.status.test.mjs +41 -0
  75. package/dist/dev-utils/dev-server.status.test.mjs.map +1 -0
  76. package/dist/dev-utils/token-manager.d.ts +31 -0
  77. package/dist/dev-utils/token-manager.d.ts.map +1 -1
  78. package/dist/dev-utils/token-manager.js +34 -0
  79. package/dist/dev-utils/token-manager.js.map +1 -1
  80. package/dist/telemetry/local-obs.js +1 -1
  81. package/dist/telemetry/local-obs.js.map +1 -1
  82. package/dist/telemetry/util.js +1 -1
  83. package/dist/types/scoped-jwt-token-payload.d.ts +1 -0
  84. package/dist/types/scoped-jwt-token-payload.d.ts.map +1 -1
  85. package/dist/version-control.d.mts.map +1 -1
  86. package/dist/version-control.mjs +6 -7
  87. package/dist/version-control.mjs.map +1 -1
  88. package/package.json +12 -12
  89. package/src/cli-replacement/automatic-upgrades.test.ts +530 -8
  90. package/src/cli-replacement/automatic-upgrades.ts +179 -7
  91. package/src/cli-replacement/dependency-install-classifier.mts +118 -0
  92. package/src/cli-replacement/dependency-install-classifier.test.mts +72 -0
  93. package/src/cli-replacement/dev-s3-restore.test.mts +554 -14
  94. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +35 -2
  95. package/src/cli-replacement/dev-token-priming.test.mts +103 -0
  96. package/src/cli-replacement/dev.interception.test.mts +80 -0
  97. package/src/cli-replacement/dev.mts +597 -95
  98. package/src/cli-replacement/home-npmrc.mts +409 -0
  99. package/src/cli-replacement/home-npmrc.test.mts +757 -0
  100. package/src/cli-replacement/install-packages.classify.test.mts +168 -0
  101. package/src/cli-replacement/install-packages.npm-registry.test.mts +345 -0
  102. package/src/cli-replacement/post-upgrade-lockfile-strip.mts +296 -0
  103. package/src/cli-replacement/post-upgrade-lockfile-strip.test.mts +482 -0
  104. package/src/cli-replacement/userconfig-env.integration.test.mts +189 -0
  105. package/src/dev-utils/dev-server-metrics.mts +96 -0
  106. package/src/dev-utils/dev-server-metrics.test.mts +38 -0
  107. package/src/dev-utils/dev-server.mts +48 -8
  108. package/src/dev-utils/dev-server.status.test.mts +58 -0
  109. package/src/dev-utils/token-manager.ts +36 -0
  110. package/src/telemetry/local-obs.ts +1 -1
  111. package/src/telemetry/util.ts +1 -1
  112. package/src/types/scoped-jwt-token-payload.ts +1 -0
  113. package/src/version-control.mts +8 -6
  114. package/tsconfig.tsbuildinfo +1 -1
  115. package/.turbo/turbo-publish-package.log +0 -0
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { InitialInstallFailed, classifyInitialInstallError, } from "./dependency-install-classifier.mjs";
3
+ const ERESOLVE_JSON = JSON.stringify({
4
+ error: {
5
+ code: "ERESOLVE",
6
+ summary: "unable to resolve dependency tree",
7
+ detail: 'Found: react-router@undefined\nCould not resolve dependency:\npeer react-router@">=6.4" from @superblocksteam/library@2.0.0-SNAPSHOT',
8
+ },
9
+ });
10
+ describe("classifyInitialInstallError", () => {
11
+ it("classifies ERESOLVE as dependency_conflict and extracts packages", () => {
12
+ const e = classifyInitialInstallError({
13
+ stdout: ERESOLVE_JSON,
14
+ stderr: "npm error code ERESOLVE",
15
+ message: "Command failed",
16
+ }, { hasAnyRegistryConfigured: true, requestedPackages: [] });
17
+ expect(e.type).toBe("dev-server/dependency-install");
18
+ expect(e.category).toBe("dependency_conflict");
19
+ expect(e.npmErrorCode).toBe("ERESOLVE");
20
+ expect(e.packages?.some((p) => p.name === "react-router")).toBe(true);
21
+ expect(e.rawError.length).toBeGreaterThan(0);
22
+ });
23
+ it("maps an E404 registry block to not_in_registry", () => {
24
+ const E404 = JSON.stringify({
25
+ error: {
26
+ code: "E404",
27
+ summary: "Not Found - GET https://reg.corp/foo",
28
+ detail: "'foo@1.0.0' is not in this registry.",
29
+ },
30
+ });
31
+ const e = classifyInitialInstallError({ stdout: E404, stderr: "", message: "x" }, { hasAnyRegistryConfigured: true, requestedPackages: [{ name: "foo" }] });
32
+ expect(e.category).toBe("not_in_registry");
33
+ expect(e.registryHost).toBe("reg.corp");
34
+ });
35
+ it("falls back to unknown for an unmapped code (still a DependencyInstallError)", () => {
36
+ const e = classifyInitialInstallError({
37
+ stdout: "not json",
38
+ stderr: "npm error code EWEIRD\nsomething broke",
39
+ message: "x",
40
+ }, {});
41
+ expect(e.category).toBe("unknown");
42
+ expect(e.rawError).toContain("EWEIRD");
43
+ });
44
+ it("InitialInstallFailed carries the classified error", () => {
45
+ const se = classifyInitialInstallError({ stdout: ERESOLVE_JSON, stderr: "", message: "x" }, {});
46
+ const marker = new InitialInstallFailed(se);
47
+ expect(marker).toBeInstanceOf(Error);
48
+ expect(marker.serverError).toBe(se);
49
+ });
50
+ });
51
+ //# sourceMappingURL=dependency-install-classifier.test.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dependency-install-classifier.test.mjs","sourceRoot":"","sources":["../../src/cli-replacement/dependency-install-classifier.test.mts"],"names":[],"mappings":"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"}
@@ -5,9 +5,11 @@ import * as path from "node:path";
5
5
  import { beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { packageJsonSnapshot } from "./package-json-snapshot.mjs";
7
7
  const execMock = vi.fn();
8
+ const execFileMock = vi.fn();
8
9
  vi.mock("node:child_process", () => ({
9
- default: { exec: execMock },
10
+ default: { exec: execMock, execFile: execFileMock },
10
11
  exec: execMock,
12
+ execFile: execFileMock,
11
13
  }));
12
14
  vi.mock("node:readline", () => ({
13
15
  default: {
@@ -46,6 +48,9 @@ const mockLogger = {
46
48
  error: vi.fn(),
47
49
  debug: vi.fn(),
48
50
  };
51
+ const mockAiServiceState = vi.hoisted(() => ({
52
+ npmRegistryClient: undefined,
53
+ }));
49
54
  vi.mock("../telemetry/logging.js", () => ({
50
55
  getLogger: () => mockLogger,
51
56
  getErrorMeta: vi.fn(() => ({})),
@@ -61,7 +66,12 @@ vi.mock("@opentelemetry/api", () => ({
61
66
  vi.mock("colorette", () => ({
62
67
  green: (s) => s,
63
68
  }));
64
- vi.mock("@superblocksteam/shared", () => ({
69
+ vi.mock("@superblocksteam/shared", async (importOriginal) => ({
70
+ // dev.mts now transitively loads @superblocksteam/shared (via the npm-error
71
+ // classifier re-exported from vite-plugin-file-sync/npm-registry — APPS-4450),
72
+ // pulling in many named exports (DeploymentTypeEnum, ISocket, …). Spread the
73
+ // real module so the closed overrides below don't have to enumerate them.
74
+ ...(await importOriginal()),
65
75
  buildGithubSuperblocksSyncWorkflow: vi.fn(),
66
76
  buildGithubSuperblocksSyncWorkflowFromBaseUrl: vi.fn(),
67
77
  ConflictError: class ConflictError extends Error {
@@ -82,6 +92,12 @@ vi.mock("@superblocksteam/vite-plugin-file-sync/ai-service", () => ({
82
92
  initialize = vi.fn(async () => undefined);
83
93
  removeIntegrationCache = vi.fn(async () => undefined);
84
94
  chatSessionStore = { invalidateCache: vi.fn() };
95
+ // `dev.mts` reads this at startup for two paths: `syncHomeNpmrc`
96
+ // (APPS-4231 home `~/.npmrc` writer) and the startup `installPackages`
97
+ // call (APPS-4251 per-org `allow_install_scripts`). Returning undefined
98
+ // keeps the install path on the "no policy resolved" branch and is also
99
+ // valid input for the mocked-out `syncHomeNpmrc`.
100
+ getNpmRegistryClient = vi.fn(() => mockAiServiceState.npmRegistryClient);
85
101
  },
86
102
  AiServiceFeatureFlags: class {
87
103
  static create = vi.fn(() => ({}));
@@ -92,6 +108,20 @@ vi.mock("@superblocksteam/vite-plugin-file-sync/ai-service", () => ({
92
108
  isSdkApiTemplate: vi.fn(() => false),
93
109
  stripResolvedFromLockfile: vi.fn(async () => undefined),
94
110
  }));
111
+ vi.mock("@superblocksteam/vite-plugin-file-sync/npm-registry", () => ({
112
+ shouldIgnoreInstallScripts: vi.fn(() => false),
113
+ // Used by the dependency-install-classifier reachable from installPackages'
114
+ // catch path (APPS-4450). Previously the only test that hit this codepath
115
+ // (`logs a structured error when the background install fails`) didn't
116
+ // assert classification, so the missing exports surfaced silently as a
117
+ // ReferenceError instead of an InitialInstallFailed. The new
118
+ // restart-vs-degrade test depends on installPackages re-throwing an actual
119
+ // InitialInstallFailed marker, so stub all classifier-required exports.
120
+ scrubSecrets: vi.fn((value) => value),
121
+ parseNpmJsonDiagnostic: vi.fn(() => ""),
122
+ parseNpmJsonError: vi.fn(() => null),
123
+ classifyNpmExecStderr: vi.fn(() => null),
124
+ }));
95
125
  vi.mock("@superblocksteam/vite-plugin-file-sync/git-service", () => ({
96
126
  createGitService: vi.fn(async () => undefined),
97
127
  }));
@@ -154,6 +184,24 @@ const mockCheckVersions = vi.fn(async () => ({
154
184
  }));
155
185
  vi.mock("./automatic-upgrades.js", () => ({
156
186
  checkVersionsAndWritePackageJson: (...args) => mockCheckVersions(...args),
187
+ buildInstallEnv: (userconfigPath) => ({
188
+ ...process.env,
189
+ NPM_CONFIG_USERCONFIG: userconfigPath,
190
+ PNPM_CONFIG_USERCONFIG: userconfigPath,
191
+ }),
192
+ }));
193
+ // The home `~/.npmrc` writer (APPS-4231) is a best-effort step that runs
194
+ // before auto-upgrade. Mock it out here so these tests stay focused on the
195
+ // DBFS / S3 restore behaviour. The `AiService` mock above exposes a
196
+ // minimal `getNpmRegistryClient()` stub so the call site doesn't `TypeError`
197
+ // and silently swallow this mock.
198
+ vi.mock("./home-npmrc.mjs", () => ({
199
+ syncHomeNpmrc: vi.fn(async () => ({
200
+ outcome: "skipped-not-configured",
201
+ path: "/tmp/.superblocks/npmrc",
202
+ })),
203
+ superblocksNpmrcPath: vi.fn(() => "/tmp/.superblocks/npmrc"),
204
+ superblocksLogsPath: vi.fn((appDir) => `${appDir}/.superblocks/logs`),
157
205
  }));
158
206
  vi.mock("./git-repo-setup.mjs", () => ({
159
207
  ensureRemoteHasDefaultBranch: vi.fn(async () => undefined),
@@ -207,6 +255,8 @@ function buildDevOptions(overrides = {}) {
207
255
  tokenManager: {
208
256
  getToken: vi.fn(() => "test-token"),
209
257
  onTokenRefresh: vi.fn(),
258
+ updateToken: vi.fn(),
259
+ getCurrentToken: vi.fn(() => "test-token"),
210
260
  },
211
261
  getCurrentToken: vi.fn(() => "test-token"),
212
262
  sdk: buildMockSdk(),
@@ -232,6 +282,7 @@ function expectInstallToHaveRun() {
232
282
  describe("dev startup after S3 workspace restore", () => {
233
283
  beforeEach(async () => {
234
284
  vi.clearAllMocks();
285
+ mockAiServiceState.npmRegistryClient = undefined;
235
286
  setupExecMock();
236
287
  mockCheckVersions.mockResolvedValue({
237
288
  cliUpdated: false,
@@ -454,6 +505,234 @@ describe("dev startup after S3 workspace restore", () => {
454
505
  await fs.rm(tmpDir, { recursive: true, force: true });
455
506
  }
456
507
  });
508
+ it("keeps forced npm install when unchanged restore inputs still need private registry validation", async () => {
509
+ mockAiServiceState.npmRegistryClient = {
510
+ getConfig: vi.fn(async () => ({
511
+ source: "configured",
512
+ config: { configured: true },
513
+ })),
514
+ };
515
+ const unchangedPackageJson = {
516
+ name: "test-app",
517
+ dependencies: { "@superblocksteam/library": "1.0.0" },
518
+ };
519
+ const { dev } = await import("./dev.mjs");
520
+ await dev(buildDevOptions({
521
+ forcePackageInstall: true,
522
+ packageJsonSnapshotBeforeRestore: packageJsonSnapshot(unchangedPackageJson),
523
+ }));
524
+ expectInstallToHaveRun();
525
+ expect(mockLogger.info).toHaveBeenCalledWith("Package install decision", expect.objectContaining({
526
+ forcePackageInstall: true,
527
+ forcePackageInstallRequested: true,
528
+ packageJsonRequiresInstall: false,
529
+ }));
530
+ });
531
+ it("runs npm install at startup when private registries are configured even without package changes", async () => {
532
+ mockAiServiceState.npmRegistryClient = {
533
+ getConfig: vi.fn(async () => ({
534
+ source: "configured",
535
+ config: { configured: true },
536
+ })),
537
+ };
538
+ const unchangedPackageJson = {
539
+ name: "test-app",
540
+ dependencies: { "@superblocksteam/library": "1.0.0" },
541
+ };
542
+ const { dev } = await import("./dev.mjs");
543
+ await dev(buildDevOptions({
544
+ forcePackageInstall: false,
545
+ packageJsonSnapshotBeforeRestore: packageJsonSnapshot(unchangedPackageJson),
546
+ }));
547
+ expectInstallToHaveRun();
548
+ expect(mockLogger.info).toHaveBeenCalledWith("Package install decision", expect.objectContaining({
549
+ forcePackageInstall: false,
550
+ forcePackageInstallRequested: false,
551
+ packageJsonRequiresInstall: false,
552
+ privateRegistryRequiresInstallValidation: true,
553
+ }));
554
+ });
555
+ it("does not upload package state when private registry validation install leaves the lockfile unchanged", async () => {
556
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-dev-private-registry-"));
557
+ const unchangedPackageJson = {
558
+ name: "test-app",
559
+ packageManager: "npm@10.0.0",
560
+ dependencies: { "@superblocksteam/library": "1.0.0" },
561
+ };
562
+ try {
563
+ await fs.mkdir(path.join(tmpDir, "node_modules"));
564
+ await fs.writeFile(path.join(tmpDir, "package.json"), JSON.stringify(unchangedPackageJson, null, 2));
565
+ await fs.writeFile(path.join(tmpDir, "package-lock.json"), JSON.stringify({ name: "test-app", lockfileVersion: 3 }, null, 2));
566
+ const { readPackage } = await import("read-pkg");
567
+ readPackage.mockImplementation(async ({ cwd }) => JSON.parse(await fs.readFile(path.join(cwd, "package.json"), "utf-8")));
568
+ mockAiServiceState.npmRegistryClient = {
569
+ getConfig: vi.fn(async () => ({
570
+ source: "configured",
571
+ config: { configured: true },
572
+ })),
573
+ };
574
+ const { dev } = await import("./dev.mjs");
575
+ await dev(buildDevOptions({
576
+ cwd: tmpDir,
577
+ forcePackageInstall: false,
578
+ packageJsonSnapshotBeforeRestore: packageJsonSnapshot(unchangedPackageJson),
579
+ }));
580
+ expectInstallToHaveRun();
581
+ expect(mockSyncService.uploadDirectory).not.toHaveBeenCalled();
582
+ expect(mockSyncService.uploadDirectoryNowIfNeeded).not.toHaveBeenCalled();
583
+ }
584
+ finally {
585
+ await fs.rm(tmpDir, { recursive: true, force: true });
586
+ }
587
+ });
588
+ it("does NOT upload package state when the validation install only rewrites `resolved` URLs", async () => {
589
+ // The exact churn cycle flagged in review: `stripResolvedFromLockfile`
590
+ // removes every `resolved` URL before the install and npm writes them
591
+ // back, so a byte-level comparison would flag "changed" on EVERY
592
+ // registry-validation boot and re-upload the workspace to DBFS forever.
593
+ // `lockfileComparisonKey` ignores `resolved`, so a resolved-only rewrite
594
+ // must NOT trigger the upload.
595
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-dev-private-registry-"));
596
+ const unchangedPackageJson = {
597
+ name: "test-app",
598
+ packageManager: "npm@10.0.0",
599
+ dependencies: { "@superblocksteam/library": "1.0.0" },
600
+ };
601
+ const strippedLockfile = {
602
+ name: "test-app",
603
+ lockfileVersion: 3,
604
+ packages: {
605
+ "node_modules/@superblocksteam/library": { version: "1.0.0" },
606
+ },
607
+ };
608
+ const rewrittenLockfile = {
609
+ name: "test-app",
610
+ lockfileVersion: 3,
611
+ packages: {
612
+ "node_modules/@superblocksteam/library": {
613
+ version: "1.0.0",
614
+ resolved: "https://registry.example.com/@superblocksteam/library/-/library-1.0.0.tgz",
615
+ },
616
+ },
617
+ };
618
+ try {
619
+ await fs.mkdir(path.join(tmpDir, "node_modules"));
620
+ await fs.writeFile(path.join(tmpDir, "package.json"), JSON.stringify(unchangedPackageJson, null, 2));
621
+ await fs.writeFile(path.join(tmpDir, "package-lock.json"), JSON.stringify(strippedLockfile, null, 2));
622
+ const { readPackage } = await import("read-pkg");
623
+ readPackage.mockImplementation(async ({ cwd }) => JSON.parse(await fs.readFile(path.join(cwd, "package.json"), "utf-8")));
624
+ mockAiServiceState.npmRegistryClient = {
625
+ getConfig: vi.fn(async () => ({
626
+ source: "configured",
627
+ config: { configured: true },
628
+ })),
629
+ };
630
+ execMock.mockImplementation((_cmd, opts, cb) => {
631
+ const cwd = typeof opts === "object" ? opts.cwd : tmpDir;
632
+ void fs
633
+ .writeFile(path.join(cwd ?? tmpDir, "package-lock.json"), JSON.stringify(rewrittenLockfile, null, 2))
634
+ .then(() => {
635
+ if (typeof opts === "function") {
636
+ opts(null, { stdout: "installed" });
637
+ }
638
+ else {
639
+ cb?.(null, { stdout: "installed" });
640
+ }
641
+ });
642
+ });
643
+ const { dev } = await import("./dev.mjs");
644
+ await dev(buildDevOptions({
645
+ cwd: tmpDir,
646
+ forcePackageInstall: false,
647
+ packageJsonSnapshotBeforeRestore: packageJsonSnapshot(unchangedPackageJson),
648
+ }));
649
+ expectInstallToHaveRun();
650
+ expect(mockSyncService.uploadDirectory).not.toHaveBeenCalled();
651
+ expect(mockSyncService.uploadDirectoryNowIfNeeded).not.toHaveBeenCalled();
652
+ }
653
+ finally {
654
+ await fs.rm(tmpDir, { recursive: true, force: true });
655
+ }
656
+ });
657
+ it("uploads package state when private registry validation install rewrites the lockfile", async () => {
658
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sdk-dev-private-registry-"));
659
+ const unchangedPackageJson = {
660
+ name: "test-app",
661
+ packageManager: "npm@10.0.0",
662
+ dependencies: { "@superblocksteam/library": "1.0.0" },
663
+ };
664
+ try {
665
+ await fs.mkdir(path.join(tmpDir, "node_modules"));
666
+ await fs.writeFile(path.join(tmpDir, "package.json"), JSON.stringify(unchangedPackageJson, null, 2));
667
+ await fs.writeFile(path.join(tmpDir, "package-lock.json"), JSON.stringify({ name: "test-app", lockfileVersion: 3 }, null, 2));
668
+ const { readPackage } = await import("read-pkg");
669
+ readPackage.mockImplementation(async ({ cwd }) => JSON.parse(await fs.readFile(path.join(cwd, "package.json"), "utf-8")));
670
+ mockAiServiceState.npmRegistryClient = {
671
+ getConfig: vi.fn(async () => ({
672
+ source: "configured",
673
+ config: { configured: true },
674
+ })),
675
+ };
676
+ execMock.mockImplementation((_cmd, opts, cb) => {
677
+ const cwd = typeof opts === "object" ? opts.cwd : tmpDir;
678
+ void fs
679
+ .writeFile(path.join(cwd ?? tmpDir, "package-lock.json"), JSON.stringify({ name: "test-app", lockfileVersion: 3, packages: {} }, null, 2))
680
+ .then(() => {
681
+ if (typeof opts === "function") {
682
+ opts(null, { stdout: "installed" });
683
+ }
684
+ else {
685
+ cb?.(null, { stdout: "installed" });
686
+ }
687
+ });
688
+ });
689
+ const { dev } = await import("./dev.mjs");
690
+ await dev(buildDevOptions({
691
+ cwd: tmpDir,
692
+ forcePackageInstall: false,
693
+ packageJsonSnapshotBeforeRestore: packageJsonSnapshot(unchangedPackageJson),
694
+ }));
695
+ expectInstallToHaveRun();
696
+ expect(mockSyncService.uploadDirectory).toHaveBeenCalledWith("cli:sdk");
697
+ expect(mockSyncService.uploadDirectoryNowIfNeeded).toHaveBeenCalledWith("cli:sdk");
698
+ }
699
+ finally {
700
+ await fs.rm(tmpDir, { recursive: true, force: true });
701
+ }
702
+ });
703
+ it("does NOT force the startup install when the registry config is unreachable (fail closed, APPS-4527)", async () => {
704
+ // `unreachable` with no last-known-good means we could not materialize
705
+ // the right `.npmrc`. Forcing an install would re-resolve a
706
+ // private-registry org's lockfile against whatever registry happens to
707
+ // be on disk — validating against the wrong host and writing its URLs
708
+ // back into the lockfile. Fail closed instead (APPS-4370 principle):
709
+ // keep today's snapshot decision and skip the install.
710
+ mockAiServiceState.npmRegistryClient = {
711
+ getConfig: vi.fn(async () => ({ source: "unreachable" })),
712
+ };
713
+ const { dev } = await import("./dev.mjs");
714
+ await dev(buildDevOptions());
715
+ expect(execMock).not.toHaveBeenCalled();
716
+ expect(mockLogger.info).toHaveBeenCalledWith("Package install decision", expect.objectContaining({
717
+ packageJsonRequiresInstall: false,
718
+ forcePackageInstall: false,
719
+ privateRegistryRequiresInstallValidation: false,
720
+ }));
721
+ });
722
+ it("does NOT force the startup install when registry config resolution throws (fail closed, APPS-4527)", async () => {
723
+ mockAiServiceState.npmRegistryClient = {
724
+ getConfig: vi.fn(async () => {
725
+ throw new Error("registry config fetch failed");
726
+ }),
727
+ };
728
+ const { dev } = await import("./dev.mjs");
729
+ await dev(buildDevOptions());
730
+ expect(execMock).not.toHaveBeenCalled();
731
+ expect(mockLogger.warn).toHaveBeenCalledWith("Could not resolve npm registry config for startup install validation; preserving package snapshot decision", expect.anything());
732
+ expect(mockLogger.info).toHaveBeenCalledWith("Package install decision", expect.objectContaining({
733
+ privateRegistryRequiresInstallValidation: false,
734
+ }));
735
+ });
457
736
  });
458
737
  // Wires up `execMock` so install calls hang until the test calls
459
738
  // `finishInstall`. We stash the promisify callback directly rather than
@@ -503,6 +782,7 @@ function setupDeferredInstallMock(options = {}) {
503
782
  describe("dev background package install join semantics", () => {
504
783
  beforeEach(async () => {
505
784
  vi.clearAllMocks();
785
+ mockAiServiceState.npmRegistryClient = undefined;
506
786
  mockCheckVersions.mockResolvedValue({
507
787
  cliUpdated: false,
508
788
  upgradePromises: [],
@@ -543,31 +823,140 @@ describe("dev background package install join semantics", () => {
543
823
  it("logs a structured error when the background install fails", async () => {
544
824
  const deferred = setupDeferredInstallMock({ fail: true });
545
825
  // Use upgradePromises so the install runs without forcing the upload
546
- // path — the failure then propagates through the pre-Vite join, whose
547
- // outer catch only sets the span status and rethrows (no process.exit,
548
- // which vitest replaces with a throw that becomes an unhandled error).
826
+ // path — the failure propagates through the pre-Vite join. With the
827
+ // dependency-install-classifier fully wired in tests, the rejection is
828
+ // an InitialInstallFailed marker and `handleStartupError` routes it to
829
+ // "degrade" (Vite still starts up, dev() resolves with the error
830
+ // recorded on devServerStatus). The point of THIS test is the
831
+ // independent `.catch` backstop on packageInstallPromise — it must
832
+ // log a structured error regardless of which downstream join consumes
833
+ // the rejection.
549
834
  mockCheckVersions.mockResolvedValue({
550
835
  cliUpdated: false,
551
836
  upgradePromises: [Promise.resolve()],
552
837
  });
553
838
  const { dev } = await import("./dev.mjs");
554
- // Pre-attach a catcher so vitest's unhandled-rejection detector observes
555
- // the rejection synchronously with promise creation; without this, the
556
- // .catch backstop's logging counts as a handler but lands on the
557
- // microtask queue after vitest's check.
558
- let caughtError;
559
- const devPromise = dev(buildDevOptions()).catch((err) => {
560
- caughtError = err;
561
- });
839
+ const devPromise = dev(buildDevOptions()).catch(() => undefined);
562
840
  await deferred.awaitInstallStarted();
563
841
  await deferred.finishInstall();
564
842
  await devPromise;
565
- expect(caughtError).toBeDefined();
566
843
  // The .catch backstop fires regardless and tags the log with errorId so
567
844
  // alert rules can match without needing typed attributes.
568
845
  expect(mockLogger.error.mock.calls.some((call) => typeof call[0] === "string" &&
569
846
  call[0].includes("Background package install failed") &&
570
847
  call[0].includes("errorId=DEV_SERVER_BG_INSTALL_FAILED"))).toBe(true);
571
848
  });
849
+ // APPS-4450 review: an InitialInstallFailed observed in the "before upload"
850
+ // join must NOT preempt a successful CLI upgrade restart. Previously the
851
+ // join's rejection was caught by the outer sync/setup catch (→ degrade) and
852
+ // execution never reached the `hasCliUpdated` branch, so the restart was
853
+ // silently dropped. The fix catches the join locally when `hasCliUpdated`
854
+ // and falls through to the restart block.
855
+ it("prioritises CLI restart over install-failure degrade when both fire in the upload path", async () => {
856
+ const deferred = setupDeferredInstallMock({ fail: true });
857
+ // CLI upgrade succeeds + `forcePackageInstall` triggers the upload path.
858
+ mockCheckVersions.mockResolvedValue({
859
+ cliUpdated: true,
860
+ upgradePromises: [Promise.resolve()],
861
+ });
862
+ // Capture `process.exit` synchronously; the production path calls it on
863
+ // the restart with AUTO_UPGRADE_EXIT_CODE (mocked to 99). Track every
864
+ // call and throw a tagged error so the test can assert the exit code
865
+ // without actually terminating the vitest worker. We replace
866
+ // `process.exit` directly instead of `vi.spyOn` — vitest's `process`
867
+ // object is partially shadowed and `vi.spyOn(process, "exit")` swallows
868
+ // calls intermittently here.
869
+ const originalExit = process.exit;
870
+ const exitCalls = [];
871
+ process.exit = (code) => {
872
+ exitCalls.push(code);
873
+ throw new Error(`__test_process_exit__:${String(code)}`);
874
+ };
875
+ try {
876
+ const { dev } = await import("./dev.mjs");
877
+ let caughtError;
878
+ const devPromise = dev(buildDevOptions({ forcePackageInstall: true })).catch((err) => {
879
+ caughtError = err;
880
+ });
881
+ await deferred.awaitInstallStarted();
882
+ await deferred.finishInstall();
883
+ await devPromise;
884
+ // First exit call MUST be the CLI-restart with AUTO_UPGRADE_EXIT_CODE
885
+ // (mocked to 99). The throw escapes through the outer catch, which
886
+ // routes the non-InitialInstallFailed thrown wrapper through
887
+ // handleStartupError → exit, so a follow-up `process.exit(1)` is
888
+ // expected and benign. The invariant is the FIRST call.
889
+ expect(exitCalls[0]).toBe(99);
890
+ // devPromise resolved (or rejected) after the chain settled.
891
+ expect(caughtError).toBeDefined();
892
+ // Upload was skipped — install failed before the upload body could run
893
+ // and the local catch routed straight to the restart branch.
894
+ expect(mockSyncService.uploadDirectory).not.toHaveBeenCalled();
895
+ expect(mockSyncService.uploadDirectoryNowIfNeeded).not.toHaveBeenCalled();
896
+ // Restart actually got reached (logged + skip-upload reason logged).
897
+ expect(mockLogger.info).toHaveBeenCalledWith("CLI was updated, restarting the dev server…");
898
+ expect(mockLogger.info.mock.calls.some((call) => typeof call[0] === "string" &&
899
+ call[0].includes("Initial package install failed before startup upload"))).toBe(true);
900
+ }
901
+ finally {
902
+ process.exit = originalExit;
903
+ }
904
+ });
905
+ // APPS-4450 review (companion to above): the new local try/catch in the
906
+ // upload path must only swallow `InitialInstallFailed`. A non-marker
907
+ // rejection from `joinUpgradeThenInstall("before upload")` (e.g. a failed
908
+ // package upgrade) must rethrow so the outer sync/setup catch routes it to
909
+ // `process.exit(1)` — even when `hasCliUpdated` is true. Otherwise an
910
+ // upgrade rejection could be silently dropped on the upload path.
911
+ it("rethrows non-InitialInstallFailed join errors so the outer catch exits", async () => {
912
+ // Install succeeds instantly — only the UPGRADE rejects, so the join's
913
+ // first await (`joinPackageUpgrade`) is what surfaces.
914
+ execMock.mockImplementation((_cmd, opts, cb) => {
915
+ const callback = typeof opts === "function"
916
+ ? opts
917
+ : cb;
918
+ callback?.(null, { stdout: "installed" });
919
+ });
920
+ // hasCliUpdated=true ensures we'd hit the restart branch IF the local
921
+ // catch wrongly swallowed the rejection. A rejecting upgrade promise
922
+ // surfaces a generic Error (not InitialInstallFailed), so the local catch
923
+ // must rethrow.
924
+ mockCheckVersions.mockResolvedValue({
925
+ cliUpdated: true,
926
+ upgradePromises: [Promise.reject(new Error("simulated upgrade failure"))],
927
+ });
928
+ const originalExit = process.exit;
929
+ const exitCalls = [];
930
+ process.exit = (code) => {
931
+ exitCalls.push(code);
932
+ throw new Error(`__test_process_exit__:${String(code)}`);
933
+ };
934
+ try {
935
+ const { dev } = await import("./dev.mjs");
936
+ let caughtError;
937
+ await dev(buildDevOptions({ forcePackageInstall: true })).catch((err) => {
938
+ caughtError = err;
939
+ });
940
+ // First exit MUST be the outer-catch exit(1), NOT the
941
+ // AUTO_UPGRADE_EXIT_CODE (99). If the local catch wrongly swallowed
942
+ // the upgrade rejection, execution would fall through to the
943
+ // `if (hasCliUpdated)` restart branch and call exit(99).
944
+ expect(exitCalls[0]).toBe(1);
945
+ expect(exitCalls[0]).not.toBe(99);
946
+ expect(caughtError).toBeDefined();
947
+ // Upload skipped — join rejected before the upload body could run.
948
+ expect(mockSyncService.uploadDirectory).not.toHaveBeenCalled();
949
+ expect(mockSyncService.uploadDirectoryNowIfNeeded).not.toHaveBeenCalled();
950
+ // Restart was NOT reached — outer catch exited before that block.
951
+ expect(mockLogger.info).not.toHaveBeenCalledWith("CLI was updated, restarting the dev server…");
952
+ // The skip-upload-for-restart log must NOT fire either; it's gated on
953
+ // the `InitialInstallFailed` branch of the local catch.
954
+ expect(mockLogger.info.mock.calls.some((call) => typeof call[0] === "string" &&
955
+ call[0].includes("Initial package install failed before startup upload"))).toBe(false);
956
+ }
957
+ finally {
958
+ process.exit = originalExit;
959
+ }
960
+ });
572
961
  });
573
962
  //# sourceMappingURL=dev-s3-restore.test.mjs.map