@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
@@ -13,19 +13,22 @@ import { maskUnixSignals } from "@superblocksteam/util";
13
13
  import { AiService, AiServiceFeatureFlags, SnapshotManager, isSdkApiTemplate, stripResolvedFromLockfile, } from "@superblocksteam/vite-plugin-file-sync/ai-service";
14
14
  import { createGitService } from "@superblocksteam/vite-plugin-file-sync/git-service";
15
15
  import { LockService, LockType, } from "@superblocksteam/vite-plugin-file-sync/lock-service";
16
+ import { shouldIgnoreInstallScripts, } from "@superblocksteam/vite-plugin-file-sync/npm-registry";
16
17
  import { OperationQueue } from "@superblocksteam/vite-plugin-file-sync/operation-queue";
17
18
  import { AutoConnectingRpcClient } from "@superblocksteam/vite-plugin-file-sync/server-rpc";
18
19
  import { SyncService } from "@superblocksteam/vite-plugin-file-sync/sync-service";
20
+ import { devServerMetrics } from "../dev-utils/dev-server-metrics.mjs";
19
21
  import { createDevServer } from "../dev-utils/dev-server.mjs";
20
22
  import { AUTO_UPGRADE_EXIT_CODE } from "../index.js";
21
23
  import { getTracer } from "../telemetry/index.js";
22
24
  import { getErrorMeta, getLogger } from "../telemetry/logging.js";
23
- import { checkVersionsAndWritePackageJson } from "./automatic-upgrades.js";
25
+ import { buildInstallEnv, checkVersionsAndWritePackageJson, } from "./automatic-upgrades.js";
26
+ import { classifyInitialInstallError, InitialInstallFailed, } from "./dependency-install-classifier.mjs";
24
27
  import { ensureRemoteHasDefaultBranch, getGitErrorFields, } from "./git-repo-setup.mjs";
28
+ import { superblocksLogsPath, superblocksNpmrcPath, syncHomeNpmrc, } from "./home-npmrc.mjs";
25
29
  import { normalizeWorkspaceProtocolForNpm } from "./normalize-workspace-protocol.js";
26
30
  import { didPackageJsonSnapshotChange, packageJsonSnapshot, packageJsonSnapshotDiagnostic, restoreManagedPackageDependencies, } from "./package-json-snapshot.mjs";
27
31
  import { getCurrentCliVersion } from "./version-detection.js";
28
- const exec = promisify(child_process.exec);
29
32
  const passErrorToVSCode = (message, logger) => {
30
33
  if (message && process.env.SUPERBLOCKS_VSCODE === "true") {
31
34
  // Prefixing with `clierr:` will make the VS code extension capture this message and show it to the user.
@@ -127,6 +130,73 @@ async function readPkgJson(cwd) {
127
130
  return null;
128
131
  }
129
132
  }
133
+ async function readPackageLock(cwd) {
134
+ try {
135
+ return await nodeFs.readFile(path.join(cwd, "package-lock.json"), "utf8");
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ }
141
+ /**
142
+ * Canonicalize a lockfile for post-install change detection. Every boot,
143
+ * `stripResolvedFromLockfile` (APPS-4300) removes the `resolved` URLs and
144
+ * the validation install writes them back, so comparing raw bytes flags
145
+ * "changed" on EVERY registry-validation boot — re-uploading the full
146
+ * workspace to DBFS forever. Dropping `resolved` from both sides of the
147
+ * comparison (same npm v2+ `.packages` shape the strip targets) means only
148
+ * material resolution changes — added/removed packages, version bumps,
149
+ * integrity changes — trigger the upload.
150
+ */
151
+ export function lockfileComparisonKey(raw) {
152
+ if (raw === null)
153
+ return null;
154
+ try {
155
+ const parsed = JSON.parse(raw);
156
+ if (parsed?.packages && typeof parsed.packages === "object") {
157
+ for (const entry of Object.values(parsed.packages)) {
158
+ if (entry && typeof entry === "object") {
159
+ delete entry.resolved;
160
+ }
161
+ }
162
+ }
163
+ return JSON.stringify(parsed);
164
+ }
165
+ catch {
166
+ // Malformed lockfile: fall back to byte-level comparison.
167
+ return raw;
168
+ }
169
+ }
170
+ async function shouldValidatePrivateRegistryInstall(npmRegistryClient, logger) {
171
+ if (!npmRegistryClient) {
172
+ return false;
173
+ }
174
+ try {
175
+ const result = await npmRegistryClient.getConfig();
176
+ // `configured` / `stale` → the org is known to route installs through a
177
+ // registry (`stale` = last-known-good config during a config-service
178
+ // outage), so the startup install must run even on identical
179
+ // package.json snapshots: with `resolved` stripped from the lockfiles
180
+ // (APPS-4300), npm re-resolves the entire baked tree through that
181
+ // registry, surfacing missing packages at app-creation time instead of
182
+ // at deploy-time bundle build (APPS-4527).
183
+ //
184
+ // `not-configured` and `unreachable` deliberately do NOT force the
185
+ // install. Not-configured orgs gain nothing from re-resolving against
186
+ // public npm on every boot. And when the config service is unreachable
187
+ // with no last-known-good, we cannot materialize the right `.npmrc` —
188
+ // forcing an install would re-resolve a private-registry org's lockfile
189
+ // against whatever registry happens to be on disk, "validating" against
190
+ // the wrong host and writing its URLs back into the lockfile. Fail
191
+ // closed instead (same principle as the AppShell short-circuit,
192
+ // APPS-4370): skip validation and keep today's snapshot decision.
193
+ return result.source === "configured" || result.source === "stale";
194
+ }
195
+ catch (err) {
196
+ logger.warn("Could not resolve npm registry config for startup install validation; preserving package snapshot decision", getErrorMeta(err));
197
+ return false;
198
+ }
199
+ }
130
200
  async function normalizePackageJsonForNpm(cwd, logger) {
131
201
  const packageJsonPath = path.join(cwd, "package.json");
132
202
  let raw;
@@ -160,7 +230,7 @@ async function normalizePackageJsonForNpm(cwd, logger) {
160
230
  throw new Error(`Failed to write normalized package.json at ${packageJsonPath}: ${err instanceof Error ? err.message : String(err)}`);
161
231
  }
162
232
  }
163
- async function installPackages(cwd, logger) {
233
+ export async function installPackages(cwd, logger, npmRegistryClient) {
164
234
  try {
165
235
  const pm = await detect({
166
236
  strategies: [
@@ -181,24 +251,145 @@ async function installPackages(cwd, logger) {
181
251
  // resolve a published version instead.
182
252
  await normalizePackageJsonForNpm(cwd, logger);
183
253
  }
184
- const installCommand = resolveCommand(pm.agent, "install", pm.agent === "npm" ? ["--fund=false", "--audit=false"] : []);
254
+ // Honor the per-org `allow_install_scripts` policy on the dev-server
255
+ // startup install just like AppShell does for agent-driven installs
256
+ // (see `shell.ts`). When the org has opted out (`allowInstallScripts ===
257
+ // false`), append `--ignore-scripts` so lifecycle scripts don't run
258
+ // during dev-server boot. `undefined` (LD flag off, server omitted the
259
+ // field, no client wired up, or the resolution itself failed) keeps
260
+ // today's behaviour. The client owns last-known-good / cross-outage
261
+ // policy preservation; we deliberately swallow resolution errors here
262
+ // so a transient registry-endpoint outage doesn't abort the user's
263
+ // startup install — the policy default is "scripts allowed" anyway.
264
+ let ignoreScripts = false;
265
+ if (npmRegistryClient) {
266
+ try {
267
+ const result = await npmRegistryClient.getConfig();
268
+ ignoreScripts = shouldIgnoreInstallScripts(result);
269
+ }
270
+ catch (err) {
271
+ logger.warn("Could not resolve npm install-scripts policy; proceeding without --ignore-scripts", getErrorMeta(err));
272
+ }
273
+ }
274
+ // `--json` makes npm channel the structured error envelope (code +
275
+ // summary + detail) onto stdout so a failure can be classified into a
276
+ // DependencyInstallError. Output-only: it does not change resolution,
277
+ // only how npm reports it. The success-path `logger.info(stdout)` below
278
+ // then logs JSON, which is acceptable.
279
+ const baseArgs = pm.agent === "npm" ? ["--fund=false", "--audit=false", "--json"] : [];
280
+ const installArgs = ignoreScripts
281
+ ? [...baseArgs, "--ignore-scripts"]
282
+ : baseArgs;
283
+ const installCommand = resolveCommand(pm.agent, "install", installArgs);
185
284
  if (!installCommand) {
186
285
  logger.warn(`Could not determine install command for ${pm.agent}, skipping package installation`);
187
286
  return;
188
287
  }
189
288
  const { command, args } = installCommand;
190
289
  logger.info(`Running ${command} ${args.join(" ")} to install dependencies…`);
290
+ // Pin both npm and pnpm to the Superblocks-owned userconfig via env
291
+ // overlay. `buildInstallEnv` (from `automatic-upgrades.ts`) sets
292
+ // both `NPM_CONFIG_USERCONFIG` (npm, pnpm <= 10) and
293
+ // `PNPM_CONFIG_USERCONFIG` (pnpm 11+) — pnpm 11 stopped honouring
294
+ // the `npm_config_*` env vars. CLI flags differ between agents
295
+ // (`--userconfig=` for npm, `--config.userconfig=` for pnpm), so
296
+ // the env overlay is the uniform mechanism. Without this, customer
297
+ // pods using a private registry would read default `~/.npmrc`
298
+ // (empty after the relocation) and fail to resolve private
299
+ // packages. Centralised in `buildInstallEnv` so this and the CLI
300
+ // auto-upgrade install stay in sync.
301
+ // Ensure the per-app log dir exists so npm's debug log (routed via
302
+ // `NPM_CONFIG_LOGS_DIR` in `buildInstallEnv`) lands in a predictable
303
+ // place. npm would create it on demand, but doing it up front means the
304
+ // folder exists even for pnpm runs (which ignore the var) and for any
305
+ // log-collection sidecar watching `<app>/.superblocks/logs`.
306
+ const logsDir = superblocksLogsPath(cwd);
307
+ await nodeFs.mkdir(logsDir, { recursive: true }).catch(() => undefined);
308
+ // Resolve the promisified `exec` at CALL time — not module scope — so a
309
+ // test's `vi.mock("node:child_process")` is always honoured regardless of
310
+ // when `dev.mjs` was first evaluated. A module-scope capture binds to
311
+ // whichever `child_process.exec` was live at first import; if a sibling
312
+ // test imports this module before installing its own mock (e.g.
313
+ // `dev-token-priming` / `dev.interception`, which don't mock
314
+ // `child_process`), that binding is the REAL npm and the classify test's
315
+ // mock silently never takes effect — the exact APPS-4450 CI failure
316
+ // (`category: "unknown"`, `npmErrorCode: undefined` from real npm against a
317
+ // missing `/tmp/app`).
318
+ const exec = promisify(child_process.exec);
191
319
  const { stdout } = await exec(`${command} ${args.join(" ")}`, {
192
320
  cwd,
321
+ env: buildInstallEnv(superblocksNpmrcPath(), logsDir),
193
322
  });
194
323
  logger.info("Package installation completed successfully");
195
324
  logger.info(stdout);
196
325
  }
197
326
  catch (error) {
198
327
  logger.error("Error during package installation", getErrorMeta(error));
199
- throw error;
328
+ // `util.promisify(child_process.exec)` preserves `.stdout`/`.stderr` on
329
+ // the rejected error separately; with `--json`, the structured npm error
330
+ // envelope lands on `.stdout`. Classify it into a DependencyInstallError
331
+ // and throw the `InitialInstallFailed` marker so the `dev()` catch can
332
+ // degrade by origin rather than crash-looping the dev-server pod.
333
+ const ctx = await buildInstallParseContext(cwd, npmRegistryClient);
334
+ const f = error;
335
+ const serverError = classifyInitialInstallError({ stdout: f.stdout, stderr: f.stderr, message: f.message }, ctx);
336
+ throw new InitialInstallFailed(serverError);
200
337
  }
201
338
  }
339
+ /**
340
+ * Build the `ParseContext` the dependency-install classifier needs from the
341
+ * failing install's working directory + registry client:
342
+ *
343
+ * - `requestedPackages` — the union of `dependencies` + `devDependencies`
344
+ * in the app's `package.json`, used by the registry-blocked renderers to
345
+ * name the failing specs when npm's `--json` `detail` doesn't.
346
+ * - `hasAnyRegistryConfigured` — the tri-state derived from the registry
347
+ * client's most recent `getConfig()`, mirroring AppShell's
348
+ * `deriveHasAnyRegistryConfigured` (`shell.ts`): `configured`/`stale`
349
+ * → `true` (rows known to exist), `not-configured` → `false` (deliberate
350
+ * "no rows"), `unreachable` → `undefined` ("we don't know" — the renderer
351
+ * falls back to the default variant). Left `undefined` when no client is
352
+ * wired in or the resolution itself throws.
353
+ *
354
+ * Best-effort throughout: a missing/unparseable package.json or a registry
355
+ * outage must not mask the underlying install failure, so both lookups are
356
+ * caught and degrade to empty/undefined.
357
+ */
358
+ async function buildInstallParseContext(cwd, npmRegistryClient) {
359
+ let requestedPackages = [];
360
+ try {
361
+ const pkg = JSON.parse(await nodeFs.readFile(path.join(cwd, "package.json"), "utf8"));
362
+ requestedPackages = Object.entries({
363
+ ...(pkg.dependencies ?? {}),
364
+ ...(pkg.devDependencies ?? {}),
365
+ }).map(([name, version]) => ({ name, version: String(version) }));
366
+ }
367
+ catch {
368
+ /* best-effort: a missing/unparseable package.json yields no specs */
369
+ }
370
+ let hasAnyRegistryConfigured;
371
+ if (npmRegistryClient) {
372
+ try {
373
+ const result = await npmRegistryClient.getConfig();
374
+ switch (result.source) {
375
+ case "configured":
376
+ case "stale":
377
+ hasAnyRegistryConfigured = true;
378
+ break;
379
+ case "not-configured":
380
+ hasAnyRegistryConfigured = false;
381
+ break;
382
+ case "unreachable":
383
+ hasAnyRegistryConfigured = undefined;
384
+ break;
385
+ }
386
+ }
387
+ catch {
388
+ /* leave undefined → renderers treat as "don't know" / default variant */
389
+ }
390
+ }
391
+ return { requestedPackages, hasAnyRegistryConfigured };
392
+ }
202
393
  export var DevServerAutoUpgradeMode;
203
394
  (function (DevServerAutoUpgradeMode) {
204
395
  DevServerAutoUpgradeMode["SKIP"] = "skip-upgrade";
@@ -212,8 +403,61 @@ export var DevServerAutoUpgradeMode;
212
403
  */
213
404
  DevServerAutoUpgradeMode["SKIP_CLI_ONLY"] = "skip-cli-only";
214
405
  })(DevServerAutoUpgradeMode || (DevServerAutoUpgradeMode = {}));
406
+ /**
407
+ * Seed `tokenManager` with the initial token the CLI received from auth.json
408
+ * (standalone dev) or `/_sb_activate` (SABS live-edit pod activation), so the
409
+ * `NpmRegistryClient`'s JWT source has a usable bearer credential on cold
410
+ * boot.
411
+ *
412
+ * Without this seeding, `TokenManager.updateToken` is only ever called by
413
+ * `AuthHotReloadServer` (`auth-hot-reload.mts`) — and that socket is
414
+ * explicitly disabled on live-edit pods (`sabs/entrypoint-local.sh` sets
415
+ * `SUPERBLOCKS_AUTH_HOT_RELOAD=false`), so `NpmRegistryClient.getConfig()`
416
+ * cannot authenticate its server fetch on cold boot. The result is
417
+ * `source: "unreachable"`, `syncHomeNpmrc` skips with `~/.npmrc` left
418
+ * untouched, and the CLI auto-upgrade that fires moments later resolves
419
+ * `npm install -g @superblocksteam/cli@…` through public npm instead of the
420
+ * customer's configured private registry.
421
+ *
422
+ * Call-site ordering is intentionally NOT load-bearing: `TokenManager`
423
+ * retains the current token as state and `AiService` reads
424
+ * `tokenManager.getCurrentToken()` synchronously at construction time, so
425
+ * the prime call works whether it runs before or after consumer
426
+ * construction. The `tokenUpdated` event stream stays as the refresh
427
+ * channel for future rotations (hot-reload pushes, JWT renewal), so this
428
+ * seed is purely additive.
429
+ */
430
+ export function primeTokenManagerWithInitialToken(tokenManager, token) {
431
+ if (token) {
432
+ tokenManager.updateToken(token);
433
+ }
434
+ }
435
+ /** Decide how the startup catch handles an error: degrade (record, keep Vite up)
436
+ * for an app-install failure (the InitialInstallFailed marker), or exit for
437
+ * anything else (lock/sync/upgrade). Pure + unit-tested. Does NOT call process.exit. */
438
+ export function handleStartupError(error, status, logger) {
439
+ if (error instanceof InitialInstallFailed) {
440
+ status.serverErrors.push(error.serverError);
441
+ logger.error("[dev-server] initial dependency install failed; keeping dev server up", getErrorMeta(error));
442
+ devServerMetrics.recordInitialInstallFailure({
443
+ category: error.serverError.category,
444
+ npmErrorCode: error.serverError.npmErrorCode,
445
+ hasAnyRegistryConfigured: error.serverError.hasAnyRegistryConfigured,
446
+ });
447
+ return "degrade";
448
+ }
449
+ return "exit";
450
+ }
215
451
  export async function dev(options) {
216
452
  const { cwd, tokenConfig, devServerPort, skipSync, applicationConfig, autoUpgradeMode, tokenManager, authHotReloadServer, sdk, } = options;
453
+ // Seed the tokenManager with the initial CLI token so downstream consumers
454
+ // (AiService, AutoConnectingRpcClient, syncHomeNpmrc) have a usable
455
+ // bearer credential without waiting on the AuthHotReloadServer push
456
+ // refresh (which is disabled on live-edit pods). Order-independent: the
457
+ // manager retains state and AiService reads it synchronously during
458
+ // construction. See `primeTokenManagerWithInitialToken` for the
459
+ // cold-boot rationale.
460
+ primeTokenManagerWithInitialToken(tokenManager, tokenConfig.token);
217
461
  // May be overridden by a pending snapshot restore
218
462
  let { downloadFirst, uploadFirst } = options;
219
463
  // Services that will be created
@@ -224,17 +468,40 @@ export async function dev(options) {
224
468
  let snapshotManager;
225
469
  let gitUserName;
226
470
  let gitUserEmail;
227
- // In-flight install handle. We launch the install while the upload/restart
228
- // decisions are still being evaluated and join at every gate that depends
229
- // on post-install state. See the install block and `joinPackageInstall`.
471
+ // In-flight handles for the two background jobs we launch from sync.
472
+ // We keep them SEPARATE so an install rejection cannot swallow an upgrade
473
+ // rejection (or vice-versa) the way a single `Promise.all` would: that
474
+ // bundle settles on the FIRST rejection and silently absorbs the other's
475
+ // outcome, breaking APPS-4457's intent that auto-upgrade failures still
476
+ // exit (not degrade) and CLI-restart-on-upgrade still happens even if the
477
+ // app install fails.
478
+ //
479
+ // - packageUpgradePromise: library upgrades (and their `npm install`
480
+ // side-effects) from `checkVersionsAndWritePackageJson`. Rejection is
481
+ // OUT of scope for graceful degrade (APPS-4457) — `handleStartupError`
482
+ // routes it to `process.exit(1)`.
483
+ // - packageInstallPromise: the app's verification `npm install`.
484
+ // Rejection MAY be the `InitialInstallFailed` marker, which
485
+ // `handleStartupError` routes to the degrade path.
486
+ let packageUpgradePromise;
230
487
  let packageInstallPromise;
231
488
  const tracer = getTracer();
232
489
  const logger = getLogger(options.logger);
233
- // Joins the in-flight install at any step that depends on a settled
234
- // node_modules / lockfile state. Clears the handle so later joins are
235
- // no-ops. The rejection (if any) propagates so the caller's step can
236
- // abort cleanly. Defined at outer scope so the post-sync `createDevServer`
237
- // gate and the abort handler can call it too.
490
+ // Joins the in-flight upgrade. Rejection propagates so the caller's step
491
+ // can abort cleanly (handleStartupError exits auto-upgrade graceful
492
+ // degrade is OOS per APPS-4457).
493
+ const joinPackageUpgrade = async (reason) => {
494
+ if (!packageUpgradePromise) {
495
+ return;
496
+ }
497
+ logger.info(`Waiting for background package upgrade (${reason})…`);
498
+ const promise = packageUpgradePromise;
499
+ packageUpgradePromise = undefined;
500
+ await promise;
501
+ };
502
+ // Joins the in-flight install. Rejection propagates so the caller's step
503
+ // can abort cleanly (handleStartupError degrades for InitialInstallFailed,
504
+ // exits otherwise).
238
505
  const joinPackageInstall = async (reason) => {
239
506
  if (!packageInstallPromise) {
240
507
  return;
@@ -244,10 +511,34 @@ export async function dev(options) {
244
511
  packageInstallPromise = undefined;
245
512
  await promise;
246
513
  };
514
+ // Settles the in-flight install WITHOUT throwing. Used on the CLI-restart
515
+ // path so an install failure doesn't preempt the restart (the new CLI's
516
+ // first boot re-runs install). Still waits for npm to finish so we don't
517
+ // SIGKILL it mid-rename.
518
+ const settlePackageInstall = async (reason) => {
519
+ if (!packageInstallPromise) {
520
+ return;
521
+ }
522
+ logger.info(`Settling background package install (${reason})…`);
523
+ const promise = packageInstallPromise;
524
+ packageInstallPromise = undefined;
525
+ await Promise.allSettled([promise]);
526
+ };
527
+ // Joins upgrade THEN install. Upgrade failures surface first so
528
+ // handleStartupError exits (per APPS-4457) before an install rejection
529
+ // gets a chance to route to degrade.
530
+ const joinUpgradeThenInstall = async (reason) => {
531
+ await joinPackageUpgrade(reason);
532
+ await joinPackageInstall(reason);
533
+ };
247
534
  const skipAutoUpgrade = autoUpgradeMode === DevServerAutoUpgradeMode.SKIP;
248
535
  const skipCliUpgrade = skipAutoUpgrade ||
249
536
  autoUpgradeMode === DevServerAutoUpgradeMode.SKIP_CLI_ONLY;
250
537
  await tracer.startActiveSpan("devServerStartup", async (startupSpan) => {
538
+ // Mutable startup status surfaced to the browser via createDevServer's
539
+ // /_sb_connect + /_sb_status. The startup catch records an app-install
540
+ // failure here (degrade path) so Vite still starts and serves the error.
541
+ const devServerStatus = { serverErrors: [] };
251
542
  try {
252
543
  // Add check for node_modules
253
544
  if (!fs.existsSync(path.join(cwd, "node_modules"))) {
@@ -536,15 +827,39 @@ export async function dev(options) {
536
827
  else if (downloadFirst && isSynced) {
537
828
  logger.info("[dev-startup] Skipping download, already in sync");
538
829
  }
539
- // Unconditional lockfile sanitation: strip cross-registry
540
- // `resolved` URLs from any lockfile on disk regardless of whether
541
- // install runs next. The lockfile here is whatever survived the
542
- // DBFS path (downloadFirst overwrite, prior boot, brownfield
543
- // import); npm honors `resolved` verbatim and would bypass the
544
- // active registry. `integrity` is preserved, so genuine
545
- // cross-registry tarball drift surfaces as EINTEGRITY. No-op when
546
- // there's no lockfile or no `resolved` entries.
830
+ // Unconditional lockfile sanitation (APPS-4300): strip
831
+ // cross-registry `resolved` URLs from any lockfile on disk
832
+ // regardless of whether install runs next. The lockfile here is
833
+ // whatever survived the DBFS path (downloadFirst overwrite,
834
+ // prior boot, brownfield import); npm honors `resolved` verbatim
835
+ // and would bypass the active registry. `integrity` is
836
+ // preserved, so genuine cross-registry tarball drift surfaces as
837
+ // EINTEGRITY. No-op when there's no lockfile or no `resolved`
838
+ // entries.
547
839
  await stripResolvedFromLockfile(cwd);
840
+ // Materialise `~/.superblocks/npmrc` from the server-fetched
841
+ // per-org npm registry config BEFORE the global Superblocks CLI
842
+ // auto-upgrade fires. With the file in place, the auto-upgrade's
843
+ // `npm install -g @superblocksteam/cli@…` resolves through the
844
+ // customer's private registry instead of `registry.npmjs.org`.
845
+ await tracer.startActiveSpan("syncHomeNpmrc", async (span) => {
846
+ try {
847
+ if (!aiService) {
848
+ logger.info("[home-npmrc] skipped: AiService unavailable at startup");
849
+ return;
850
+ }
851
+ await syncHomeNpmrc({
852
+ npmRegistryClient: aiService.getNpmRegistryClient(),
853
+ logger,
854
+ });
855
+ }
856
+ catch (error) {
857
+ logger.warn("[home-npmrc] sync step failed unexpectedly; ~/.superblocks/npmrc left untouched", getErrorMeta(error));
858
+ }
859
+ finally {
860
+ span.end();
861
+ }
862
+ });
548
863
  let hasCliUpdated = false;
549
864
  let upgradePromises = [];
550
865
  const forceUpgrade = options.autoUpgradeMode === DevServerAutoUpgradeMode.FORCE;
@@ -565,7 +880,10 @@ export async function dev(options) {
565
880
  organizationId: currentUser.user.currentOrganizationId,
566
881
  featureFlags: sdk.getFeatureFlagsForUser(currentUser),
567
882
  };
568
- const result = await checkVersionsAndWritePackageJson(lockService, applicationConfigWithTokenConfigAndUserInfo, forceUpgrade, skipCliUpgrade);
883
+ const result = await checkVersionsAndWritePackageJson(lockService, applicationConfigWithTokenConfigAndUserInfo, forceUpgrade, skipCliUpgrade,
884
+ // Route the CLI-upgrade install's npm debug log into the
885
+ // same `<app>/.superblocks/logs` as the startup install.
886
+ cwd);
569
887
  hasCliUpdated = result.cliUpdated;
570
888
  upgradePromises = result.upgradePromises;
571
889
  }
@@ -604,11 +922,17 @@ export async function dev(options) {
604
922
  if (!packageJsonBefore && packageJsonRequiresInstall) {
605
923
  logger.info("package.json was created, installing packages…");
606
924
  }
925
+ const npmRegistryClient = aiService?.getNpmRegistryClient();
607
926
  const forcePackageInstallRequested = !!options.forcePackageInstall;
608
927
  let forcePackageInstall = forcePackageInstallRequested;
928
+ const privateRegistryRequiresInstallValidation = packageJsonAfter
929
+ ? await shouldValidatePrivateRegistryInstall(npmRegistryClient, logger)
930
+ : false;
609
931
  if (forcePackageInstallRequested &&
610
932
  hasPackageJsonSnapshotBeforeRestore) {
611
- forcePackageInstall = packageJsonRequiresInstall;
933
+ forcePackageInstall =
934
+ packageJsonRequiresInstall ||
935
+ privateRegistryRequiresInstallValidation;
612
936
  }
613
937
  logger.info("Package install decision", {
614
938
  packageJsonBeforePresent: !!packageJsonBefore,
@@ -617,14 +941,45 @@ export async function dev(options) {
617
941
  packageJsonRequiresInstall,
618
942
  forcePackageInstall,
619
943
  forcePackageInstallRequested,
944
+ privateRegistryRequiresInstallValidation,
620
945
  upgradePromiseCount: upgradePromises.length,
621
946
  packageJsonSnapshotBefore: packageJsonSnapshotDiagnostic(packageJsonSnapshotBefore),
622
947
  packageJsonInstallBaselineSnapshot: packageJsonSnapshotDiagnostic(packageJsonInstallBaselineSnapshot),
623
948
  packageJsonSnapshotAfter: packageJsonSnapshotDiagnostic(packageJsonSnapshotAfter),
624
949
  });
950
+ const installApplicationId = applicationConfig.id;
951
+ // Launch upgrades and app install as INDEPENDENT promises (not a
952
+ // single `Promise.all`) so each outcome is observed on its own
953
+ // join: upgrade failure exits (APPS-4457), install failure may
954
+ // degrade (InitialInstallFailed). A bundled `Promise.all` would
955
+ // settle on the first rejection and silently absorb the other's
956
+ // result. The `.catch` backstops convert "no join fired" cases
957
+ // into logged-then-handled rejections instead of unhandled ones.
958
+ if (upgradePromises.length > 0) {
959
+ logger.info("Starting package upgrade in background…");
960
+ const launchedUpgradeCount = upgradePromises.length;
961
+ packageUpgradePromise = tracer.startActiveSpan("packageUpgrades", async (span) => {
962
+ try {
963
+ await Promise.all(upgradePromises);
964
+ }
965
+ finally {
966
+ span.end();
967
+ }
968
+ });
969
+ packageUpgradePromise.catch((err) => {
970
+ logger.error(`Background package upgrade failed [errorId=DEV_SERVER_BG_UPGRADE_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${launchedUpgradeCount}]`, getErrorMeta(err));
971
+ });
972
+ }
973
+ // Run the verification install when EITHER the package.json
974
+ // requires it, upgrades just modified package.json/lockfile
975
+ // (re-syncing node_modules), the caller forced it, or a custom
976
+ // registry must validate that the required packages resolve there.
977
+ let packageLockBeforeInstall = null;
625
978
  if (packageJsonRequiresInstall ||
626
979
  upgradePromises.length > 0 ||
627
- forcePackageInstall) {
980
+ forcePackageInstall ||
981
+ privateRegistryRequiresInstallValidation) {
982
+ packageLockBeforeInstall = await readPackageLock(cwd);
628
983
  // Launch the install while the upload/restart decisions below
629
984
  // are still being evaluated. The synchronous joins at upload,
630
985
  // CLI restart, and pre-Vite-startup all observe and surface
@@ -634,50 +989,80 @@ export async function dev(options) {
634
989
  logger.info("Starting package install in background…");
635
990
  packageInstallPromise = tracer.startActiveSpan("installPackages", async (span) => {
636
991
  try {
637
- // Upgrade global CLI and local packages in parallel - improves performance
638
- await Promise.all([
639
- ...upgradePromises,
640
- installPackages(cwd, logger),
641
- ]);
992
+ await installPackages(cwd, logger, npmRegistryClient);
642
993
  }
643
994
  finally {
644
995
  span.end();
645
996
  }
646
997
  });
647
- // Backstop: assigning `.catch` to a separate (discarded) promise
648
- // keeps `packageInstallPromise` itself rejecting, so the joins
649
- // can still observe and abort. Without this, an install that
650
- // fails before any join fires becomes an unhandled rejection.
651
- const installApplicationId = applicationConfig.id;
652
- const installUpgradeCount = upgradePromises.length;
653
998
  packageInstallPromise.catch((err) => {
654
999
  logger.error(
655
1000
  // errorId is encoded into the message body because the
656
1001
  // logger.error contract limits structured attributes to
657
1002
  // `{ error: { kind, message, stack } }`. The id stays
658
1003
  // grep-able for Datadog/Sentry alert rules.
659
- `Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${installUpgradeCount}]`, getErrorMeta(err));
1004
+ `Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd}]`, getErrorMeta(err));
660
1005
  });
661
1006
  }
662
1007
  else {
663
1008
  logger.info("package.json has not changed, skipping package installation");
664
1009
  }
665
1010
  const shouldUploadPackageState = hasPackageChanged || forcePackageInstall;
666
- if (shouldUploadPackageState || uploadFirst) {
1011
+ let shouldUploadAfterInstall = shouldUploadPackageState;
1012
+ if (shouldUploadPackageState ||
1013
+ uploadFirst ||
1014
+ privateRegistryRequiresInstallValidation) {
667
1015
  // Upload serializes the post-install lockfile + node_modules
668
- // tree to DBFS, so it must observe a quiesced install.
669
- await joinPackageInstall("before upload");
670
- logger.info(`Uploading local files to branch '${activeDbfsBranchName}' on server before starting`);
671
- await tracer.startActiveSpan("uploadFirstOrPackageChanged", async (span) => {
672
- await syncService.uploadDirectory("cli:sdk");
673
- await syncService.uploadDirectoryNowIfNeeded("cli:sdk");
674
- span.end();
675
- });
1016
+ // tree to DBFS, so it must observe quiesced upgrade+install.
1017
+ // Upgrade first — its rejection exits before an install
1018
+ // rejection can take the degrade path (APPS-4457).
1019
+ //
1020
+ // BUT: if a successful CLI upgrade is pending restart
1021
+ // (`hasCliUpdated`), the restart branch below MUST win over
1022
+ // the install-failure degrade path. Otherwise the outer
1023
+ // sync/setup catch routes `InitialInstallFailed` to "degrade"
1024
+ // and skips the restart entirely — masking a successful CLI
1025
+ // upgrade. Catch ONLY the join (not the upload body) so a
1026
+ // post-join upload failure still propagates normally.
1027
+ let skipStartupUploadForCliRestart = false;
1028
+ try {
1029
+ await joinUpgradeThenInstall("before upload");
1030
+ }
1031
+ catch (joinError) {
1032
+ if (hasCliUpdated &&
1033
+ joinError instanceof InitialInstallFailed) {
1034
+ logger.info("Initial package install failed before startup upload, but CLI was updated; skipping upload and letting the restart proceed");
1035
+ skipStartupUploadForCliRestart = true;
1036
+ }
1037
+ else {
1038
+ throw joinError;
1039
+ }
1040
+ }
1041
+ if (privateRegistryRequiresInstallValidation &&
1042
+ !shouldUploadAfterInstall &&
1043
+ lockfileComparisonKey(packageLockBeforeInstall) !==
1044
+ lockfileComparisonKey(await readPackageLock(cwd))) {
1045
+ shouldUploadAfterInstall = true;
1046
+ }
1047
+ if (!skipStartupUploadForCliRestart &&
1048
+ (shouldUploadAfterInstall || uploadFirst)) {
1049
+ logger.info(`Uploading local files to branch '${activeDbfsBranchName}' on server before starting`);
1050
+ await tracer.startActiveSpan("uploadFirstOrPackageChanged", async (span) => {
1051
+ await syncService.uploadDirectory("cli:sdk");
1052
+ await syncService.uploadDirectoryNowIfNeeded("cli:sdk");
1053
+ span.end();
1054
+ });
1055
+ }
676
1056
  }
677
1057
  if (hasCliUpdated) {
678
- // Exiting mid-install would leave a half-written lockfile that
679
- // the next boot would have to recover from.
680
- await joinPackageInstall("before CLI restart");
1058
+ // Restart must NOT be preempted by an app-install failure:
1059
+ // the next boot re-runs install with the new CLI. Join the
1060
+ // upgrade (it must succeed or we exit before restarting) and
1061
+ // SETTLE the install (don't observe its rejection — but wait
1062
+ // for npm to finish so the restart doesn't SIGKILL it
1063
+ // mid-rename and leave a half-written lockfile).
1064
+ await joinPackageUpgrade("before CLI restart");
1065
+ await settlePackageInstall("before CLI restart");
681
1066
  try {
682
1067
  logger.info("Releasing lock before restarting the dev server");
683
1068
  await aiService?.removeIntegrationCache();
@@ -692,14 +1077,20 @@ export async function dev(options) {
692
1077
  });
693
1078
  }
694
1079
  catch (error) {
695
- logger.error("[dev-server] Startup failed during sync/lock/setup (exiting with code 1)", getErrorMeta(error));
696
- try {
697
- await aiService?.removeIntegrationCache();
698
- await lockService?.shutdownAndExit();
1080
+ if (handleStartupError(error, devServerStatus, logger) === "degrade") {
1081
+ // app-install failure: do NOT exit — fall through to Vite startup below.
1082
+ // upload + CLI-restart were already skipped (the rejecting join threw first).
699
1083
  }
700
- finally {
701
- // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
702
- process.exit(1);
1084
+ else {
1085
+ logger.error("[dev-server] Startup failed during sync/lock/setup (exiting with code 1)", getErrorMeta(error));
1086
+ try {
1087
+ await aiService?.removeIntegrationCache();
1088
+ await lockService?.shutdownAndExit();
1089
+ }
1090
+ finally {
1091
+ // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
1092
+ process.exit(1);
1093
+ }
703
1094
  }
704
1095
  }
705
1096
  }
@@ -713,7 +1104,36 @@ export async function dev(options) {
713
1104
  // cache embeds partial state that survives across reloads. Awaiting
714
1105
  // here costs at most the install's remaining wall time — the upload
715
1106
  // and CLI-restart joins above usually drain it first.
716
- await joinPackageInstall("before Vite startup");
1107
+ // The "before upload" / "before CLI restart" joins above run inside the
1108
+ // sync/lock catch that degrades on `InitialInstallFailed`. But when the
1109
+ // install ran yet none of those joins fired (e.g.
1110
+ // `packageJsonRequiresInstall && !forcePackageInstall && !upload &&
1111
+ // !hasCliUpdated`), the install rejection first surfaces HERE — outside
1112
+ // that catch. Route it through the SAME origin gate so an app-install
1113
+ // failure still degrades (keep Vite up + record the error) instead of
1114
+ // escaping `dev()` to `process.exit(1)`. Non-install errors (including
1115
+ // any background upgrade rejection that escaped the prior joins) take
1116
+ // the same explicit exit path as the sync/lock catch above so the
1117
+ // process actually terminates (the startupSpan catch only rethrows,
1118
+ // and an unhandled rejection at that depth doesn't shut the lock
1119
+ // service down or guarantee `process.exit(1)`).
1120
+ try {
1121
+ await joinUpgradeThenInstall("before Vite startup");
1122
+ }
1123
+ catch (error) {
1124
+ if (handleStartupError(error, devServerStatus, logger) === "exit") {
1125
+ logger.error("[dev-server] Startup failed during pre-Vite install join (exiting with code 1)", getErrorMeta(error));
1126
+ try {
1127
+ await aiService?.removeIntegrationCache();
1128
+ await lockService?.shutdownAndExit();
1129
+ }
1130
+ finally {
1131
+ // Redundant with `shutdownAndExit`; here so a thrown shutdown
1132
+ // path can't leave the process hanging on a stuck handle.
1133
+ process.exit(1);
1134
+ }
1135
+ }
1136
+ }
717
1137
  const activateRuntimeGitService = async () => {
718
1138
  if (gitService) {
719
1139
  const hasGit = await nodeFs.access(path.join(cwd, ".git")).then(() => true, () => false);
@@ -816,9 +1236,7 @@ export async function dev(options) {
816
1236
  superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
817
1237
  existingServer: options.existingServer,
818
1238
  warmActivationStart: options.warmActivationStart,
819
- // TODO: Remove this cast — build the options object to match
820
- // CreateDevServerOptions directly so new required fields cause a
821
- // compile error instead of silently passing undefined.
1239
+ devServerStatus,
822
1240
  };
823
1241
  const result = await createDevServer(createDevServerOptions);
824
1242
  span.end();
@@ -833,13 +1251,16 @@ export async function dev(options) {
833
1251
  logger.warn(`Error stopping auth hot-reload server: ${error}`);
834
1252
  });
835
1253
  }
836
- // Drain any straggler install before tear-down. By this point the
837
- // pre-Vite join has already run on the success path, so usually
838
- // `packageInstallPromise` is undefined and this is a no-op; if abort
1254
+ // Drain any straggler upgrade/install before tear-down. By this
1255
+ // point the pre-Vite join has already run on the success path, so
1256
+ // usually both promises are undefined and this is a no-op; if abort
839
1257
  // races createDevServer (or fires during the inner sync block on
840
- // some error path), draining here keeps the spawned npm child from
841
- // being SIGKILL'd mid-rename. Errors only get logged — we are tearing
842
- // down anyway.
1258
+ // some error path), draining here keeps the spawned npm children
1259
+ // from being SIGKILL'd mid-rename. Errors only get logged — we are
1260
+ // tearing down anyway.
1261
+ joinPackageUpgrade("during abort").catch((error) => {
1262
+ logger.warn("Error draining background upgrade during abort", getErrorMeta(error));
1263
+ });
843
1264
  joinPackageInstall("during abort").catch((error) => {
844
1265
  logger.warn("Error draining background install during abort", getErrorMeta(error));
845
1266
  });