@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
@@ -11,6 +11,7 @@ import fs from "fs-extra";
11
11
  import { resolveCommand } from "package-manager-detector";
12
12
  import { detect } from "package-manager-detector/detect";
13
13
 
14
+ import type { ServerError } from "@superblocksteam/library-shared/types";
14
15
  import {
15
16
  buildGithubSuperblocksSyncWorkflow,
16
17
  buildGithubSuperblocksSyncWorkflowFromBaseUrl,
@@ -35,10 +36,17 @@ import {
35
36
  LockService,
36
37
  LockType,
37
38
  } from "@superblocksteam/vite-plugin-file-sync/lock-service";
39
+ import {
40
+ type NpmRegistryClient,
41
+ type NpmRegistryFetchResult,
42
+ type ParseContext,
43
+ shouldIgnoreInstallScripts,
44
+ } from "@superblocksteam/vite-plugin-file-sync/npm-registry";
38
45
  import { OperationQueue } from "@superblocksteam/vite-plugin-file-sync/operation-queue";
39
46
  import { AutoConnectingRpcClient } from "@superblocksteam/vite-plugin-file-sync/server-rpc";
40
47
  import { SyncService } from "@superblocksteam/vite-plugin-file-sync/sync-service";
41
48
 
49
+ import { devServerMetrics } from "../dev-utils/dev-server-metrics.mjs";
42
50
  import { createDevServer } from "../dev-utils/dev-server.mjs";
43
51
  import { AUTO_UPGRADE_EXIT_CODE } from "../index.js";
44
52
  import type {
@@ -54,11 +62,23 @@ import type {
54
62
  ApplicationConfigWithTokenConfigAndUserInfo,
55
63
  TokenConfig,
56
64
  } from "../types/index.js";
57
- import { checkVersionsAndWritePackageJson } from "./automatic-upgrades.js";
65
+ import {
66
+ buildInstallEnv,
67
+ checkVersionsAndWritePackageJson,
68
+ } from "./automatic-upgrades.js";
69
+ import {
70
+ classifyInitialInstallError,
71
+ InitialInstallFailed,
72
+ } from "./dependency-install-classifier.mjs";
58
73
  import {
59
74
  ensureRemoteHasDefaultBranch,
60
75
  getGitErrorFields,
61
76
  } from "./git-repo-setup.mjs";
77
+ import {
78
+ superblocksLogsPath,
79
+ superblocksNpmrcPath,
80
+ syncHomeNpmrc,
81
+ } from "./home-npmrc.mjs";
62
82
  import { normalizeWorkspaceProtocolForNpm } from "./normalize-workspace-protocol.js";
63
83
  import {
64
84
  didPackageJsonSnapshotChange,
@@ -69,8 +89,6 @@ import {
69
89
  } from "./package-json-snapshot.mjs";
70
90
  import { getCurrentCliVersion } from "./version-detection.js";
71
91
 
72
- const exec = promisify(child_process.exec);
73
-
74
92
  const passErrorToVSCode = (message: string | undefined, logger: Logger) => {
75
93
  if (message && process.env.SUPERBLOCKS_VSCODE === "true") {
76
94
  // Prefixing with `clierr:` will make the VS code extension capture this message and show it to the user.
@@ -207,6 +225,79 @@ async function readPkgJson(cwd: string) {
207
225
  }
208
226
  }
209
227
 
228
+ async function readPackageLock(cwd: string): Promise<string | null> {
229
+ try {
230
+ return await nodeFs.readFile(path.join(cwd, "package-lock.json"), "utf8");
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Canonicalize a lockfile for post-install change detection. Every boot,
238
+ * `stripResolvedFromLockfile` (APPS-4300) removes the `resolved` URLs and
239
+ * the validation install writes them back, so comparing raw bytes flags
240
+ * "changed" on EVERY registry-validation boot — re-uploading the full
241
+ * workspace to DBFS forever. Dropping `resolved` from both sides of the
242
+ * comparison (same npm v2+ `.packages` shape the strip targets) means only
243
+ * material resolution changes — added/removed packages, version bumps,
244
+ * integrity changes — trigger the upload.
245
+ */
246
+ export function lockfileComparisonKey(raw: string | null): string | null {
247
+ if (raw === null) return null;
248
+ try {
249
+ const parsed = JSON.parse(raw) as { packages?: Record<string, unknown> };
250
+ if (parsed?.packages && typeof parsed.packages === "object") {
251
+ for (const entry of Object.values(parsed.packages)) {
252
+ if (entry && typeof entry === "object") {
253
+ delete (entry as { resolved?: unknown }).resolved;
254
+ }
255
+ }
256
+ }
257
+ return JSON.stringify(parsed);
258
+ } catch {
259
+ // Malformed lockfile: fall back to byte-level comparison.
260
+ return raw;
261
+ }
262
+ }
263
+
264
+ async function shouldValidatePrivateRegistryInstall(
265
+ npmRegistryClient: NpmRegistryClient | undefined,
266
+ logger: Logger,
267
+ ): Promise<boolean> {
268
+ if (!npmRegistryClient) {
269
+ return false;
270
+ }
271
+
272
+ try {
273
+ const result: NpmRegistryFetchResult = await npmRegistryClient.getConfig();
274
+ // `configured` / `stale` → the org is known to route installs through a
275
+ // registry (`stale` = last-known-good config during a config-service
276
+ // outage), so the startup install must run even on identical
277
+ // package.json snapshots: with `resolved` stripped from the lockfiles
278
+ // (APPS-4300), npm re-resolves the entire baked tree through that
279
+ // registry, surfacing missing packages at app-creation time instead of
280
+ // at deploy-time bundle build (APPS-4527).
281
+ //
282
+ // `not-configured` and `unreachable` deliberately do NOT force the
283
+ // install. Not-configured orgs gain nothing from re-resolving against
284
+ // public npm on every boot. And when the config service is unreachable
285
+ // with no last-known-good, we cannot materialize the right `.npmrc` —
286
+ // forcing an install would re-resolve a private-registry org's lockfile
287
+ // against whatever registry happens to be on disk, "validating" against
288
+ // the wrong host and writing its URLs back into the lockfile. Fail
289
+ // closed instead (same principle as the AppShell short-circuit,
290
+ // APPS-4370): skip validation and keep today's snapshot decision.
291
+ return result.source === "configured" || result.source === "stale";
292
+ } catch (err) {
293
+ logger.warn(
294
+ "Could not resolve npm registry config for startup install validation; preserving package snapshot decision",
295
+ getErrorMeta(err),
296
+ );
297
+ return false;
298
+ }
299
+ }
300
+
210
301
  async function normalizePackageJsonForNpm(cwd: string, logger: Logger) {
211
302
  const packageJsonPath = path.join(cwd, "package.json");
212
303
  let raw: string;
@@ -246,7 +337,11 @@ async function normalizePackageJsonForNpm(cwd: string, logger: Logger) {
246
337
  }
247
338
  }
248
339
 
249
- async function installPackages(cwd: string, logger: Logger) {
340
+ export async function installPackages(
341
+ cwd: string,
342
+ logger: Logger,
343
+ npmRegistryClient?: NpmRegistryClient,
344
+ ) {
250
345
  try {
251
346
  const pm = await detect({
252
347
  strategies: [
@@ -272,11 +367,41 @@ async function installPackages(cwd: string, logger: Logger) {
272
367
  await normalizePackageJsonForNpm(cwd, logger);
273
368
  }
274
369
 
275
- const installCommand = resolveCommand(
276
- pm.agent,
277
- "install",
278
- pm.agent === "npm" ? ["--fund=false", "--audit=false"] : [],
279
- );
370
+ // Honor the per-org `allow_install_scripts` policy on the dev-server
371
+ // startup install just like AppShell does for agent-driven installs
372
+ // (see `shell.ts`). When the org has opted out (`allowInstallScripts ===
373
+ // false`), append `--ignore-scripts` so lifecycle scripts don't run
374
+ // during dev-server boot. `undefined` (LD flag off, server omitted the
375
+ // field, no client wired up, or the resolution itself failed) keeps
376
+ // today's behaviour. The client owns last-known-good / cross-outage
377
+ // policy preservation; we deliberately swallow resolution errors here
378
+ // so a transient registry-endpoint outage doesn't abort the user's
379
+ // startup install — the policy default is "scripts allowed" anyway.
380
+ let ignoreScripts = false;
381
+ if (npmRegistryClient) {
382
+ try {
383
+ const result = await npmRegistryClient.getConfig();
384
+ ignoreScripts = shouldIgnoreInstallScripts(result);
385
+ } catch (err) {
386
+ logger.warn(
387
+ "Could not resolve npm install-scripts policy; proceeding without --ignore-scripts",
388
+ getErrorMeta(err),
389
+ );
390
+ }
391
+ }
392
+
393
+ // `--json` makes npm channel the structured error envelope (code +
394
+ // summary + detail) onto stdout so a failure can be classified into a
395
+ // DependencyInstallError. Output-only: it does not change resolution,
396
+ // only how npm reports it. The success-path `logger.info(stdout)` below
397
+ // then logs JSON, which is acceptable.
398
+ const baseArgs =
399
+ pm.agent === "npm" ? ["--fund=false", "--audit=false", "--json"] : [];
400
+ const installArgs = ignoreScripts
401
+ ? [...baseArgs, "--ignore-scripts"]
402
+ : baseArgs;
403
+
404
+ const installCommand = resolveCommand(pm.agent, "install", installArgs);
280
405
 
281
406
  if (!installCommand) {
282
407
  logger.warn(
@@ -290,17 +415,119 @@ async function installPackages(cwd: string, logger: Logger) {
290
415
  `Running ${command} ${args.join(" ")} to install dependencies…`,
291
416
  );
292
417
 
418
+ // Pin both npm and pnpm to the Superblocks-owned userconfig via env
419
+ // overlay. `buildInstallEnv` (from `automatic-upgrades.ts`) sets
420
+ // both `NPM_CONFIG_USERCONFIG` (npm, pnpm <= 10) and
421
+ // `PNPM_CONFIG_USERCONFIG` (pnpm 11+) — pnpm 11 stopped honouring
422
+ // the `npm_config_*` env vars. CLI flags differ between agents
423
+ // (`--userconfig=` for npm, `--config.userconfig=` for pnpm), so
424
+ // the env overlay is the uniform mechanism. Without this, customer
425
+ // pods using a private registry would read default `~/.npmrc`
426
+ // (empty after the relocation) and fail to resolve private
427
+ // packages. Centralised in `buildInstallEnv` so this and the CLI
428
+ // auto-upgrade install stay in sync.
429
+ // Ensure the per-app log dir exists so npm's debug log (routed via
430
+ // `NPM_CONFIG_LOGS_DIR` in `buildInstallEnv`) lands in a predictable
431
+ // place. npm would create it on demand, but doing it up front means the
432
+ // folder exists even for pnpm runs (which ignore the var) and for any
433
+ // log-collection sidecar watching `<app>/.superblocks/logs`.
434
+ const logsDir = superblocksLogsPath(cwd);
435
+ await nodeFs.mkdir(logsDir, { recursive: true }).catch(() => undefined);
436
+
437
+ // Resolve the promisified `exec` at CALL time — not module scope — so a
438
+ // test's `vi.mock("node:child_process")` is always honoured regardless of
439
+ // when `dev.mjs` was first evaluated. A module-scope capture binds to
440
+ // whichever `child_process.exec` was live at first import; if a sibling
441
+ // test imports this module before installing its own mock (e.g.
442
+ // `dev-token-priming` / `dev.interception`, which don't mock
443
+ // `child_process`), that binding is the REAL npm and the classify test's
444
+ // mock silently never takes effect — the exact APPS-4450 CI failure
445
+ // (`category: "unknown"`, `npmErrorCode: undefined` from real npm against a
446
+ // missing `/tmp/app`).
447
+ const exec = promisify(child_process.exec);
293
448
  const { stdout } = await exec(`${command} ${args.join(" ")}`, {
294
449
  cwd,
450
+ env: buildInstallEnv(superblocksNpmrcPath(), logsDir),
295
451
  });
296
452
  logger.info("Package installation completed successfully");
297
453
  logger.info(stdout);
298
454
  } catch (error) {
299
455
  logger.error("Error during package installation", getErrorMeta(error));
300
- throw error;
456
+ // `util.promisify(child_process.exec)` preserves `.stdout`/`.stderr` on
457
+ // the rejected error separately; with `--json`, the structured npm error
458
+ // envelope lands on `.stdout`. Classify it into a DependencyInstallError
459
+ // and throw the `InitialInstallFailed` marker so the `dev()` catch can
460
+ // degrade by origin rather than crash-looping the dev-server pod.
461
+ const ctx = await buildInstallParseContext(cwd, npmRegistryClient);
462
+ const f = error as { stdout?: string; stderr?: string; message?: string };
463
+ const serverError = classifyInitialInstallError(
464
+ { stdout: f.stdout, stderr: f.stderr, message: f.message },
465
+ ctx,
466
+ );
467
+ throw new InitialInstallFailed(serverError);
301
468
  }
302
469
  }
303
470
 
471
+ /**
472
+ * Build the `ParseContext` the dependency-install classifier needs from the
473
+ * failing install's working directory + registry client:
474
+ *
475
+ * - `requestedPackages` — the union of `dependencies` + `devDependencies`
476
+ * in the app's `package.json`, used by the registry-blocked renderers to
477
+ * name the failing specs when npm's `--json` `detail` doesn't.
478
+ * - `hasAnyRegistryConfigured` — the tri-state derived from the registry
479
+ * client's most recent `getConfig()`, mirroring AppShell's
480
+ * `deriveHasAnyRegistryConfigured` (`shell.ts`): `configured`/`stale`
481
+ * → `true` (rows known to exist), `not-configured` → `false` (deliberate
482
+ * "no rows"), `unreachable` → `undefined` ("we don't know" — the renderer
483
+ * falls back to the default variant). Left `undefined` when no client is
484
+ * wired in or the resolution itself throws.
485
+ *
486
+ * Best-effort throughout: a missing/unparseable package.json or a registry
487
+ * outage must not mask the underlying install failure, so both lookups are
488
+ * caught and degrade to empty/undefined.
489
+ */
490
+ async function buildInstallParseContext(
491
+ cwd: string,
492
+ npmRegistryClient?: NpmRegistryClient,
493
+ ): Promise<ParseContext> {
494
+ let requestedPackages: { name: string; version?: string }[] = [];
495
+ try {
496
+ const pkg = JSON.parse(
497
+ await nodeFs.readFile(path.join(cwd, "package.json"), "utf8"),
498
+ );
499
+ requestedPackages = Object.entries({
500
+ ...(pkg.dependencies ?? {}),
501
+ ...(pkg.devDependencies ?? {}),
502
+ }).map(([name, version]) => ({ name, version: String(version) }));
503
+ } catch {
504
+ /* best-effort: a missing/unparseable package.json yields no specs */
505
+ }
506
+
507
+ let hasAnyRegistryConfigured: boolean | undefined;
508
+ if (npmRegistryClient) {
509
+ try {
510
+ const result = await npmRegistryClient.getConfig();
511
+ switch (result.source) {
512
+ case "configured":
513
+ case "stale":
514
+ hasAnyRegistryConfigured = true;
515
+ break;
516
+ case "not-configured":
517
+ hasAnyRegistryConfigured = false;
518
+ break;
519
+ case "unreachable":
520
+ hasAnyRegistryConfigured = undefined;
521
+ break;
522
+ }
523
+ } catch {
524
+ /* leave undefined → renderers treat as "don't know" / default variant */
525
+ }
526
+ }
527
+
528
+ return { requestedPackages, hasAnyRegistryConfigured };
529
+ }
530
+
304
531
  export enum DevServerAutoUpgradeMode {
305
532
  SKIP = "skip-upgrade",
306
533
  FORCE = "force-upgrade",
@@ -314,6 +541,67 @@ export enum DevServerAutoUpgradeMode {
314
541
  SKIP_CLI_ONLY = "skip-cli-only",
315
542
  }
316
543
 
544
+ /**
545
+ * Seed `tokenManager` with the initial token the CLI received from auth.json
546
+ * (standalone dev) or `/_sb_activate` (SABS live-edit pod activation), so the
547
+ * `NpmRegistryClient`'s JWT source has a usable bearer credential on cold
548
+ * boot.
549
+ *
550
+ * Without this seeding, `TokenManager.updateToken` is only ever called by
551
+ * `AuthHotReloadServer` (`auth-hot-reload.mts`) — and that socket is
552
+ * explicitly disabled on live-edit pods (`sabs/entrypoint-local.sh` sets
553
+ * `SUPERBLOCKS_AUTH_HOT_RELOAD=false`), so `NpmRegistryClient.getConfig()`
554
+ * cannot authenticate its server fetch on cold boot. The result is
555
+ * `source: "unreachable"`, `syncHomeNpmrc` skips with `~/.npmrc` left
556
+ * untouched, and the CLI auto-upgrade that fires moments later resolves
557
+ * `npm install -g @superblocksteam/cli@…` through public npm instead of the
558
+ * customer's configured private registry.
559
+ *
560
+ * Call-site ordering is intentionally NOT load-bearing: `TokenManager`
561
+ * retains the current token as state and `AiService` reads
562
+ * `tokenManager.getCurrentToken()` synchronously at construction time, so
563
+ * the prime call works whether it runs before or after consumer
564
+ * construction. The `tokenUpdated` event stream stays as the refresh
565
+ * channel for future rotations (hot-reload pushes, JWT renewal), so this
566
+ * seed is purely additive.
567
+ */
568
+ export function primeTokenManagerWithInitialToken(
569
+ tokenManager: TokenManager,
570
+ token: string,
571
+ ): void {
572
+ if (token) {
573
+ tokenManager.updateToken(token);
574
+ }
575
+ }
576
+
577
+ export interface DevServerStatus {
578
+ serverErrors: ServerError[];
579
+ }
580
+
581
+ /** Decide how the startup catch handles an error: degrade (record, keep Vite up)
582
+ * for an app-install failure (the InitialInstallFailed marker), or exit for
583
+ * anything else (lock/sync/upgrade). Pure + unit-tested. Does NOT call process.exit. */
584
+ export function handleStartupError(
585
+ error: unknown,
586
+ status: DevServerStatus,
587
+ logger: Logger,
588
+ ): "degrade" | "exit" {
589
+ if (error instanceof InitialInstallFailed) {
590
+ status.serverErrors.push(error.serverError);
591
+ logger.error(
592
+ "[dev-server] initial dependency install failed; keeping dev server up",
593
+ getErrorMeta(error),
594
+ );
595
+ devServerMetrics.recordInitialInstallFailure({
596
+ category: error.serverError.category,
597
+ npmErrorCode: error.serverError.npmErrorCode,
598
+ hasAnyRegistryConfigured: error.serverError.hasAnyRegistryConfigured,
599
+ });
600
+ return "degrade";
601
+ }
602
+ return "exit";
603
+ }
604
+
317
605
  export async function dev(options: {
318
606
  /* cwd is required */
319
607
  cwd: string;
@@ -380,6 +668,15 @@ export async function dev(options: {
380
668
  sdk,
381
669
  } = options;
382
670
 
671
+ // Seed the tokenManager with the initial CLI token so downstream consumers
672
+ // (AiService, AutoConnectingRpcClient, syncHomeNpmrc) have a usable
673
+ // bearer credential without waiting on the AuthHotReloadServer push
674
+ // refresh (which is disabled on live-edit pods). Order-independent: the
675
+ // manager retains state and AiService reads it synchronously during
676
+ // construction. See `primeTokenManagerWithInitialToken` for the
677
+ // cold-boot rationale.
678
+ primeTokenManagerWithInitialToken(tokenManager, tokenConfig.token);
679
+
383
680
  // May be overridden by a pending snapshot restore
384
681
  let { downloadFirst, uploadFirst } = options;
385
682
 
@@ -391,17 +688,40 @@ export async function dev(options: {
391
688
  let snapshotManager: SnapshotManager | undefined;
392
689
  let gitUserName: string | undefined;
393
690
  let gitUserEmail: string | undefined;
394
- // In-flight install handle. We launch the install while the upload/restart
395
- // decisions are still being evaluated and join at every gate that depends
396
- // on post-install state. See the install block and `joinPackageInstall`.
691
+ // In-flight handles for the two background jobs we launch from sync.
692
+ // We keep them SEPARATE so an install rejection cannot swallow an upgrade
693
+ // rejection (or vice-versa) the way a single `Promise.all` would: that
694
+ // bundle settles on the FIRST rejection and silently absorbs the other's
695
+ // outcome, breaking APPS-4457's intent that auto-upgrade failures still
696
+ // exit (not degrade) and CLI-restart-on-upgrade still happens even if the
697
+ // app install fails.
698
+ //
699
+ // - packageUpgradePromise: library upgrades (and their `npm install`
700
+ // side-effects) from `checkVersionsAndWritePackageJson`. Rejection is
701
+ // OUT of scope for graceful degrade (APPS-4457) — `handleStartupError`
702
+ // routes it to `process.exit(1)`.
703
+ // - packageInstallPromise: the app's verification `npm install`.
704
+ // Rejection MAY be the `InitialInstallFailed` marker, which
705
+ // `handleStartupError` routes to the degrade path.
706
+ let packageUpgradePromise: Promise<void> | undefined;
397
707
  let packageInstallPromise: Promise<void> | undefined;
398
708
  const tracer = getTracer();
399
709
  const logger = getLogger(options.logger);
400
- // Joins the in-flight install at any step that depends on a settled
401
- // node_modules / lockfile state. Clears the handle so later joins are
402
- // no-ops. The rejection (if any) propagates so the caller's step can
403
- // abort cleanly. Defined at outer scope so the post-sync `createDevServer`
404
- // gate and the abort handler can call it too.
710
+ // Joins the in-flight upgrade. Rejection propagates so the caller's step
711
+ // can abort cleanly (handleStartupError exits auto-upgrade graceful
712
+ // degrade is OOS per APPS-4457).
713
+ const joinPackageUpgrade = async (reason: string): Promise<void> => {
714
+ if (!packageUpgradePromise) {
715
+ return;
716
+ }
717
+ logger.info(`Waiting for background package upgrade (${reason})…`);
718
+ const promise = packageUpgradePromise;
719
+ packageUpgradePromise = undefined;
720
+ await promise;
721
+ };
722
+ // Joins the in-flight install. Rejection propagates so the caller's step
723
+ // can abort cleanly (handleStartupError degrades for InitialInstallFailed,
724
+ // exits otherwise).
405
725
  const joinPackageInstall = async (reason: string): Promise<void> => {
406
726
  if (!packageInstallPromise) {
407
727
  return;
@@ -411,12 +731,36 @@ export async function dev(options: {
411
731
  packageInstallPromise = undefined;
412
732
  await promise;
413
733
  };
734
+ // Settles the in-flight install WITHOUT throwing. Used on the CLI-restart
735
+ // path so an install failure doesn't preempt the restart (the new CLI's
736
+ // first boot re-runs install). Still waits for npm to finish so we don't
737
+ // SIGKILL it mid-rename.
738
+ const settlePackageInstall = async (reason: string): Promise<void> => {
739
+ if (!packageInstallPromise) {
740
+ return;
741
+ }
742
+ logger.info(`Settling background package install (${reason})…`);
743
+ const promise = packageInstallPromise;
744
+ packageInstallPromise = undefined;
745
+ await Promise.allSettled([promise]);
746
+ };
747
+ // Joins upgrade THEN install. Upgrade failures surface first so
748
+ // handleStartupError exits (per APPS-4457) before an install rejection
749
+ // gets a chance to route to degrade.
750
+ const joinUpgradeThenInstall = async (reason: string): Promise<void> => {
751
+ await joinPackageUpgrade(reason);
752
+ await joinPackageInstall(reason);
753
+ };
414
754
  const skipAutoUpgrade = autoUpgradeMode === DevServerAutoUpgradeMode.SKIP;
415
755
  const skipCliUpgrade =
416
756
  skipAutoUpgrade ||
417
757
  autoUpgradeMode === DevServerAutoUpgradeMode.SKIP_CLI_ONLY;
418
758
 
419
759
  await tracer.startActiveSpan("devServerStartup", async (startupSpan) => {
760
+ // Mutable startup status surfaced to the browser via createDevServer's
761
+ // /_sb_connect + /_sb_status. The startup catch records an app-install
762
+ // failure here (degrade path) so Vite still starts and serves the error.
763
+ const devServerStatus: DevServerStatus = { serverErrors: [] };
420
764
  try {
421
765
  // Add check for node_modules
422
766
  if (!fs.existsSync(path.join(cwd, "node_modules"))) {
@@ -830,16 +1174,44 @@ export async function dev(options: {
830
1174
  logger.info("[dev-startup] Skipping download, already in sync");
831
1175
  }
832
1176
 
833
- // Unconditional lockfile sanitation: strip cross-registry
834
- // `resolved` URLs from any lockfile on disk regardless of whether
835
- // install runs next. The lockfile here is whatever survived the
836
- // DBFS path (downloadFirst overwrite, prior boot, brownfield
837
- // import); npm honors `resolved` verbatim and would bypass the
838
- // active registry. `integrity` is preserved, so genuine
839
- // cross-registry tarball drift surfaces as EINTEGRITY. No-op when
840
- // there's no lockfile or no `resolved` entries.
1177
+ // Unconditional lockfile sanitation (APPS-4300): strip
1178
+ // cross-registry `resolved` URLs from any lockfile on disk
1179
+ // regardless of whether install runs next. The lockfile here is
1180
+ // whatever survived the DBFS path (downloadFirst overwrite,
1181
+ // prior boot, brownfield import); npm honors `resolved` verbatim
1182
+ // and would bypass the active registry. `integrity` is
1183
+ // preserved, so genuine cross-registry tarball drift surfaces as
1184
+ // EINTEGRITY. No-op when there's no lockfile or no `resolved`
1185
+ // entries.
841
1186
  await stripResolvedFromLockfile(cwd);
842
1187
 
1188
+ // Materialise `~/.superblocks/npmrc` from the server-fetched
1189
+ // per-org npm registry config BEFORE the global Superblocks CLI
1190
+ // auto-upgrade fires. With the file in place, the auto-upgrade's
1191
+ // `npm install -g @superblocksteam/cli@…` resolves through the
1192
+ // customer's private registry instead of `registry.npmjs.org`.
1193
+ await tracer.startActiveSpan("syncHomeNpmrc", async (span) => {
1194
+ try {
1195
+ if (!aiService) {
1196
+ logger.info(
1197
+ "[home-npmrc] skipped: AiService unavailable at startup",
1198
+ );
1199
+ return;
1200
+ }
1201
+ await syncHomeNpmrc({
1202
+ npmRegistryClient: aiService.getNpmRegistryClient(),
1203
+ logger,
1204
+ });
1205
+ } catch (error) {
1206
+ logger.warn(
1207
+ "[home-npmrc] sync step failed unexpectedly; ~/.superblocks/npmrc left untouched",
1208
+ getErrorMeta(error),
1209
+ );
1210
+ } finally {
1211
+ span.end();
1212
+ }
1213
+ });
1214
+
843
1215
  let hasCliUpdated = false;
844
1216
  let upgradePromises: Promise<void>[] = [];
845
1217
  const forceUpgrade =
@@ -871,6 +1243,9 @@ export async function dev(options: {
871
1243
  applicationConfigWithTokenConfigAndUserInfo,
872
1244
  forceUpgrade,
873
1245
  skipCliUpgrade,
1246
+ // Route the CLI-upgrade install's npm debug log into the
1247
+ // same `<app>/.superblocks/logs` as the startup install.
1248
+ cwd,
874
1249
  );
875
1250
  hasCliUpdated = result.cliUpdated;
876
1251
  upgradePromises = result.upgradePromises;
@@ -920,13 +1295,22 @@ export async function dev(options: {
920
1295
  if (!packageJsonBefore && packageJsonRequiresInstall) {
921
1296
  logger.info("package.json was created, installing packages…");
922
1297
  }
1298
+ const npmRegistryClient = aiService?.getNpmRegistryClient();
923
1299
  const forcePackageInstallRequested = !!options.forcePackageInstall;
924
1300
  let forcePackageInstall = forcePackageInstallRequested;
1301
+ const privateRegistryRequiresInstallValidation = packageJsonAfter
1302
+ ? await shouldValidatePrivateRegistryInstall(
1303
+ npmRegistryClient,
1304
+ logger,
1305
+ )
1306
+ : false;
925
1307
  if (
926
1308
  forcePackageInstallRequested &&
927
1309
  hasPackageJsonSnapshotBeforeRestore
928
1310
  ) {
929
- forcePackageInstall = packageJsonRequiresInstall;
1311
+ forcePackageInstall =
1312
+ packageJsonRequiresInstall ||
1313
+ privateRegistryRequiresInstallValidation;
930
1314
  }
931
1315
 
932
1316
  logger.info("Package install decision", {
@@ -936,6 +1320,7 @@ export async function dev(options: {
936
1320
  packageJsonRequiresInstall,
937
1321
  forcePackageInstall,
938
1322
  forcePackageInstallRequested,
1323
+ privateRegistryRequiresInstallValidation,
939
1324
  upgradePromiseCount: upgradePromises.length,
940
1325
  packageJsonSnapshotBefore: packageJsonSnapshotDiagnostic(
941
1326
  packageJsonSnapshotBefore,
@@ -948,11 +1333,48 @@ export async function dev(options: {
948
1333
  ),
949
1334
  });
950
1335
 
1336
+ const installApplicationId = applicationConfig.id;
1337
+
1338
+ // Launch upgrades and app install as INDEPENDENT promises (not a
1339
+ // single `Promise.all`) so each outcome is observed on its own
1340
+ // join: upgrade failure exits (APPS-4457), install failure may
1341
+ // degrade (InitialInstallFailed). A bundled `Promise.all` would
1342
+ // settle on the first rejection and silently absorb the other's
1343
+ // result. The `.catch` backstops convert "no join fired" cases
1344
+ // into logged-then-handled rejections instead of unhandled ones.
1345
+ if (upgradePromises.length > 0) {
1346
+ logger.info("Starting package upgrade in background…");
1347
+ const launchedUpgradeCount = upgradePromises.length;
1348
+ packageUpgradePromise = tracer.startActiveSpan(
1349
+ "packageUpgrades",
1350
+ async (span) => {
1351
+ try {
1352
+ await Promise.all(upgradePromises);
1353
+ } finally {
1354
+ span.end();
1355
+ }
1356
+ },
1357
+ );
1358
+ packageUpgradePromise.catch((err) => {
1359
+ logger.error(
1360
+ `Background package upgrade failed [errorId=DEV_SERVER_BG_UPGRADE_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${launchedUpgradeCount}]`,
1361
+ getErrorMeta(err),
1362
+ );
1363
+ });
1364
+ }
1365
+
1366
+ // Run the verification install when EITHER the package.json
1367
+ // requires it, upgrades just modified package.json/lockfile
1368
+ // (re-syncing node_modules), the caller forced it, or a custom
1369
+ // registry must validate that the required packages resolve there.
1370
+ let packageLockBeforeInstall: string | null = null;
951
1371
  if (
952
1372
  packageJsonRequiresInstall ||
953
1373
  upgradePromises.length > 0 ||
954
- forcePackageInstall
1374
+ forcePackageInstall ||
1375
+ privateRegistryRequiresInstallValidation
955
1376
  ) {
1377
+ packageLockBeforeInstall = await readPackageLock(cwd);
956
1378
  // Launch the install while the upload/restart decisions below
957
1379
  // are still being evaluated. The synchronous joins at upload,
958
1380
  // CLI restart, and pre-Vite-startup all observe and surface
@@ -964,29 +1386,19 @@ export async function dev(options: {
964
1386
  "installPackages",
965
1387
  async (span) => {
966
1388
  try {
967
- // Upgrade global CLI and local packages in parallel - improves performance
968
- await Promise.all([
969
- ...upgradePromises,
970
- installPackages(cwd, logger),
971
- ]);
1389
+ await installPackages(cwd, logger, npmRegistryClient);
972
1390
  } finally {
973
1391
  span.end();
974
1392
  }
975
1393
  },
976
1394
  );
977
- // Backstop: assigning `.catch` to a separate (discarded) promise
978
- // keeps `packageInstallPromise` itself rejecting, so the joins
979
- // can still observe and abort. Without this, an install that
980
- // fails before any join fires becomes an unhandled rejection.
981
- const installApplicationId = applicationConfig.id;
982
- const installUpgradeCount = upgradePromises.length;
983
1395
  packageInstallPromise.catch((err) => {
984
1396
  logger.error(
985
1397
  // errorId is encoded into the message body because the
986
1398
  // logger.error contract limits structured attributes to
987
1399
  // `{ error: { kind, message, stack } }`. The id stays
988
1400
  // grep-able for Datadog/Sentry alert rules.
989
- `Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${installUpgradeCount}]`,
1401
+ `Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd}]`,
990
1402
  getErrorMeta(err),
991
1403
  );
992
1404
  });
@@ -998,27 +1410,75 @@ export async function dev(options: {
998
1410
 
999
1411
  const shouldUploadPackageState =
1000
1412
  hasPackageChanged || forcePackageInstall;
1001
- if (shouldUploadPackageState || uploadFirst) {
1413
+ let shouldUploadAfterInstall = shouldUploadPackageState;
1414
+ if (
1415
+ shouldUploadPackageState ||
1416
+ uploadFirst ||
1417
+ privateRegistryRequiresInstallValidation
1418
+ ) {
1002
1419
  // Upload serializes the post-install lockfile + node_modules
1003
- // tree to DBFS, so it must observe a quiesced install.
1004
- await joinPackageInstall("before upload");
1005
- logger.info(
1006
- `Uploading local files to branch '${activeDbfsBranchName}' on server before starting`,
1007
- );
1008
- await tracer.startActiveSpan(
1009
- "uploadFirstOrPackageChanged",
1010
- async (span) => {
1011
- await syncService!.uploadDirectory("cli:sdk");
1012
- await syncService!.uploadDirectoryNowIfNeeded("cli:sdk");
1013
- span.end();
1014
- },
1015
- );
1420
+ // tree to DBFS, so it must observe quiesced upgrade+install.
1421
+ // Upgrade first — its rejection exits before an install
1422
+ // rejection can take the degrade path (APPS-4457).
1423
+ //
1424
+ // BUT: if a successful CLI upgrade is pending restart
1425
+ // (`hasCliUpdated`), the restart branch below MUST win over
1426
+ // the install-failure degrade path. Otherwise the outer
1427
+ // sync/setup catch routes `InitialInstallFailed` to "degrade"
1428
+ // and skips the restart entirely — masking a successful CLI
1429
+ // upgrade. Catch ONLY the join (not the upload body) so a
1430
+ // post-join upload failure still propagates normally.
1431
+ let skipStartupUploadForCliRestart = false;
1432
+ try {
1433
+ await joinUpgradeThenInstall("before upload");
1434
+ } catch (joinError) {
1435
+ if (
1436
+ hasCliUpdated &&
1437
+ joinError instanceof InitialInstallFailed
1438
+ ) {
1439
+ logger.info(
1440
+ "Initial package install failed before startup upload, but CLI was updated; skipping upload and letting the restart proceed",
1441
+ );
1442
+ skipStartupUploadForCliRestart = true;
1443
+ } else {
1444
+ throw joinError;
1445
+ }
1446
+ }
1447
+ if (
1448
+ privateRegistryRequiresInstallValidation &&
1449
+ !shouldUploadAfterInstall &&
1450
+ lockfileComparisonKey(packageLockBeforeInstall) !==
1451
+ lockfileComparisonKey(await readPackageLock(cwd))
1452
+ ) {
1453
+ shouldUploadAfterInstall = true;
1454
+ }
1455
+ if (
1456
+ !skipStartupUploadForCliRestart &&
1457
+ (shouldUploadAfterInstall || uploadFirst)
1458
+ ) {
1459
+ logger.info(
1460
+ `Uploading local files to branch '${activeDbfsBranchName}' on server before starting`,
1461
+ );
1462
+ await tracer.startActiveSpan(
1463
+ "uploadFirstOrPackageChanged",
1464
+ async (span) => {
1465
+ await syncService!.uploadDirectory("cli:sdk");
1466
+ await syncService!.uploadDirectoryNowIfNeeded("cli:sdk");
1467
+ span.end();
1468
+ },
1469
+ );
1470
+ }
1016
1471
  }
1017
1472
 
1018
1473
  if (hasCliUpdated) {
1019
- // Exiting mid-install would leave a half-written lockfile that
1020
- // the next boot would have to recover from.
1021
- await joinPackageInstall("before CLI restart");
1474
+ // Restart must NOT be preempted by an app-install failure:
1475
+ // the next boot re-runs install with the new CLI. Join the
1476
+ // upgrade (it must succeed or we exit before restarting) and
1477
+ // SETTLE the install (don't observe its rejection — but wait
1478
+ // for npm to finish so the restart doesn't SIGKILL it
1479
+ // mid-rename and leave a half-written lockfile).
1480
+ await joinPackageUpgrade("before CLI restart");
1481
+ await settlePackageInstall("before CLI restart");
1022
1482
  try {
1023
1483
  logger.info("Releasing lock before restarting the dev server");
1024
1484
  await aiService?.removeIntegrationCache();
@@ -1034,16 +1494,23 @@ export async function dev(options: {
1034
1494
  }
1035
1495
  });
1036
1496
  } catch (error: any) {
1037
- logger.error(
1038
- "[dev-server] Startup failed during sync/lock/setup (exiting with code 1)",
1039
- getErrorMeta(error),
1040
- );
1041
- try {
1042
- await aiService?.removeIntegrationCache();
1043
- await lockService?.shutdownAndExit();
1044
- } finally {
1045
- // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
1046
- process.exit(1);
1497
+ if (
1498
+ handleStartupError(error, devServerStatus, logger) === "degrade"
1499
+ ) {
1500
+ // app-install failure: do NOT exit — fall through to Vite startup below.
1501
+ // upload + CLI-restart were already skipped (the rejecting join threw first).
1502
+ } else {
1503
+ logger.error(
1504
+ "[dev-server] Startup failed during sync/lock/setup (exiting with code 1)",
1505
+ getErrorMeta(error),
1506
+ );
1507
+ try {
1508
+ await aiService?.removeIntegrationCache();
1509
+ await lockService?.shutdownAndExit();
1510
+ } finally {
1511
+ // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
1512
+ process.exit(1);
1513
+ }
1047
1514
  }
1048
1515
  }
1049
1516
  } else {
@@ -1057,7 +1524,37 @@ export async function dev(options: {
1057
1524
  // cache embeds partial state that survives across reloads. Awaiting
1058
1525
  // here costs at most the install's remaining wall time — the upload
1059
1526
  // and CLI-restart joins above usually drain it first.
1060
- await joinPackageInstall("before Vite startup");
1527
+ // The "before upload" / "before CLI restart" joins above run inside the
1528
+ // sync/lock catch that degrades on `InitialInstallFailed`. But when the
1529
+ // install ran yet none of those joins fired (e.g.
1530
+ // `packageJsonRequiresInstall && !forcePackageInstall && !upload &&
1531
+ // !hasCliUpdated`), the install rejection first surfaces HERE — outside
1532
+ // that catch. Route it through the SAME origin gate so an app-install
1533
+ // failure still degrades (keep Vite up + record the error) instead of
1534
+ // escaping `dev()` to `process.exit(1)`. Non-install errors (including
1535
+ // any background upgrade rejection that escaped the prior joins) take
1536
+ // the same explicit exit path as the sync/lock catch above so the
1537
+ // process actually terminates (the startupSpan catch only rethrows,
1538
+ // and an unhandled rejection at that depth doesn't shut the lock
1539
+ // service down or guarantee `process.exit(1)`).
1540
+ try {
1541
+ await joinUpgradeThenInstall("before Vite startup");
1542
+ } catch (error) {
1543
+ if (handleStartupError(error, devServerStatus, logger) === "exit") {
1544
+ logger.error(
1545
+ "[dev-server] Startup failed during pre-Vite install join (exiting with code 1)",
1546
+ getErrorMeta(error),
1547
+ );
1548
+ try {
1549
+ await aiService?.removeIntegrationCache();
1550
+ await lockService?.shutdownAndExit();
1551
+ } finally {
1552
+ // Redundant with `shutdownAndExit`; here so a thrown shutdown
1553
+ // path can't leave the process hanging on a stuck handle.
1554
+ process.exit(1);
1555
+ }
1556
+ }
1557
+ }
1061
1558
 
1062
1559
  const activateRuntimeGitService = async (): Promise<
1063
1560
  GitService | undefined
@@ -1158,27 +1655,26 @@ export async function dev(options: {
1158
1655
  const httpServer = await tracer.startActiveSpan(
1159
1656
  "createDevServer",
1160
1657
  async (span) => {
1161
- const createDevServerOptions = {
1162
- root: options.cwd,
1163
- mode: "development",
1164
- port: port,
1165
- fsOperationQueue,
1166
- syncService: syncService,
1167
- lockService: lockService,
1168
- aiService: aiService,
1169
- gitService: gitService,
1170
- gitBootstrapFailed: devStartupGitOutcome === "bootstrap_failed",
1171
- activateGitService: activateRuntimeGitService,
1172
- snapshotManager: snapshotManager,
1173
- logger: options.logger,
1174
- sdk,
1175
- superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
1176
- existingServer: options.existingServer,
1177
- warmActivationStart: options.warmActivationStart,
1178
- // TODO: Remove this cast — build the options object to match
1179
- // CreateDevServerOptions directly so new required fields cause a
1180
- // compile error instead of silently passing undefined.
1181
- } as unknown as Parameters<typeof createDevServer>[0];
1658
+ const createDevServerOptions: Parameters<typeof createDevServer>[0] =
1659
+ {
1660
+ root: options.cwd,
1661
+ mode: "development",
1662
+ port: port,
1663
+ fsOperationQueue,
1664
+ syncService: syncService,
1665
+ lockService: lockService,
1666
+ aiService: aiService,
1667
+ gitService: gitService,
1668
+ gitBootstrapFailed: devStartupGitOutcome === "bootstrap_failed",
1669
+ activateGitService: activateRuntimeGitService,
1670
+ snapshotManager: snapshotManager,
1671
+ logger: options.logger,
1672
+ sdk,
1673
+ superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
1674
+ existingServer: options.existingServer,
1675
+ warmActivationStart: options.warmActivationStart,
1676
+ devServerStatus,
1677
+ };
1182
1678
  const result = await createDevServer(createDevServerOptions);
1183
1679
  span.end();
1184
1680
  return result;
@@ -1194,13 +1690,19 @@ export async function dev(options: {
1194
1690
  logger.warn(`Error stopping auth hot-reload server: ${error}`);
1195
1691
  });
1196
1692
  }
1197
- // Drain any straggler install before tear-down. By this point the
1198
- // pre-Vite join has already run on the success path, so usually
1199
- // `packageInstallPromise` is undefined and this is a no-op; if abort
1693
+ // Drain any straggler upgrade/install before tear-down. By this
1694
+ // point the pre-Vite join has already run on the success path, so
1695
+ // usually both promises are undefined and this is a no-op; if abort
1200
1696
  // races createDevServer (or fires during the inner sync block on
1201
- // some error path), draining here keeps the spawned npm child from
1202
- // being SIGKILL'd mid-rename. Errors only get logged — we are tearing
1203
- // down anyway.
1697
+ // some error path), draining here keeps the spawned npm children
1698
+ // from being SIGKILL'd mid-rename. Errors only get logged — we are
1699
+ // tearing down anyway.
1700
+ joinPackageUpgrade("during abort").catch((error: unknown) => {
1701
+ logger.warn(
1702
+ "Error draining background upgrade during abort",
1703
+ getErrorMeta(error),
1704
+ );
1705
+ });
1204
1706
  joinPackageInstall("during abort").catch((error: unknown) => {
1205
1707
  logger.warn(
1206
1708
  "Error draining background install during abort",