@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
@@ -13,19 +13,22 @@ import { maskUnixSignals } from "@superblocksteam/util";
13
13
  import { AiService, AiServiceFeatureFlags, SnapshotManager, isSdkApiTemplate, stripResolvedFromLockfile, } from "@superblocksteam/vite-plugin-file-sync/ai-service";
14
14
  import { createGitService } from "@superblocksteam/vite-plugin-file-sync/git-service";
15
15
  import { LockService, LockType, } from "@superblocksteam/vite-plugin-file-sync/lock-service";
16
+ import { shouldIgnoreInstallScripts, } from "@superblocksteam/vite-plugin-file-sync/npm-registry";
16
17
  import { OperationQueue } from "@superblocksteam/vite-plugin-file-sync/operation-queue";
17
18
  import { AutoConnectingRpcClient } from "@superblocksteam/vite-plugin-file-sync/server-rpc";
18
19
  import { SyncService } from "@superblocksteam/vite-plugin-file-sync/sync-service";
20
+ import { devServerMetrics } from "../dev-utils/dev-server-metrics.mjs";
19
21
  import { createDevServer } from "../dev-utils/dev-server.mjs";
20
22
  import { AUTO_UPGRADE_EXIT_CODE } from "../index.js";
21
23
  import { getTracer } from "../telemetry/index.js";
22
24
  import { getErrorMeta, getLogger } from "../telemetry/logging.js";
23
- import { checkVersionsAndWritePackageJson } from "./automatic-upgrades.js";
25
+ import { buildInstallEnv, checkVersionsAndWritePackageJson, } from "./automatic-upgrades.js";
26
+ import { classifyInitialInstallError, InitialInstallFailed, } from "./dependency-install-classifier.mjs";
24
27
  import { ensureRemoteHasDefaultBranch, getGitErrorFields, } from "./git-repo-setup.mjs";
28
+ import { superblocksLogsPath, superblocksNpmrcPath, syncHomeNpmrc, } from "./home-npmrc.mjs";
25
29
  import { normalizeWorkspaceProtocolForNpm } from "./normalize-workspace-protocol.js";
26
30
  import { didPackageJsonSnapshotChange, packageJsonSnapshot, packageJsonSnapshotDiagnostic, restoreManagedPackageDependencies, } from "./package-json-snapshot.mjs";
27
31
  import { getCurrentCliVersion } from "./version-detection.js";
28
- const exec = promisify(child_process.exec);
29
32
  const passErrorToVSCode = (message, logger) => {
30
33
  if (message && process.env.SUPERBLOCKS_VSCODE === "true") {
31
34
  // Prefixing with `clierr:` will make the VS code extension capture this message and show it to the user.
@@ -160,7 +163,7 @@ async function normalizePackageJsonForNpm(cwd, logger) {
160
163
  throw new Error(`Failed to write normalized package.json at ${packageJsonPath}: ${err instanceof Error ? err.message : String(err)}`);
161
164
  }
162
165
  }
163
- async function installPackages(cwd, logger) {
166
+ export async function installPackages(cwd, logger, npmRegistryClient) {
164
167
  try {
165
168
  const pm = await detect({
166
169
  strategies: [
@@ -181,23 +184,144 @@ async function installPackages(cwd, logger) {
181
184
  // resolve a published version instead.
182
185
  await normalizePackageJsonForNpm(cwd, logger);
183
186
  }
184
- const installCommand = resolveCommand(pm.agent, "install", pm.agent === "npm" ? ["--fund=false", "--audit=false"] : []);
187
+ // Honor the per-org `allow_install_scripts` policy on the dev-server
188
+ // startup install just like AppShell does for agent-driven installs
189
+ // (see `shell.ts`). When the org has opted out (`allowInstallScripts ===
190
+ // false`), append `--ignore-scripts` so lifecycle scripts don't run
191
+ // during dev-server boot. `undefined` (LD flag off, server omitted the
192
+ // field, no client wired up, or the resolution itself failed) keeps
193
+ // today's behaviour. The client owns last-known-good / cross-outage
194
+ // policy preservation; we deliberately swallow resolution errors here
195
+ // so a transient registry-endpoint outage doesn't abort the user's
196
+ // startup install — the policy default is "scripts allowed" anyway.
197
+ let ignoreScripts = false;
198
+ if (npmRegistryClient) {
199
+ try {
200
+ const result = await npmRegistryClient.getConfig();
201
+ ignoreScripts = shouldIgnoreInstallScripts(result);
202
+ }
203
+ catch (err) {
204
+ logger.warn("Could not resolve npm install-scripts policy; proceeding without --ignore-scripts", getErrorMeta(err));
205
+ }
206
+ }
207
+ // `--json` makes npm channel the structured error envelope (code +
208
+ // summary + detail) onto stdout so a failure can be classified into a
209
+ // DependencyInstallError. Output-only: it does not change resolution,
210
+ // only how npm reports it. The success-path `logger.info(stdout)` below
211
+ // then logs JSON, which is acceptable.
212
+ const baseArgs = pm.agent === "npm" ? ["--fund=false", "--audit=false", "--json"] : [];
213
+ const installArgs = ignoreScripts
214
+ ? [...baseArgs, "--ignore-scripts"]
215
+ : baseArgs;
216
+ const installCommand = resolveCommand(pm.agent, "install", installArgs);
185
217
  if (!installCommand) {
186
218
  logger.warn(`Could not determine install command for ${pm.agent}, skipping package installation`);
187
219
  return;
188
220
  }
189
221
  const { command, args } = installCommand;
190
222
  logger.info(`Running ${command} ${args.join(" ")} to install dependencies…`);
223
+ // Pin both npm and pnpm to the Superblocks-owned userconfig via env
224
+ // overlay. `buildInstallEnv` (from `automatic-upgrades.ts`) sets
225
+ // both `NPM_CONFIG_USERCONFIG` (npm, pnpm <= 10) and
226
+ // `PNPM_CONFIG_USERCONFIG` (pnpm 11+) — pnpm 11 stopped honouring
227
+ // the `npm_config_*` env vars. CLI flags differ between agents
228
+ // (`--userconfig=` for npm, `--config.userconfig=` for pnpm), so
229
+ // the env overlay is the uniform mechanism. Without this, customer
230
+ // pods using a private registry would read default `~/.npmrc`
231
+ // (empty after the relocation) and fail to resolve private
232
+ // packages. Centralised in `buildInstallEnv` so this and the CLI
233
+ // auto-upgrade install stay in sync.
234
+ // Ensure the per-app log dir exists so npm's debug log (routed via
235
+ // `NPM_CONFIG_LOGS_DIR` in `buildInstallEnv`) lands in a predictable
236
+ // place. npm would create it on demand, but doing it up front means the
237
+ // folder exists even for pnpm runs (which ignore the var) and for any
238
+ // log-collection sidecar watching `<app>/.superblocks/logs`.
239
+ const logsDir = superblocksLogsPath(cwd);
240
+ await nodeFs.mkdir(logsDir, { recursive: true }).catch(() => undefined);
241
+ // Resolve the promisified `exec` at CALL time — not module scope — so a
242
+ // test's `vi.mock("node:child_process")` is always honoured regardless of
243
+ // when `dev.mjs` was first evaluated. A module-scope capture binds to
244
+ // whichever `child_process.exec` was live at first import; if a sibling
245
+ // test imports this module before installing its own mock (e.g.
246
+ // `dev-token-priming` / `dev.interception`, which don't mock
247
+ // `child_process`), that binding is the REAL npm and the classify test's
248
+ // mock silently never takes effect — the exact APPS-4450 CI failure
249
+ // (`category: "unknown"`, `npmErrorCode: undefined` from real npm against a
250
+ // missing `/tmp/app`).
251
+ const exec = promisify(child_process.exec);
191
252
  const { stdout } = await exec(`${command} ${args.join(" ")}`, {
192
253
  cwd,
254
+ env: buildInstallEnv(superblocksNpmrcPath(), logsDir),
193
255
  });
194
256
  logger.info("Package installation completed successfully");
195
257
  logger.info(stdout);
196
258
  }
197
259
  catch (error) {
198
260
  logger.error("Error during package installation", getErrorMeta(error));
199
- throw error;
261
+ // `util.promisify(child_process.exec)` preserves `.stdout`/`.stderr` on
262
+ // the rejected error separately; with `--json`, the structured npm error
263
+ // envelope lands on `.stdout`. Classify it into a DependencyInstallError
264
+ // and throw the `InitialInstallFailed` marker so the `dev()` catch can
265
+ // degrade by origin rather than crash-looping the dev-server pod.
266
+ const ctx = await buildInstallParseContext(cwd, npmRegistryClient);
267
+ const f = error;
268
+ const serverError = classifyInitialInstallError({ stdout: f.stdout, stderr: f.stderr, message: f.message }, ctx);
269
+ throw new InitialInstallFailed(serverError);
270
+ }
271
+ }
272
+ /**
273
+ * Build the `ParseContext` the dependency-install classifier needs from the
274
+ * failing install's working directory + registry client:
275
+ *
276
+ * - `requestedPackages` — the union of `dependencies` + `devDependencies`
277
+ * in the app's `package.json`, used by the registry-blocked renderers to
278
+ * name the failing specs when npm's `--json` `detail` doesn't.
279
+ * - `hasAnyRegistryConfigured` — the tri-state derived from the registry
280
+ * client's most recent `getConfig()`, mirroring AppShell's
281
+ * `deriveHasAnyRegistryConfigured` (`shell.ts`): `configured`/`stale`
282
+ * → `true` (rows known to exist), `not-configured` → `false` (deliberate
283
+ * "no rows"), `unreachable` → `undefined` ("we don't know" — the renderer
284
+ * falls back to the default variant). Left `undefined` when no client is
285
+ * wired in or the resolution itself throws.
286
+ *
287
+ * Best-effort throughout: a missing/unparseable package.json or a registry
288
+ * outage must not mask the underlying install failure, so both lookups are
289
+ * caught and degrade to empty/undefined.
290
+ */
291
+ async function buildInstallParseContext(cwd, npmRegistryClient) {
292
+ let requestedPackages = [];
293
+ try {
294
+ const pkg = JSON.parse(await nodeFs.readFile(path.join(cwd, "package.json"), "utf8"));
295
+ requestedPackages = Object.entries({
296
+ ...(pkg.dependencies ?? {}),
297
+ ...(pkg.devDependencies ?? {}),
298
+ }).map(([name, version]) => ({ name, version: String(version) }));
299
+ }
300
+ catch {
301
+ /* best-effort: a missing/unparseable package.json yields no specs */
200
302
  }
303
+ let hasAnyRegistryConfigured;
304
+ if (npmRegistryClient) {
305
+ try {
306
+ const result = await npmRegistryClient.getConfig();
307
+ switch (result.source) {
308
+ case "configured":
309
+ case "stale":
310
+ hasAnyRegistryConfigured = true;
311
+ break;
312
+ case "not-configured":
313
+ hasAnyRegistryConfigured = false;
314
+ break;
315
+ case "unreachable":
316
+ hasAnyRegistryConfigured = undefined;
317
+ break;
318
+ }
319
+ }
320
+ catch {
321
+ /* leave undefined → renderers treat as "don't know" / default variant */
322
+ }
323
+ }
324
+ return { requestedPackages, hasAnyRegistryConfigured };
201
325
  }
202
326
  export var DevServerAutoUpgradeMode;
203
327
  (function (DevServerAutoUpgradeMode) {
@@ -212,8 +336,61 @@ export var DevServerAutoUpgradeMode;
212
336
  */
213
337
  DevServerAutoUpgradeMode["SKIP_CLI_ONLY"] = "skip-cli-only";
214
338
  })(DevServerAutoUpgradeMode || (DevServerAutoUpgradeMode = {}));
339
+ /**
340
+ * Seed `tokenManager` with the initial token the CLI received from auth.json
341
+ * (standalone dev) or `/_sb_activate` (SABS live-edit pod activation), so the
342
+ * `NpmRegistryClient`'s JWT source has a usable bearer credential on cold
343
+ * boot.
344
+ *
345
+ * Without this seeding, `TokenManager.updateToken` is only ever called by
346
+ * `AuthHotReloadServer` (`auth-hot-reload.mts`) — and that socket is
347
+ * explicitly disabled on live-edit pods (`sabs/entrypoint-local.sh` sets
348
+ * `SUPERBLOCKS_AUTH_HOT_RELOAD=false`), so `NpmRegistryClient.getConfig()`
349
+ * cannot authenticate its server fetch on cold boot. The result is
350
+ * `source: "unreachable"`, `syncHomeNpmrc` skips with `~/.npmrc` left
351
+ * untouched, and the CLI auto-upgrade that fires moments later resolves
352
+ * `npm install -g @superblocksteam/cli@…` through public npm instead of the
353
+ * customer's configured private registry.
354
+ *
355
+ * Call-site ordering is intentionally NOT load-bearing: `TokenManager`
356
+ * retains the current token as state and `AiService` reads
357
+ * `tokenManager.getCurrentToken()` synchronously at construction time, so
358
+ * the prime call works whether it runs before or after consumer
359
+ * construction. The `tokenUpdated` event stream stays as the refresh
360
+ * channel for future rotations (hot-reload pushes, JWT renewal), so this
361
+ * seed is purely additive.
362
+ */
363
+ export function primeTokenManagerWithInitialToken(tokenManager, token) {
364
+ if (token) {
365
+ tokenManager.updateToken(token);
366
+ }
367
+ }
368
+ /** Decide how the startup catch handles an error: degrade (record, keep Vite up)
369
+ * for an app-install failure (the InitialInstallFailed marker), or exit for
370
+ * anything else (lock/sync/upgrade). Pure + unit-tested. Does NOT call process.exit. */
371
+ export function handleStartupError(error, status, logger) {
372
+ if (error instanceof InitialInstallFailed) {
373
+ status.serverErrors.push(error.serverError);
374
+ logger.error("[dev-server] initial dependency install failed; keeping dev server up", getErrorMeta(error));
375
+ devServerMetrics.recordInitialInstallFailure({
376
+ category: error.serverError.category,
377
+ npmErrorCode: error.serverError.npmErrorCode,
378
+ hasAnyRegistryConfigured: error.serverError.hasAnyRegistryConfigured,
379
+ });
380
+ return "degrade";
381
+ }
382
+ return "exit";
383
+ }
215
384
  export async function dev(options) {
216
385
  const { cwd, tokenConfig, devServerPort, skipSync, applicationConfig, autoUpgradeMode, tokenManager, authHotReloadServer, sdk, } = options;
386
+ // Seed the tokenManager with the initial CLI token so downstream consumers
387
+ // (AiService, AutoConnectingRpcClient, syncHomeNpmrc) have a usable
388
+ // bearer credential without waiting on the AuthHotReloadServer push
389
+ // refresh (which is disabled on live-edit pods). Order-independent: the
390
+ // manager retains state and AiService reads it synchronously during
391
+ // construction. See `primeTokenManagerWithInitialToken` for the
392
+ // cold-boot rationale.
393
+ primeTokenManagerWithInitialToken(tokenManager, tokenConfig.token);
217
394
  // May be overridden by a pending snapshot restore
218
395
  let { downloadFirst, uploadFirst } = options;
219
396
  // Services that will be created
@@ -224,17 +401,40 @@ export async function dev(options) {
224
401
  let snapshotManager;
225
402
  let gitUserName;
226
403
  let gitUserEmail;
227
- // In-flight install handle. We launch the install while the upload/restart
228
- // decisions are still being evaluated and join at every gate that depends
229
- // on post-install state. See the install block and `joinPackageInstall`.
404
+ // In-flight handles for the two background jobs we launch from sync.
405
+ // We keep them SEPARATE so an install rejection cannot swallow an upgrade
406
+ // rejection (or vice-versa) the way a single `Promise.all` would: that
407
+ // bundle settles on the FIRST rejection and silently absorbs the other's
408
+ // outcome, breaking APPS-4457's intent that auto-upgrade failures still
409
+ // exit (not degrade) and CLI-restart-on-upgrade still happens even if the
410
+ // app install fails.
411
+ //
412
+ // - packageUpgradePromise: library upgrades (and their `npm install`
413
+ // side-effects) from `checkVersionsAndWritePackageJson`. Rejection is
414
+ // OUT of scope for graceful degrade (APPS-4457) — `handleStartupError`
415
+ // routes it to `process.exit(1)`.
416
+ // - packageInstallPromise: the app's verification `npm install`.
417
+ // Rejection MAY be the `InitialInstallFailed` marker, which
418
+ // `handleStartupError` routes to the degrade path.
419
+ let packageUpgradePromise;
230
420
  let packageInstallPromise;
231
421
  const tracer = getTracer();
232
422
  const logger = getLogger(options.logger);
233
- // Joins the in-flight install at any step that depends on a settled
234
- // node_modules / lockfile state. Clears the handle so later joins are
235
- // no-ops. The rejection (if any) propagates so the caller's step can
236
- // abort cleanly. Defined at outer scope so the post-sync `createDevServer`
237
- // gate and the abort handler can call it too.
423
+ // Joins the in-flight upgrade. Rejection propagates so the caller's step
424
+ // can abort cleanly (handleStartupError exits auto-upgrade graceful
425
+ // degrade is OOS per APPS-4457).
426
+ const joinPackageUpgrade = async (reason) => {
427
+ if (!packageUpgradePromise) {
428
+ return;
429
+ }
430
+ logger.info(`Waiting for background package upgrade (${reason})…`);
431
+ const promise = packageUpgradePromise;
432
+ packageUpgradePromise = undefined;
433
+ await promise;
434
+ };
435
+ // Joins the in-flight install. Rejection propagates so the caller's step
436
+ // can abort cleanly (handleStartupError degrades for InitialInstallFailed,
437
+ // exits otherwise).
238
438
  const joinPackageInstall = async (reason) => {
239
439
  if (!packageInstallPromise) {
240
440
  return;
@@ -244,10 +444,34 @@ export async function dev(options) {
244
444
  packageInstallPromise = undefined;
245
445
  await promise;
246
446
  };
447
+ // Settles the in-flight install WITHOUT throwing. Used on the CLI-restart
448
+ // path so an install failure doesn't preempt the restart (the new CLI's
449
+ // first boot re-runs install). Still waits for npm to finish so we don't
450
+ // SIGKILL it mid-rename.
451
+ const settlePackageInstall = async (reason) => {
452
+ if (!packageInstallPromise) {
453
+ return;
454
+ }
455
+ logger.info(`Settling background package install (${reason})…`);
456
+ const promise = packageInstallPromise;
457
+ packageInstallPromise = undefined;
458
+ await Promise.allSettled([promise]);
459
+ };
460
+ // Joins upgrade THEN install. Upgrade failures surface first so
461
+ // handleStartupError exits (per APPS-4457) before an install rejection
462
+ // gets a chance to route to degrade.
463
+ const joinUpgradeThenInstall = async (reason) => {
464
+ await joinPackageUpgrade(reason);
465
+ await joinPackageInstall(reason);
466
+ };
247
467
  const skipAutoUpgrade = autoUpgradeMode === DevServerAutoUpgradeMode.SKIP;
248
468
  const skipCliUpgrade = skipAutoUpgrade ||
249
469
  autoUpgradeMode === DevServerAutoUpgradeMode.SKIP_CLI_ONLY;
250
470
  await tracer.startActiveSpan("devServerStartup", async (startupSpan) => {
471
+ // Mutable startup status surfaced to the browser via createDevServer's
472
+ // /_sb_connect + /_sb_status. The startup catch records an app-install
473
+ // failure here (degrade path) so Vite still starts and serves the error.
474
+ const devServerStatus = { serverErrors: [] };
251
475
  try {
252
476
  // Add check for node_modules
253
477
  if (!fs.existsSync(path.join(cwd, "node_modules"))) {
@@ -536,15 +760,39 @@ export async function dev(options) {
536
760
  else if (downloadFirst && isSynced) {
537
761
  logger.info("[dev-startup] Skipping download, already in sync");
538
762
  }
539
- // Unconditional lockfile sanitation: strip cross-registry
540
- // `resolved` URLs from any lockfile on disk regardless of whether
541
- // install runs next. The lockfile here is whatever survived the
542
- // DBFS path (downloadFirst overwrite, prior boot, brownfield
543
- // import); npm honors `resolved` verbatim and would bypass the
544
- // active registry. `integrity` is preserved, so genuine
545
- // cross-registry tarball drift surfaces as EINTEGRITY. No-op when
546
- // there's no lockfile or no `resolved` entries.
763
+ // Unconditional lockfile sanitation (APPS-4300): strip
764
+ // cross-registry `resolved` URLs from any lockfile on disk
765
+ // regardless of whether install runs next. The lockfile here is
766
+ // whatever survived the DBFS path (downloadFirst overwrite,
767
+ // prior boot, brownfield import); npm honors `resolved` verbatim
768
+ // and would bypass the active registry. `integrity` is
769
+ // preserved, so genuine cross-registry tarball drift surfaces as
770
+ // EINTEGRITY. No-op when there's no lockfile or no `resolved`
771
+ // entries.
547
772
  await stripResolvedFromLockfile(cwd);
773
+ // Materialise `~/.superblocks/npmrc` from the server-fetched
774
+ // per-org npm registry config BEFORE the global Superblocks CLI
775
+ // auto-upgrade fires. With the file in place, the auto-upgrade's
776
+ // `npm install -g @superblocksteam/cli@…` resolves through the
777
+ // customer's private registry instead of `registry.npmjs.org`.
778
+ await tracer.startActiveSpan("syncHomeNpmrc", async (span) => {
779
+ try {
780
+ if (!aiService) {
781
+ logger.info("[home-npmrc] skipped: AiService unavailable at startup");
782
+ return;
783
+ }
784
+ await syncHomeNpmrc({
785
+ npmRegistryClient: aiService.getNpmRegistryClient(),
786
+ logger,
787
+ });
788
+ }
789
+ catch (error) {
790
+ logger.warn("[home-npmrc] sync step failed unexpectedly; ~/.superblocks/npmrc left untouched", getErrorMeta(error));
791
+ }
792
+ finally {
793
+ span.end();
794
+ }
795
+ });
548
796
  let hasCliUpdated = false;
549
797
  let upgradePromises = [];
550
798
  const forceUpgrade = options.autoUpgradeMode === DevServerAutoUpgradeMode.FORCE;
@@ -565,7 +813,10 @@ export async function dev(options) {
565
813
  organizationId: currentUser.user.currentOrganizationId,
566
814
  featureFlags: sdk.getFeatureFlagsForUser(currentUser),
567
815
  };
568
- const result = await checkVersionsAndWritePackageJson(lockService, applicationConfigWithTokenConfigAndUserInfo, forceUpgrade, skipCliUpgrade);
816
+ const result = await checkVersionsAndWritePackageJson(lockService, applicationConfigWithTokenConfigAndUserInfo, forceUpgrade, skipCliUpgrade,
817
+ // Route the CLI-upgrade install's npm debug log into the
818
+ // same `<app>/.superblocks/logs` as the startup install.
819
+ cwd);
569
820
  hasCliUpdated = result.cliUpdated;
570
821
  upgradePromises = result.upgradePromises;
571
822
  }
@@ -622,6 +873,33 @@ export async function dev(options) {
622
873
  packageJsonInstallBaselineSnapshot: packageJsonSnapshotDiagnostic(packageJsonInstallBaselineSnapshot),
623
874
  packageJsonSnapshotAfter: packageJsonSnapshotDiagnostic(packageJsonSnapshotAfter),
624
875
  });
876
+ const installApplicationId = applicationConfig.id;
877
+ // Launch upgrades and app install as INDEPENDENT promises (not a
878
+ // single `Promise.all`) so each outcome is observed on its own
879
+ // join: upgrade failure exits (APPS-4457), install failure may
880
+ // degrade (InitialInstallFailed). A bundled `Promise.all` would
881
+ // settle on the first rejection and silently absorb the other's
882
+ // result. The `.catch` backstops convert "no join fired" cases
883
+ // into logged-then-handled rejections instead of unhandled ones.
884
+ if (upgradePromises.length > 0) {
885
+ logger.info("Starting package upgrade in background…");
886
+ const launchedUpgradeCount = upgradePromises.length;
887
+ packageUpgradePromise = tracer.startActiveSpan("packageUpgrades", async (span) => {
888
+ try {
889
+ await Promise.all(upgradePromises);
890
+ }
891
+ finally {
892
+ span.end();
893
+ }
894
+ });
895
+ packageUpgradePromise.catch((err) => {
896
+ logger.error(`Background package upgrade failed [errorId=DEV_SERVER_BG_UPGRADE_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${launchedUpgradeCount}]`, getErrorMeta(err));
897
+ });
898
+ }
899
+ // Run the verification install when EITHER the package.json
900
+ // requires it, or upgrades just modified package.json/lockfile
901
+ // (re-syncing node_modules), or the caller forced it. Mirrors
902
+ // the original launch condition.
625
903
  if (packageJsonRequiresInstall ||
626
904
  upgradePromises.length > 0 ||
627
905
  forcePackageInstall) {
@@ -634,29 +912,19 @@ export async function dev(options) {
634
912
  logger.info("Starting package install in background…");
635
913
  packageInstallPromise = tracer.startActiveSpan("installPackages", async (span) => {
636
914
  try {
637
- // Upgrade global CLI and local packages in parallel - improves performance
638
- await Promise.all([
639
- ...upgradePromises,
640
- installPackages(cwd, logger),
641
- ]);
915
+ await installPackages(cwd, logger, aiService?.getNpmRegistryClient());
642
916
  }
643
917
  finally {
644
918
  span.end();
645
919
  }
646
920
  });
647
- // Backstop: assigning `.catch` to a separate (discarded) promise
648
- // keeps `packageInstallPromise` itself rejecting, so the joins
649
- // can still observe and abort. Without this, an install that
650
- // fails before any join fires becomes an unhandled rejection.
651
- const installApplicationId = applicationConfig.id;
652
- const installUpgradeCount = upgradePromises.length;
653
921
  packageInstallPromise.catch((err) => {
654
922
  logger.error(
655
923
  // errorId is encoded into the message body because the
656
924
  // logger.error contract limits structured attributes to
657
925
  // `{ error: { kind, message, stack } }`. The id stays
658
926
  // grep-able for Datadog/Sentry alert rules.
659
- `Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${installUpgradeCount}]`, getErrorMeta(err));
927
+ `Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd}]`, getErrorMeta(err));
660
928
  });
661
929
  }
662
930
  else {
@@ -665,19 +933,49 @@ export async function dev(options) {
665
933
  const shouldUploadPackageState = hasPackageChanged || forcePackageInstall;
666
934
  if (shouldUploadPackageState || uploadFirst) {
667
935
  // Upload serializes the post-install lockfile + node_modules
668
- // tree to DBFS, so it must observe a quiesced install.
669
- await joinPackageInstall("before upload");
670
- logger.info(`Uploading local files to branch '${activeDbfsBranchName}' on server before starting`);
671
- await tracer.startActiveSpan("uploadFirstOrPackageChanged", async (span) => {
672
- await syncService.uploadDirectory("cli:sdk");
673
- await syncService.uploadDirectoryNowIfNeeded("cli:sdk");
674
- span.end();
675
- });
936
+ // tree to DBFS, so it must observe quiesced upgrade+install.
937
+ // Upgrade first — its rejection exits before an install
938
+ // rejection can take the degrade path (APPS-4457).
939
+ //
940
+ // BUT: if a successful CLI upgrade is pending restart
941
+ // (`hasCliUpdated`), the restart branch below MUST win over
942
+ // the install-failure degrade path. Otherwise the outer
943
+ // sync/setup catch routes `InitialInstallFailed` to "degrade"
944
+ // and skips the restart entirely — masking a successful CLI
945
+ // upgrade. Catch ONLY the join (not the upload body) so a
946
+ // post-join upload failure still propagates normally.
947
+ let skipStartupUploadForCliRestart = false;
948
+ try {
949
+ await joinUpgradeThenInstall("before upload");
950
+ }
951
+ catch (joinError) {
952
+ if (hasCliUpdated &&
953
+ joinError instanceof InitialInstallFailed) {
954
+ logger.info("Initial package install failed before startup upload, but CLI was updated; skipping upload and letting the restart proceed");
955
+ skipStartupUploadForCliRestart = true;
956
+ }
957
+ else {
958
+ throw joinError;
959
+ }
960
+ }
961
+ if (!skipStartupUploadForCliRestart) {
962
+ logger.info(`Uploading local files to branch '${activeDbfsBranchName}' on server before starting`);
963
+ await tracer.startActiveSpan("uploadFirstOrPackageChanged", async (span) => {
964
+ await syncService.uploadDirectory("cli:sdk");
965
+ await syncService.uploadDirectoryNowIfNeeded("cli:sdk");
966
+ span.end();
967
+ });
968
+ }
676
969
  }
677
970
  if (hasCliUpdated) {
678
- // Exiting mid-install would leave a half-written lockfile that
679
- // the next boot would have to recover from.
680
- await joinPackageInstall("before CLI restart");
971
+ // Restart must NOT be preempted by an app-install failure:
972
+ // the next boot re-runs install with the new CLI. Join the
973
+ // upgrade (it must succeed or we exit before restarting) and
974
+ // SETTLE the install (don't observe its rejection — but wait
975
+ // for npm to finish so the restart doesn't SIGKILL it
976
+ // mid-rename and leave a half-written lockfile).
977
+ await joinPackageUpgrade("before CLI restart");
978
+ await settlePackageInstall("before CLI restart");
681
979
  try {
682
980
  logger.info("Releasing lock before restarting the dev server");
683
981
  await aiService?.removeIntegrationCache();
@@ -692,14 +990,20 @@ export async function dev(options) {
692
990
  });
693
991
  }
694
992
  catch (error) {
695
- logger.error("[dev-server] Startup failed during sync/lock/setup (exiting with code 1)", getErrorMeta(error));
696
- try {
697
- await aiService?.removeIntegrationCache();
698
- await lockService?.shutdownAndExit();
993
+ if (handleStartupError(error, devServerStatus, logger) === "degrade") {
994
+ // app-install failure: do NOT exit — fall through to Vite startup below.
995
+ // upload + CLI-restart were already skipped (the rejecting join threw first).
699
996
  }
700
- finally {
701
- // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
702
- process.exit(1);
997
+ else {
998
+ logger.error("[dev-server] Startup failed during sync/lock/setup (exiting with code 1)", getErrorMeta(error));
999
+ try {
1000
+ await aiService?.removeIntegrationCache();
1001
+ await lockService?.shutdownAndExit();
1002
+ }
1003
+ finally {
1004
+ // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
1005
+ process.exit(1);
1006
+ }
703
1007
  }
704
1008
  }
705
1009
  }
@@ -713,7 +1017,36 @@ export async function dev(options) {
713
1017
  // cache embeds partial state that survives across reloads. Awaiting
714
1018
  // here costs at most the install's remaining wall time — the upload
715
1019
  // and CLI-restart joins above usually drain it first.
716
- await joinPackageInstall("before Vite startup");
1020
+ // The "before upload" / "before CLI restart" joins above run inside the
1021
+ // sync/lock catch that degrades on `InitialInstallFailed`. But when the
1022
+ // install ran yet none of those joins fired (e.g.
1023
+ // `packageJsonRequiresInstall && !forcePackageInstall && !upload &&
1024
+ // !hasCliUpdated`), the install rejection first surfaces HERE — outside
1025
+ // that catch. Route it through the SAME origin gate so an app-install
1026
+ // failure still degrades (keep Vite up + record the error) instead of
1027
+ // escaping `dev()` to `process.exit(1)`. Non-install errors (including
1028
+ // any background upgrade rejection that escaped the prior joins) take
1029
+ // the same explicit exit path as the sync/lock catch above so the
1030
+ // process actually terminates (the startupSpan catch only rethrows,
1031
+ // and an unhandled rejection at that depth doesn't shut the lock
1032
+ // service down or guarantee `process.exit(1)`).
1033
+ try {
1034
+ await joinUpgradeThenInstall("before Vite startup");
1035
+ }
1036
+ catch (error) {
1037
+ if (handleStartupError(error, devServerStatus, logger) === "exit") {
1038
+ logger.error("[dev-server] Startup failed during pre-Vite install join (exiting with code 1)", getErrorMeta(error));
1039
+ try {
1040
+ await aiService?.removeIntegrationCache();
1041
+ await lockService?.shutdownAndExit();
1042
+ }
1043
+ finally {
1044
+ // Redundant with `shutdownAndExit`; here so a thrown shutdown
1045
+ // path can't leave the process hanging on a stuck handle.
1046
+ process.exit(1);
1047
+ }
1048
+ }
1049
+ }
717
1050
  const activateRuntimeGitService = async () => {
718
1051
  if (gitService) {
719
1052
  const hasGit = await nodeFs.access(path.join(cwd, ".git")).then(() => true, () => false);
@@ -816,9 +1149,7 @@ export async function dev(options) {
816
1149
  superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
817
1150
  existingServer: options.existingServer,
818
1151
  warmActivationStart: options.warmActivationStart,
819
- // TODO: Remove this cast — build the options object to match
820
- // CreateDevServerOptions directly so new required fields cause a
821
- // compile error instead of silently passing undefined.
1152
+ devServerStatus,
822
1153
  };
823
1154
  const result = await createDevServer(createDevServerOptions);
824
1155
  span.end();
@@ -833,13 +1164,16 @@ export async function dev(options) {
833
1164
  logger.warn(`Error stopping auth hot-reload server: ${error}`);
834
1165
  });
835
1166
  }
836
- // Drain any straggler install before tear-down. By this point the
837
- // pre-Vite join has already run on the success path, so usually
838
- // `packageInstallPromise` is undefined and this is a no-op; if abort
1167
+ // Drain any straggler upgrade/install before tear-down. By this
1168
+ // point the pre-Vite join has already run on the success path, so
1169
+ // usually both promises are undefined and this is a no-op; if abort
839
1170
  // races createDevServer (or fires during the inner sync block on
840
- // some error path), draining here keeps the spawned npm child from
841
- // being SIGKILL'd mid-rename. Errors only get logged — we are tearing
842
- // down anyway.
1171
+ // some error path), draining here keeps the spawned npm children
1172
+ // from being SIGKILL'd mid-rename. Errors only get logged — we are
1173
+ // tearing down anyway.
1174
+ joinPackageUpgrade("during abort").catch((error) => {
1175
+ logger.warn("Error draining background upgrade during abort", getErrorMeta(error));
1176
+ });
843
1177
  joinPackageInstall("during abort").catch((error) => {
844
1178
  logger.warn("Error draining background install during abort", getErrorMeta(error));
845
1179
  });