@superblocksteam/sdk 2.0.123-next.0 → 2.0.124-next.0

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 +170 -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 +36 -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 +396 -62
  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 +210 -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 +495 -92
  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,16 @@ import {
35
36
  LockService,
36
37
  LockType,
37
38
  } from "@superblocksteam/vite-plugin-file-sync/lock-service";
39
+ import {
40
+ type NpmRegistryClient,
41
+ type ParseContext,
42
+ shouldIgnoreInstallScripts,
43
+ } from "@superblocksteam/vite-plugin-file-sync/npm-registry";
38
44
  import { OperationQueue } from "@superblocksteam/vite-plugin-file-sync/operation-queue";
39
45
  import { AutoConnectingRpcClient } from "@superblocksteam/vite-plugin-file-sync/server-rpc";
40
46
  import { SyncService } from "@superblocksteam/vite-plugin-file-sync/sync-service";
41
47
 
48
+ import { devServerMetrics } from "../dev-utils/dev-server-metrics.mjs";
42
49
  import { createDevServer } from "../dev-utils/dev-server.mjs";
43
50
  import { AUTO_UPGRADE_EXIT_CODE } from "../index.js";
44
51
  import type {
@@ -54,11 +61,23 @@ import type {
54
61
  ApplicationConfigWithTokenConfigAndUserInfo,
55
62
  TokenConfig,
56
63
  } from "../types/index.js";
57
- import { checkVersionsAndWritePackageJson } from "./automatic-upgrades.js";
64
+ import {
65
+ buildInstallEnv,
66
+ checkVersionsAndWritePackageJson,
67
+ } from "./automatic-upgrades.js";
68
+ import {
69
+ classifyInitialInstallError,
70
+ InitialInstallFailed,
71
+ } from "./dependency-install-classifier.mjs";
58
72
  import {
59
73
  ensureRemoteHasDefaultBranch,
60
74
  getGitErrorFields,
61
75
  } from "./git-repo-setup.mjs";
76
+ import {
77
+ superblocksLogsPath,
78
+ superblocksNpmrcPath,
79
+ syncHomeNpmrc,
80
+ } from "./home-npmrc.mjs";
62
81
  import { normalizeWorkspaceProtocolForNpm } from "./normalize-workspace-protocol.js";
63
82
  import {
64
83
  didPackageJsonSnapshotChange,
@@ -69,8 +88,6 @@ import {
69
88
  } from "./package-json-snapshot.mjs";
70
89
  import { getCurrentCliVersion } from "./version-detection.js";
71
90
 
72
- const exec = promisify(child_process.exec);
73
-
74
91
  const passErrorToVSCode = (message: string | undefined, logger: Logger) => {
75
92
  if (message && process.env.SUPERBLOCKS_VSCODE === "true") {
76
93
  // Prefixing with `clierr:` will make the VS code extension capture this message and show it to the user.
@@ -246,7 +263,11 @@ async function normalizePackageJsonForNpm(cwd: string, logger: Logger) {
246
263
  }
247
264
  }
248
265
 
249
- async function installPackages(cwd: string, logger: Logger) {
266
+ export async function installPackages(
267
+ cwd: string,
268
+ logger: Logger,
269
+ npmRegistryClient?: NpmRegistryClient,
270
+ ) {
250
271
  try {
251
272
  const pm = await detect({
252
273
  strategies: [
@@ -272,11 +293,41 @@ async function installPackages(cwd: string, logger: Logger) {
272
293
  await normalizePackageJsonForNpm(cwd, logger);
273
294
  }
274
295
 
275
- const installCommand = resolveCommand(
276
- pm.agent,
277
- "install",
278
- pm.agent === "npm" ? ["--fund=false", "--audit=false"] : [],
279
- );
296
+ // Honor the per-org `allow_install_scripts` policy on the dev-server
297
+ // startup install just like AppShell does for agent-driven installs
298
+ // (see `shell.ts`). When the org has opted out (`allowInstallScripts ===
299
+ // false`), append `--ignore-scripts` so lifecycle scripts don't run
300
+ // during dev-server boot. `undefined` (LD flag off, server omitted the
301
+ // field, no client wired up, or the resolution itself failed) keeps
302
+ // today's behaviour. The client owns last-known-good / cross-outage
303
+ // policy preservation; we deliberately swallow resolution errors here
304
+ // so a transient registry-endpoint outage doesn't abort the user's
305
+ // startup install — the policy default is "scripts allowed" anyway.
306
+ let ignoreScripts = false;
307
+ if (npmRegistryClient) {
308
+ try {
309
+ const result = await npmRegistryClient.getConfig();
310
+ ignoreScripts = shouldIgnoreInstallScripts(result);
311
+ } catch (err) {
312
+ logger.warn(
313
+ "Could not resolve npm install-scripts policy; proceeding without --ignore-scripts",
314
+ getErrorMeta(err),
315
+ );
316
+ }
317
+ }
318
+
319
+ // `--json` makes npm channel the structured error envelope (code +
320
+ // summary + detail) onto stdout so a failure can be classified into a
321
+ // DependencyInstallError. Output-only: it does not change resolution,
322
+ // only how npm reports it. The success-path `logger.info(stdout)` below
323
+ // then logs JSON, which is acceptable.
324
+ const baseArgs =
325
+ pm.agent === "npm" ? ["--fund=false", "--audit=false", "--json"] : [];
326
+ const installArgs = ignoreScripts
327
+ ? [...baseArgs, "--ignore-scripts"]
328
+ : baseArgs;
329
+
330
+ const installCommand = resolveCommand(pm.agent, "install", installArgs);
280
331
 
281
332
  if (!installCommand) {
282
333
  logger.warn(
@@ -290,15 +341,117 @@ async function installPackages(cwd: string, logger: Logger) {
290
341
  `Running ${command} ${args.join(" ")} to install dependencies…`,
291
342
  );
292
343
 
344
+ // Pin both npm and pnpm to the Superblocks-owned userconfig via env
345
+ // overlay. `buildInstallEnv` (from `automatic-upgrades.ts`) sets
346
+ // both `NPM_CONFIG_USERCONFIG` (npm, pnpm <= 10) and
347
+ // `PNPM_CONFIG_USERCONFIG` (pnpm 11+) — pnpm 11 stopped honouring
348
+ // the `npm_config_*` env vars. CLI flags differ between agents
349
+ // (`--userconfig=` for npm, `--config.userconfig=` for pnpm), so
350
+ // the env overlay is the uniform mechanism. Without this, customer
351
+ // pods using a private registry would read default `~/.npmrc`
352
+ // (empty after the relocation) and fail to resolve private
353
+ // packages. Centralised in `buildInstallEnv` so this and the CLI
354
+ // auto-upgrade install stay in sync.
355
+ // Ensure the per-app log dir exists so npm's debug log (routed via
356
+ // `NPM_CONFIG_LOGS_DIR` in `buildInstallEnv`) lands in a predictable
357
+ // place. npm would create it on demand, but doing it up front means the
358
+ // folder exists even for pnpm runs (which ignore the var) and for any
359
+ // log-collection sidecar watching `<app>/.superblocks/logs`.
360
+ const logsDir = superblocksLogsPath(cwd);
361
+ await nodeFs.mkdir(logsDir, { recursive: true }).catch(() => undefined);
362
+
363
+ // Resolve the promisified `exec` at CALL time — not module scope — so a
364
+ // test's `vi.mock("node:child_process")` is always honoured regardless of
365
+ // when `dev.mjs` was first evaluated. A module-scope capture binds to
366
+ // whichever `child_process.exec` was live at first import; if a sibling
367
+ // test imports this module before installing its own mock (e.g.
368
+ // `dev-token-priming` / `dev.interception`, which don't mock
369
+ // `child_process`), that binding is the REAL npm and the classify test's
370
+ // mock silently never takes effect — the exact APPS-4450 CI failure
371
+ // (`category: "unknown"`, `npmErrorCode: undefined` from real npm against a
372
+ // missing `/tmp/app`).
373
+ const exec = promisify(child_process.exec);
293
374
  const { stdout } = await exec(`${command} ${args.join(" ")}`, {
294
375
  cwd,
376
+ env: buildInstallEnv(superblocksNpmrcPath(), logsDir),
295
377
  });
296
378
  logger.info("Package installation completed successfully");
297
379
  logger.info(stdout);
298
380
  } catch (error) {
299
381
  logger.error("Error during package installation", getErrorMeta(error));
300
- throw error;
382
+ // `util.promisify(child_process.exec)` preserves `.stdout`/`.stderr` on
383
+ // the rejected error separately; with `--json`, the structured npm error
384
+ // envelope lands on `.stdout`. Classify it into a DependencyInstallError
385
+ // and throw the `InitialInstallFailed` marker so the `dev()` catch can
386
+ // degrade by origin rather than crash-looping the dev-server pod.
387
+ const ctx = await buildInstallParseContext(cwd, npmRegistryClient);
388
+ const f = error as { stdout?: string; stderr?: string; message?: string };
389
+ const serverError = classifyInitialInstallError(
390
+ { stdout: f.stdout, stderr: f.stderr, message: f.message },
391
+ ctx,
392
+ );
393
+ throw new InitialInstallFailed(serverError);
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Build the `ParseContext` the dependency-install classifier needs from the
399
+ * failing install's working directory + registry client:
400
+ *
401
+ * - `requestedPackages` — the union of `dependencies` + `devDependencies`
402
+ * in the app's `package.json`, used by the registry-blocked renderers to
403
+ * name the failing specs when npm's `--json` `detail` doesn't.
404
+ * - `hasAnyRegistryConfigured` — the tri-state derived from the registry
405
+ * client's most recent `getConfig()`, mirroring AppShell's
406
+ * `deriveHasAnyRegistryConfigured` (`shell.ts`): `configured`/`stale`
407
+ * → `true` (rows known to exist), `not-configured` → `false` (deliberate
408
+ * "no rows"), `unreachable` → `undefined` ("we don't know" — the renderer
409
+ * falls back to the default variant). Left `undefined` when no client is
410
+ * wired in or the resolution itself throws.
411
+ *
412
+ * Best-effort throughout: a missing/unparseable package.json or a registry
413
+ * outage must not mask the underlying install failure, so both lookups are
414
+ * caught and degrade to empty/undefined.
415
+ */
416
+ async function buildInstallParseContext(
417
+ cwd: string,
418
+ npmRegistryClient?: NpmRegistryClient,
419
+ ): Promise<ParseContext> {
420
+ let requestedPackages: { name: string; version?: string }[] = [];
421
+ try {
422
+ const pkg = JSON.parse(
423
+ await nodeFs.readFile(path.join(cwd, "package.json"), "utf8"),
424
+ );
425
+ requestedPackages = Object.entries({
426
+ ...(pkg.dependencies ?? {}),
427
+ ...(pkg.devDependencies ?? {}),
428
+ }).map(([name, version]) => ({ name, version: String(version) }));
429
+ } catch {
430
+ /* best-effort: a missing/unparseable package.json yields no specs */
431
+ }
432
+
433
+ let hasAnyRegistryConfigured: boolean | undefined;
434
+ if (npmRegistryClient) {
435
+ try {
436
+ const result = await npmRegistryClient.getConfig();
437
+ switch (result.source) {
438
+ case "configured":
439
+ case "stale":
440
+ hasAnyRegistryConfigured = true;
441
+ break;
442
+ case "not-configured":
443
+ hasAnyRegistryConfigured = false;
444
+ break;
445
+ case "unreachable":
446
+ hasAnyRegistryConfigured = undefined;
447
+ break;
448
+ }
449
+ } catch {
450
+ /* leave undefined → renderers treat as "don't know" / default variant */
451
+ }
301
452
  }
453
+
454
+ return { requestedPackages, hasAnyRegistryConfigured };
302
455
  }
303
456
 
304
457
  export enum DevServerAutoUpgradeMode {
@@ -314,6 +467,67 @@ export enum DevServerAutoUpgradeMode {
314
467
  SKIP_CLI_ONLY = "skip-cli-only",
315
468
  }
316
469
 
470
+ /**
471
+ * Seed `tokenManager` with the initial token the CLI received from auth.json
472
+ * (standalone dev) or `/_sb_activate` (SABS live-edit pod activation), so the
473
+ * `NpmRegistryClient`'s JWT source has a usable bearer credential on cold
474
+ * boot.
475
+ *
476
+ * Without this seeding, `TokenManager.updateToken` is only ever called by
477
+ * `AuthHotReloadServer` (`auth-hot-reload.mts`) — and that socket is
478
+ * explicitly disabled on live-edit pods (`sabs/entrypoint-local.sh` sets
479
+ * `SUPERBLOCKS_AUTH_HOT_RELOAD=false`), so `NpmRegistryClient.getConfig()`
480
+ * cannot authenticate its server fetch on cold boot. The result is
481
+ * `source: "unreachable"`, `syncHomeNpmrc` skips with `~/.npmrc` left
482
+ * untouched, and the CLI auto-upgrade that fires moments later resolves
483
+ * `npm install -g @superblocksteam/cli@…` through public npm instead of the
484
+ * customer's configured private registry.
485
+ *
486
+ * Call-site ordering is intentionally NOT load-bearing: `TokenManager`
487
+ * retains the current token as state and `AiService` reads
488
+ * `tokenManager.getCurrentToken()` synchronously at construction time, so
489
+ * the prime call works whether it runs before or after consumer
490
+ * construction. The `tokenUpdated` event stream stays as the refresh
491
+ * channel for future rotations (hot-reload pushes, JWT renewal), so this
492
+ * seed is purely additive.
493
+ */
494
+ export function primeTokenManagerWithInitialToken(
495
+ tokenManager: TokenManager,
496
+ token: string,
497
+ ): void {
498
+ if (token) {
499
+ tokenManager.updateToken(token);
500
+ }
501
+ }
502
+
503
+ export interface DevServerStatus {
504
+ serverErrors: ServerError[];
505
+ }
506
+
507
+ /** Decide how the startup catch handles an error: degrade (record, keep Vite up)
508
+ * for an app-install failure (the InitialInstallFailed marker), or exit for
509
+ * anything else (lock/sync/upgrade). Pure + unit-tested. Does NOT call process.exit. */
510
+ export function handleStartupError(
511
+ error: unknown,
512
+ status: DevServerStatus,
513
+ logger: Logger,
514
+ ): "degrade" | "exit" {
515
+ if (error instanceof InitialInstallFailed) {
516
+ status.serverErrors.push(error.serverError);
517
+ logger.error(
518
+ "[dev-server] initial dependency install failed; keeping dev server up",
519
+ getErrorMeta(error),
520
+ );
521
+ devServerMetrics.recordInitialInstallFailure({
522
+ category: error.serverError.category,
523
+ npmErrorCode: error.serverError.npmErrorCode,
524
+ hasAnyRegistryConfigured: error.serverError.hasAnyRegistryConfigured,
525
+ });
526
+ return "degrade";
527
+ }
528
+ return "exit";
529
+ }
530
+
317
531
  export async function dev(options: {
318
532
  /* cwd is required */
319
533
  cwd: string;
@@ -380,6 +594,15 @@ export async function dev(options: {
380
594
  sdk,
381
595
  } = options;
382
596
 
597
+ // Seed the tokenManager with the initial CLI token so downstream consumers
598
+ // (AiService, AutoConnectingRpcClient, syncHomeNpmrc) have a usable
599
+ // bearer credential without waiting on the AuthHotReloadServer push
600
+ // refresh (which is disabled on live-edit pods). Order-independent: the
601
+ // manager retains state and AiService reads it synchronously during
602
+ // construction. See `primeTokenManagerWithInitialToken` for the
603
+ // cold-boot rationale.
604
+ primeTokenManagerWithInitialToken(tokenManager, tokenConfig.token);
605
+
383
606
  // May be overridden by a pending snapshot restore
384
607
  let { downloadFirst, uploadFirst } = options;
385
608
 
@@ -391,17 +614,40 @@ export async function dev(options: {
391
614
  let snapshotManager: SnapshotManager | undefined;
392
615
  let gitUserName: string | undefined;
393
616
  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`.
617
+ // In-flight handles for the two background jobs we launch from sync.
618
+ // We keep them SEPARATE so an install rejection cannot swallow an upgrade
619
+ // rejection (or vice-versa) the way a single `Promise.all` would: that
620
+ // bundle settles on the FIRST rejection and silently absorbs the other's
621
+ // outcome, breaking APPS-4457's intent that auto-upgrade failures still
622
+ // exit (not degrade) and CLI-restart-on-upgrade still happens even if the
623
+ // app install fails.
624
+ //
625
+ // - packageUpgradePromise: library upgrades (and their `npm install`
626
+ // side-effects) from `checkVersionsAndWritePackageJson`. Rejection is
627
+ // OUT of scope for graceful degrade (APPS-4457) — `handleStartupError`
628
+ // routes it to `process.exit(1)`.
629
+ // - packageInstallPromise: the app's verification `npm install`.
630
+ // Rejection MAY be the `InitialInstallFailed` marker, which
631
+ // `handleStartupError` routes to the degrade path.
632
+ let packageUpgradePromise: Promise<void> | undefined;
397
633
  let packageInstallPromise: Promise<void> | undefined;
398
634
  const tracer = getTracer();
399
635
  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.
636
+ // Joins the in-flight upgrade. Rejection propagates so the caller's step
637
+ // can abort cleanly (handleStartupError exits auto-upgrade graceful
638
+ // degrade is OOS per APPS-4457).
639
+ const joinPackageUpgrade = async (reason: string): Promise<void> => {
640
+ if (!packageUpgradePromise) {
641
+ return;
642
+ }
643
+ logger.info(`Waiting for background package upgrade (${reason})…`);
644
+ const promise = packageUpgradePromise;
645
+ packageUpgradePromise = undefined;
646
+ await promise;
647
+ };
648
+ // Joins the in-flight install. Rejection propagates so the caller's step
649
+ // can abort cleanly (handleStartupError degrades for InitialInstallFailed,
650
+ // exits otherwise).
405
651
  const joinPackageInstall = async (reason: string): Promise<void> => {
406
652
  if (!packageInstallPromise) {
407
653
  return;
@@ -411,12 +657,36 @@ export async function dev(options: {
411
657
  packageInstallPromise = undefined;
412
658
  await promise;
413
659
  };
660
+ // Settles the in-flight install WITHOUT throwing. Used on the CLI-restart
661
+ // path so an install failure doesn't preempt the restart (the new CLI's
662
+ // first boot re-runs install). Still waits for npm to finish so we don't
663
+ // SIGKILL it mid-rename.
664
+ const settlePackageInstall = async (reason: string): Promise<void> => {
665
+ if (!packageInstallPromise) {
666
+ return;
667
+ }
668
+ logger.info(`Settling background package install (${reason})…`);
669
+ const promise = packageInstallPromise;
670
+ packageInstallPromise = undefined;
671
+ await Promise.allSettled([promise]);
672
+ };
673
+ // Joins upgrade THEN install. Upgrade failures surface first so
674
+ // handleStartupError exits (per APPS-4457) before an install rejection
675
+ // gets a chance to route to degrade.
676
+ const joinUpgradeThenInstall = async (reason: string): Promise<void> => {
677
+ await joinPackageUpgrade(reason);
678
+ await joinPackageInstall(reason);
679
+ };
414
680
  const skipAutoUpgrade = autoUpgradeMode === DevServerAutoUpgradeMode.SKIP;
415
681
  const skipCliUpgrade =
416
682
  skipAutoUpgrade ||
417
683
  autoUpgradeMode === DevServerAutoUpgradeMode.SKIP_CLI_ONLY;
418
684
 
419
685
  await tracer.startActiveSpan("devServerStartup", async (startupSpan) => {
686
+ // Mutable startup status surfaced to the browser via createDevServer's
687
+ // /_sb_connect + /_sb_status. The startup catch records an app-install
688
+ // failure here (degrade path) so Vite still starts and serves the error.
689
+ const devServerStatus: DevServerStatus = { serverErrors: [] };
420
690
  try {
421
691
  // Add check for node_modules
422
692
  if (!fs.existsSync(path.join(cwd, "node_modules"))) {
@@ -830,16 +1100,44 @@ export async function dev(options: {
830
1100
  logger.info("[dev-startup] Skipping download, already in sync");
831
1101
  }
832
1102
 
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.
1103
+ // Unconditional lockfile sanitation (APPS-4300): strip
1104
+ // cross-registry `resolved` URLs from any lockfile on disk
1105
+ // regardless of whether install runs next. The lockfile here is
1106
+ // whatever survived the DBFS path (downloadFirst overwrite,
1107
+ // prior boot, brownfield import); npm honors `resolved` verbatim
1108
+ // and would bypass the active registry. `integrity` is
1109
+ // preserved, so genuine cross-registry tarball drift surfaces as
1110
+ // EINTEGRITY. No-op when there's no lockfile or no `resolved`
1111
+ // entries.
841
1112
  await stripResolvedFromLockfile(cwd);
842
1113
 
1114
+ // Materialise `~/.superblocks/npmrc` from the server-fetched
1115
+ // per-org npm registry config BEFORE the global Superblocks CLI
1116
+ // auto-upgrade fires. With the file in place, the auto-upgrade's
1117
+ // `npm install -g @superblocksteam/cli@…` resolves through the
1118
+ // customer's private registry instead of `registry.npmjs.org`.
1119
+ await tracer.startActiveSpan("syncHomeNpmrc", async (span) => {
1120
+ try {
1121
+ if (!aiService) {
1122
+ logger.info(
1123
+ "[home-npmrc] skipped: AiService unavailable at startup",
1124
+ );
1125
+ return;
1126
+ }
1127
+ await syncHomeNpmrc({
1128
+ npmRegistryClient: aiService.getNpmRegistryClient(),
1129
+ logger,
1130
+ });
1131
+ } catch (error) {
1132
+ logger.warn(
1133
+ "[home-npmrc] sync step failed unexpectedly; ~/.superblocks/npmrc left untouched",
1134
+ getErrorMeta(error),
1135
+ );
1136
+ } finally {
1137
+ span.end();
1138
+ }
1139
+ });
1140
+
843
1141
  let hasCliUpdated = false;
844
1142
  let upgradePromises: Promise<void>[] = [];
845
1143
  const forceUpgrade =
@@ -871,6 +1169,9 @@ export async function dev(options: {
871
1169
  applicationConfigWithTokenConfigAndUserInfo,
872
1170
  forceUpgrade,
873
1171
  skipCliUpgrade,
1172
+ // Route the CLI-upgrade install's npm debug log into the
1173
+ // same `<app>/.superblocks/logs` as the startup install.
1174
+ cwd,
874
1175
  );
875
1176
  hasCliUpdated = result.cliUpdated;
876
1177
  upgradePromises = result.upgradePromises;
@@ -948,6 +1249,40 @@ export async function dev(options: {
948
1249
  ),
949
1250
  });
950
1251
 
1252
+ const installApplicationId = applicationConfig.id;
1253
+
1254
+ // Launch upgrades and app install as INDEPENDENT promises (not a
1255
+ // single `Promise.all`) so each outcome is observed on its own
1256
+ // join: upgrade failure exits (APPS-4457), install failure may
1257
+ // degrade (InitialInstallFailed). A bundled `Promise.all` would
1258
+ // settle on the first rejection and silently absorb the other's
1259
+ // result. The `.catch` backstops convert "no join fired" cases
1260
+ // into logged-then-handled rejections instead of unhandled ones.
1261
+ if (upgradePromises.length > 0) {
1262
+ logger.info("Starting package upgrade in background…");
1263
+ const launchedUpgradeCount = upgradePromises.length;
1264
+ packageUpgradePromise = tracer.startActiveSpan(
1265
+ "packageUpgrades",
1266
+ async (span) => {
1267
+ try {
1268
+ await Promise.all(upgradePromises);
1269
+ } finally {
1270
+ span.end();
1271
+ }
1272
+ },
1273
+ );
1274
+ packageUpgradePromise.catch((err) => {
1275
+ logger.error(
1276
+ `Background package upgrade failed [errorId=DEV_SERVER_BG_UPGRADE_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${launchedUpgradeCount}]`,
1277
+ getErrorMeta(err),
1278
+ );
1279
+ });
1280
+ }
1281
+
1282
+ // Run the verification install when EITHER the package.json
1283
+ // requires it, or upgrades just modified package.json/lockfile
1284
+ // (re-syncing node_modules), or the caller forced it. Mirrors
1285
+ // the original launch condition.
951
1286
  if (
952
1287
  packageJsonRequiresInstall ||
953
1288
  upgradePromises.length > 0 ||
@@ -964,29 +1299,23 @@ export async function dev(options: {
964
1299
  "installPackages",
965
1300
  async (span) => {
966
1301
  try {
967
- // Upgrade global CLI and local packages in parallel - improves performance
968
- await Promise.all([
969
- ...upgradePromises,
970
- installPackages(cwd, logger),
971
- ]);
1302
+ await installPackages(
1303
+ cwd,
1304
+ logger,
1305
+ aiService?.getNpmRegistryClient(),
1306
+ );
972
1307
  } finally {
973
1308
  span.end();
974
1309
  }
975
1310
  },
976
1311
  );
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
1312
  packageInstallPromise.catch((err) => {
984
1313
  logger.error(
985
1314
  // errorId is encoded into the message body because the
986
1315
  // logger.error contract limits structured attributes to
987
1316
  // `{ error: { kind, message, stack } }`. The id stays
988
1317
  // grep-able for Datadog/Sentry alert rules.
989
- `Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${installUpgradeCount}]`,
1318
+ `Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd}]`,
990
1319
  getErrorMeta(err),
991
1320
  );
992
1321
  });
@@ -1000,25 +1329,57 @@ export async function dev(options: {
1000
1329
  hasPackageChanged || forcePackageInstall;
1001
1330
  if (shouldUploadPackageState || uploadFirst) {
1002
1331
  // 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
- );
1332
+ // tree to DBFS, so it must observe quiesced upgrade+install.
1333
+ // Upgrade first — its rejection exits before an install
1334
+ // rejection can take the degrade path (APPS-4457).
1335
+ //
1336
+ // BUT: if a successful CLI upgrade is pending restart
1337
+ // (`hasCliUpdated`), the restart branch below MUST win over
1338
+ // the install-failure degrade path. Otherwise the outer
1339
+ // sync/setup catch routes `InitialInstallFailed` to "degrade"
1340
+ // and skips the restart entirely — masking a successful CLI
1341
+ // upgrade. Catch ONLY the join (not the upload body) so a
1342
+ // post-join upload failure still propagates normally.
1343
+ let skipStartupUploadForCliRestart = false;
1344
+ try {
1345
+ await joinUpgradeThenInstall("before upload");
1346
+ } catch (joinError) {
1347
+ if (
1348
+ hasCliUpdated &&
1349
+ joinError instanceof InitialInstallFailed
1350
+ ) {
1351
+ logger.info(
1352
+ "Initial package install failed before startup upload, but CLI was updated; skipping upload and letting the restart proceed",
1353
+ );
1354
+ skipStartupUploadForCliRestart = true;
1355
+ } else {
1356
+ throw joinError;
1357
+ }
1358
+ }
1359
+ if (!skipStartupUploadForCliRestart) {
1360
+ logger.info(
1361
+ `Uploading local files to branch '${activeDbfsBranchName}' on server before starting`,
1362
+ );
1363
+ await tracer.startActiveSpan(
1364
+ "uploadFirstOrPackageChanged",
1365
+ async (span) => {
1366
+ await syncService!.uploadDirectory("cli:sdk");
1367
+ await syncService!.uploadDirectoryNowIfNeeded("cli:sdk");
1368
+ span.end();
1369
+ },
1370
+ );
1371
+ }
1016
1372
  }
1017
1373
 
1018
1374
  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");
1375
+ // Restart must NOT be preempted by an app-install failure:
1376
+ // the next boot re-runs install with the new CLI. Join the
1377
+ // upgrade (it must succeed or we exit before restarting) and
1378
+ // SETTLE the install (don't observe its rejection — but wait
1379
+ // for npm to finish so the restart doesn't SIGKILL it
1380
+ // mid-rename and leave a half-written lockfile).
1381
+ await joinPackageUpgrade("before CLI restart");
1382
+ await settlePackageInstall("before CLI restart");
1022
1383
  try {
1023
1384
  logger.info("Releasing lock before restarting the dev server");
1024
1385
  await aiService?.removeIntegrationCache();
@@ -1034,16 +1395,23 @@ export async function dev(options: {
1034
1395
  }
1035
1396
  });
1036
1397
  } 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);
1398
+ if (
1399
+ handleStartupError(error, devServerStatus, logger) === "degrade"
1400
+ ) {
1401
+ // app-install failure: do NOT exit — fall through to Vite startup below.
1402
+ // upload + CLI-restart were already skipped (the rejecting join threw first).
1403
+ } else {
1404
+ logger.error(
1405
+ "[dev-server] Startup failed during sync/lock/setup (exiting with code 1)",
1406
+ getErrorMeta(error),
1407
+ );
1408
+ try {
1409
+ await aiService?.removeIntegrationCache();
1410
+ await lockService?.shutdownAndExit();
1411
+ } finally {
1412
+ // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
1413
+ process.exit(1);
1414
+ }
1047
1415
  }
1048
1416
  }
1049
1417
  } else {
@@ -1057,7 +1425,37 @@ export async function dev(options: {
1057
1425
  // cache embeds partial state that survives across reloads. Awaiting
1058
1426
  // here costs at most the install's remaining wall time — the upload
1059
1427
  // and CLI-restart joins above usually drain it first.
1060
- await joinPackageInstall("before Vite startup");
1428
+ // The "before upload" / "before CLI restart" joins above run inside the
1429
+ // sync/lock catch that degrades on `InitialInstallFailed`. But when the
1430
+ // install ran yet none of those joins fired (e.g.
1431
+ // `packageJsonRequiresInstall && !forcePackageInstall && !upload &&
1432
+ // !hasCliUpdated`), the install rejection first surfaces HERE — outside
1433
+ // that catch. Route it through the SAME origin gate so an app-install
1434
+ // failure still degrades (keep Vite up + record the error) instead of
1435
+ // escaping `dev()` to `process.exit(1)`. Non-install errors (including
1436
+ // any background upgrade rejection that escaped the prior joins) take
1437
+ // the same explicit exit path as the sync/lock catch above so the
1438
+ // process actually terminates (the startupSpan catch only rethrows,
1439
+ // and an unhandled rejection at that depth doesn't shut the lock
1440
+ // service down or guarantee `process.exit(1)`).
1441
+ try {
1442
+ await joinUpgradeThenInstall("before Vite startup");
1443
+ } catch (error) {
1444
+ if (handleStartupError(error, devServerStatus, logger) === "exit") {
1445
+ logger.error(
1446
+ "[dev-server] Startup failed during pre-Vite install join (exiting with code 1)",
1447
+ getErrorMeta(error),
1448
+ );
1449
+ try {
1450
+ await aiService?.removeIntegrationCache();
1451
+ await lockService?.shutdownAndExit();
1452
+ } finally {
1453
+ // Redundant with `shutdownAndExit`; here so a thrown shutdown
1454
+ // path can't leave the process hanging on a stuck handle.
1455
+ process.exit(1);
1456
+ }
1457
+ }
1458
+ }
1061
1459
 
1062
1460
  const activateRuntimeGitService = async (): Promise<
1063
1461
  GitService | undefined
@@ -1158,27 +1556,26 @@ export async function dev(options: {
1158
1556
  const httpServer = await tracer.startActiveSpan(
1159
1557
  "createDevServer",
1160
1558
  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];
1559
+ const createDevServerOptions: Parameters<typeof createDevServer>[0] =
1560
+ {
1561
+ root: options.cwd,
1562
+ mode: "development",
1563
+ port: port,
1564
+ fsOperationQueue,
1565
+ syncService: syncService,
1566
+ lockService: lockService,
1567
+ aiService: aiService,
1568
+ gitService: gitService,
1569
+ gitBootstrapFailed: devStartupGitOutcome === "bootstrap_failed",
1570
+ activateGitService: activateRuntimeGitService,
1571
+ snapshotManager: snapshotManager,
1572
+ logger: options.logger,
1573
+ sdk,
1574
+ superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
1575
+ existingServer: options.existingServer,
1576
+ warmActivationStart: options.warmActivationStart,
1577
+ devServerStatus,
1578
+ };
1182
1579
  const result = await createDevServer(createDevServerOptions);
1183
1580
  span.end();
1184
1581
  return result;
@@ -1194,13 +1591,19 @@ export async function dev(options: {
1194
1591
  logger.warn(`Error stopping auth hot-reload server: ${error}`);
1195
1592
  });
1196
1593
  }
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
1594
+ // Drain any straggler upgrade/install before tear-down. By this
1595
+ // point the pre-Vite join has already run on the success path, so
1596
+ // usually both promises are undefined and this is a no-op; if abort
1200
1597
  // 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.
1598
+ // some error path), draining here keeps the spawned npm children
1599
+ // from being SIGKILL'd mid-rename. Errors only get logged — we are
1600
+ // tearing down anyway.
1601
+ joinPackageUpgrade("during abort").catch((error: unknown) => {
1602
+ logger.warn(
1603
+ "Error draining background upgrade during abort",
1604
+ getErrorMeta(error),
1605
+ );
1606
+ });
1204
1607
  joinPackageInstall("during abort").catch((error: unknown) => {
1205
1608
  logger.warn(
1206
1609
  "Error draining background install during abort",