@superblocksteam/sdk 2.0.123 → 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
@@ -8,11 +8,18 @@
8
8
  * @superblocksteam/library pin matches the target version.
9
9
  */
10
10
 
11
+ import { EventEmitter } from "node:events";
11
12
  import fs from "node:fs/promises";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
12
15
 
13
16
  import { describe, it, expect, beforeEach, vi } from "vitest";
14
17
 
15
- import { checkVersionsAndWritePackageJson } from "./automatic-upgrades.js";
18
+ import {
19
+ buildGlobalInstallArgs,
20
+ buildInstallEnv,
21
+ checkVersionsAndWritePackageJson,
22
+ } from "./automatic-upgrades.js";
16
23
 
17
24
  // ---------------------------------------------------------------------------
18
25
  // Mocks
@@ -27,6 +34,10 @@ vi.mock("node:fs/promises", () => ({
27
34
 
28
35
  vi.mock("node:child_process", () => ({
29
36
  exec: vi.fn(),
37
+ // `execFile` is exported so `promisify(execFile)` in npm-registry.ts (loaded
38
+ // transitively via `@superblocksteam/vite-plugin-file-sync/npm-error-parser`)
39
+ // doesn't crash at module load. This test never invokes it.
40
+ execFile: vi.fn(),
30
41
  }));
31
42
 
32
43
  const upgradeWithPackageManagerCalls: unknown[] = [];
@@ -50,13 +61,28 @@ vi.mock("./version-detection.js", () => ({
50
61
  clearCliVersionCache: vi.fn(),
51
62
  }));
52
63
 
64
+ // `vi.mock` calls are hoisted to the top of the module, so any factory must
65
+ // either declare its mocks inline OR use `vi.hoisted()` to hoist the mock
66
+ // fns alongside the mock registration. The latter lets us reuse the mocks
67
+ // inside test bodies (e.g. `.mockReturnValue(...)`).
68
+ const { stripUpgradedCliLockfilesMock, loggerMock } = vi.hoisted(() => {
69
+ return {
70
+ stripUpgradedCliLockfilesMock: vi.fn(async () => ({})),
71
+ loggerMock: {
72
+ info: vi.fn(),
73
+ warn: vi.fn(),
74
+ error: vi.fn(),
75
+ debug: vi.fn(),
76
+ },
77
+ };
78
+ });
79
+
80
+ vi.mock("./post-upgrade-lockfile-strip.mjs", () => ({
81
+ stripUpgradedCliLockfiles: stripUpgradedCliLockfilesMock,
82
+ }));
83
+
53
84
  vi.mock("../telemetry/logging.js", () => ({
54
- getLogger: () => ({
55
- info: vi.fn(),
56
- warn: vi.fn(),
57
- error: vi.fn(),
58
- debug: vi.fn(),
59
- }),
85
+ getLogger: () => loggerMock,
60
86
  getErrorMeta: vi.fn(() => ({})),
61
87
  }));
62
88
 
@@ -66,8 +92,18 @@ vi.mock("../telemetry/index.js", () => ({
66
92
  }),
67
93
  }));
68
94
 
69
- vi.mock("@superblocksteam/shared", async () => {
95
+ // Use `importOriginal` so transitive consumers of `@superblocksteam/shared`
96
+ // (e.g. `@superblocksteam/telemetry`'s policy module pulled in by the
97
+ // `bucketNpmRegistryHost` import added in this file's source under test for
98
+ // APPS-4381) see every real export (`DeploymentTypeEnum`, etc.), while still
99
+ // letting the test override the two symbols `automatic-upgrades.ts` itself
100
+ // consumes. The factory return type is `any` because `NON_SB_ORG_UPDATE_ERROR`
101
+ // is a string-literal type in the real module that our test override can't
102
+ // satisfy structurally; `vi.mock`'s value-level behaviour is unaffected.
103
+ vi.mock("@superblocksteam/shared", async (importOriginal) => {
104
+ const actual = (await importOriginal()) as Record<string, unknown>;
70
105
  return {
106
+ ...actual,
71
107
  NON_SB_ORG_UPDATE_ERROR: "non_sb_org_update_error",
72
108
  // traceFunction in tests is a passthrough — we just want the fn to run.
73
109
  traceFunction: async ({ fn }: { fn: () => Promise<unknown> }) => {
@@ -102,6 +138,8 @@ const mockConfig: Parameters<typeof checkVersionsAndWritePackageJson>[1] = {
102
138
  beforeEach(() => {
103
139
  vi.clearAllMocks();
104
140
  upgradeWithPackageManagerCalls.length = 0;
141
+ // Restore default mock return values after `clearAllMocks` resets them.
142
+ stripUpgradedCliLockfilesMock.mockImplementation(async () => ({}));
105
143
 
106
144
  // Mock global fetch used by getRemoteVersions
107
145
  global.fetch = vi.fn(async () => ({
@@ -340,6 +378,398 @@ describe("checkVersionsAndWritePackageJson", () => {
340
378
  );
341
379
  });
342
380
 
381
+ // -----------------------------------------------------------------
382
+ // APPS-4324: classifyNpmExecStderr wiring for `upgradeCliWithPackageManager`
383
+ //
384
+ // The CLI auto-upgrade pod's npm fallback exec used to surface a
385
+ // perimeter-block (air-gapped customer with public-npm egress denied)
386
+ // as a generic `"Failed to upgrade packages"` log, leaving the operator
387
+ // no signal as to whether the cause was a transient registry blip, a
388
+ // bad auth token, or a missing package. These tests pin the structured
389
+ // log emitted just before the upgrade promise rejects.
390
+ //
391
+ // Note on flow: `checkVersionsAndWritePackageJson` returns the upgrade
392
+ // promise to its caller (`dev.mts` joins it via `Promise.all` against
393
+ // the local install). The rejection is therefore observed by awaiting
394
+ // `result.upgradePromises[0]`, not by the outer try/catch inside this
395
+ // function (which only catches sync errors during the package.json
396
+ // read/write block).
397
+ // -----------------------------------------------------------------
398
+
399
+ /** Build a fake ChildProcess that emits the supplied stderr (once,
400
+ * asynchronously so the caller has time to register `.on("data")`),
401
+ * then fires the exec callback with `cbError`. Passing an array emits
402
+ * one `data` event per element — exercise the stderr-buffer accumulation
403
+ * path against split chunks (real npm output arrives multi-chunk).
404
+ *
405
+ * Pass `{ skipEmit: true, errorStderr: "..." }` to model the fast-fail
406
+ * race the Bugbot finding flagged: the subprocess exits before our
407
+ * `cp.stderr.on('data', …)` listener attaches, so `stderrBuffer` is
408
+ * empty and only `error.stderr` (which the exec wrapper aggregates
409
+ * internally) carries the npm output. The classifier must fall back to
410
+ * that — otherwise the structured `[npm-install-blocked]` line goes
411
+ * missing on exactly the failure mode this PR exists to diagnose. */
412
+ async function mockNpmFallbackExec(
413
+ stderr: string | string[],
414
+ cbError: Error,
415
+ opts: { skipEmit?: boolean; errorStderr?: string } = {},
416
+ ) {
417
+ const stdout = new EventEmitter();
418
+ const stderrEmitter = new EventEmitter();
419
+ const chunks = Array.isArray(stderr) ? stderr : [stderr];
420
+ // The exec call we care about is the third positional invocation
421
+ // (after `which superblocks` for upgradeCliWithOclif and the oclif
422
+ // `update` subprocess). Make `which` succeed (returns a path) and
423
+ // `superblocks update` return "not updatable" so the package-manager
424
+ // fallback path runs.
425
+ const { exec } = await import("node:child_process");
426
+ const execMock = vi.mocked(exec);
427
+ execMock.mockImplementation(((
428
+ cmd: string,
429
+ ...rest: unknown[]
430
+ ): unknown => {
431
+ const cb = (
432
+ typeof rest[rest.length - 1] === "function"
433
+ ? rest[rest.length - 1]
434
+ : undefined
435
+ ) as ((err: Error | null, result?: unknown) => void) | undefined;
436
+ // Promisified consumers (upgradeCliWithOclif) destructure `{stdout,
437
+ // stderr}` from the awaited result. Real Node has a
438
+ // `util.promisify.custom` symbol that produces that shape; our vi.fn
439
+ // mock doesn't, so we pass the destructured object as the callback's
440
+ // single value-arg (default promisify behaviour resolves to that).
441
+ if (cmd === "which superblocks" || cmd === "where superblocks") {
442
+ cb?.(null, { stdout: "/usr/local/bin/superblocks\n", stderr: "" });
443
+ return {};
444
+ }
445
+ if (cmd.includes("superblocks update")) {
446
+ // Make oclif report "not updatable" so the fallback fires.
447
+ cb?.(null, { stdout: "this version is not updatable\n", stderr: "" });
448
+ return {};
449
+ }
450
+ // The npm install fallback uses the callback-style API directly (not
451
+ // promisified), so the callback signature is just (error). Schedule
452
+ // the stderr emit + callback so the caller has time to register
453
+ // listeners on cp.stdout/cp.stderr.
454
+ const errArg: Error & { stderr?: string } = cbError;
455
+ if (opts.errorStderr !== undefined) errArg.stderr = opts.errorStderr;
456
+ queueMicrotask(() => {
457
+ if (!opts.skipEmit) {
458
+ for (const chunk of chunks) {
459
+ if (chunk) stderrEmitter.emit("data", chunk);
460
+ }
461
+ }
462
+ cb?.(errArg);
463
+ });
464
+ return { stdout, stderr: stderrEmitter };
465
+ }) as unknown as Parameters<typeof execMock.mockImplementation>[0]);
466
+ }
467
+
468
+ async function runAndAwaitUpgrade() {
469
+ const result = await checkVersionsAndWritePackageJson(
470
+ mockLockService,
471
+ mockConfig,
472
+ false,
473
+ false,
474
+ );
475
+ // The CLI upgrade promise was pushed to upgradePromises and is the
476
+ // promise that rejects when the npm fallback fails. The caller in
477
+ // dev.mts joins it via Promise.all; here we await it directly so the
478
+ // test can assert on rejection + log shape.
479
+ await Promise.all(result.upgradePromises);
480
+ }
481
+
482
+ it("APPS-4324: logs NpmInstallBlocked with reason=registry_unreachable on ECONNREFUSED stderr", async () => {
483
+ await mockNpmFallbackExec(
484
+ [
485
+ "npm error code ECONNREFUSED",
486
+ "npm error errno ECONNREFUSED",
487
+ "npm error network request to https://registry.npmjs.org/@superblocksteam%2fcli failed, reason: connect ECONNREFUSED 127.0.0.1:443",
488
+ ].join("\n"),
489
+ new Error("Command failed: pnpm install ..."),
490
+ );
491
+
492
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
493
+ /Command failed: pnpm install/,
494
+ );
495
+
496
+ // The structured log line fires before the rejection so the operator
497
+ // sees the reason code in the crash-loop output. Reason is encoded
498
+ // into the message body — no meta arg is passed, because the
499
+ // `NpmInstallBlocked` instance's `Error.message` carries the raw
500
+ // registry host (APPS-4381 round-2 review).
501
+ expect(loggerMock.error).toHaveBeenCalledWith(
502
+ expect.stringMatching(
503
+ /\[npm-install-blocked\] reason=registry_unreachable/,
504
+ ),
505
+ );
506
+
507
+ // Pin the relative ordering: the structured `[npm-install-blocked]`
508
+ // line MUST precede the legacy `Failed to upgrade packages` line so a
509
+ // downstream sink that truncates after the first error still surfaces
510
+ // the reason code (see the comment in automatic-upgrades.ts beside the
511
+ // blocked-log emit).
512
+ const blockedIdx = loggerMock.error.mock.calls.findIndex(
513
+ (c) =>
514
+ typeof c[0] === "string" && c[0].includes("[npm-install-blocked]"),
515
+ );
516
+ const failedIdx = loggerMock.error.mock.calls.findIndex(
517
+ (c) => typeof c[0] === "string" && c[0].includes("Failed to upgrade"),
518
+ );
519
+ expect(blockedIdx).toBeGreaterThanOrEqual(0);
520
+ expect(failedIdx).toBeGreaterThan(blockedIdx);
521
+ });
522
+
523
+ it("APPS-4324: logs reason=registry_auth_failed on E401 stderr", async () => {
524
+ await mockNpmFallbackExec(
525
+ [
526
+ "npm error code E401",
527
+ "npm error 401 Unauthorized - GET https://npm.private.example.com/@superblocksteam%2fcli",
528
+ ].join("\n"),
529
+ new Error("Command failed: pnpm install ..."),
530
+ );
531
+
532
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
533
+ /Command failed: pnpm install/,
534
+ );
535
+
536
+ expect(loggerMock.error).toHaveBeenCalledWith(
537
+ expect.stringMatching(
538
+ /\[npm-install-blocked\] reason=registry_auth_failed/,
539
+ ),
540
+ );
541
+ });
542
+
543
+ it("APPS-4381: buckets a private registry host so the structured registryHost facet is `private`, not the raw hostname", async () => {
544
+ // Regression for review feedback on PR #19634: the structured
545
+ // auto-upgrade log used to emit `registryHost=${blocked.registryHost ?? "unknown"}`
546
+ // verbatim. That made the customer's private registry hostname a
547
+ // structured log facet — unbounded cardinality in shared operational
548
+ // log indexes and a leak of customer infra identifiers across the
549
+ // `registryHost` dimension. The fix passes the value through
550
+ // `bucketNpmRegistryHost` (the same helper PR #19622 wired for llmobs
551
+ // tags) so this field emits a bounded enum (`public_npm` | `private`
552
+ // | `unknown`).
553
+ //
554
+ // Scope note: the `summary=...` free-text field is also redacted of
555
+ // URLs / bare hostnames before logging (round-2 review feedback on
556
+ // PR #19634) — see the `does not appear anywhere` regression test
557
+ // below. The structured-facet bucketing pinned here is the
558
+ // `registryHost=` field only.
559
+ const privateHost = "customer.jfrog.io";
560
+ await mockNpmFallbackExec(
561
+ [
562
+ "npm error code E401",
563
+ `npm error 401 Unauthorized - GET https://${privateHost}/@superblocksteam%2fcli`,
564
+ ].join("\n"),
565
+ new Error("Command failed: pnpm install ..."),
566
+ );
567
+
568
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
569
+ /Command failed: pnpm install/,
570
+ );
571
+
572
+ const blockedCall = loggerMock.error.mock.calls.find(
573
+ (c) =>
574
+ typeof c[0] === "string" && c[0].includes("[npm-install-blocked]"),
575
+ );
576
+ expect(blockedCall).toBeDefined();
577
+ const message = blockedCall![0] as string;
578
+ // The structured `registryHost=` facet is bucketed to `private`...
579
+ expect(message).toMatch(/registryHost=private(\s|$)/);
580
+ // ...and the raw hostname does NOT appear as the facet value (would
581
+ // regress if a future refactor reverted the bucketing call).
582
+ expect(message).not.toMatch(
583
+ new RegExp(`registryHost=${privateHost.replace(/\./g, "\\.")}`),
584
+ );
585
+ });
586
+
587
+ it("APPS-4381: buckets registry.npmjs.org as public_npm", async () => {
588
+ // Pin the other side of the bucket so a future refactor that breaks
589
+ // the public-vs-private split also breaks this test.
590
+ await mockNpmFallbackExec(
591
+ [
592
+ "npm error code ECONNREFUSED",
593
+ "npm error network request to https://registry.npmjs.org/@superblocksteam%2fcli failed",
594
+ ].join("\n"),
595
+ new Error("Command failed: pnpm install ..."),
596
+ );
597
+
598
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
599
+ /Command failed: pnpm install/,
600
+ );
601
+
602
+ const blockedCall = loggerMock.error.mock.calls.find(
603
+ (c) =>
604
+ typeof c[0] === "string" && c[0].includes("[npm-install-blocked]"),
605
+ );
606
+ expect(blockedCall).toBeDefined();
607
+ expect(blockedCall![0] as string).toMatch(
608
+ /registryHost=public_npm(\s|$)/,
609
+ );
610
+ });
611
+
612
+ it("APPS-4381: a private registry host does not appear anywhere in the emitted [npm-install-blocked] log call (round-2 review feedback)", async () => {
613
+ // George's round-2 feedback on PR #19634: the `registryHost=` facet is
614
+ // bucketed, but `summary=${JSON.stringify(blocked.summary)}` still
615
+ // echoed the raw URL/host from the npm stderr line. The meta arg
616
+ // (`getErrorMeta(blocked)`) was a second leak channel because
617
+ // `NpmInstallBlocked.message` is `"... against ${host} [code]"` —
618
+ // `getErrorMeta` synthesizes meta from that message + the stack that
619
+ // embeds it.
620
+ //
621
+ // This test uses a unique sentinel host that no other fixture in this
622
+ // file references, then asserts the host substring does not appear in
623
+ // ANY argument of the structured logger call — message body, any meta
624
+ // arg, any stack — anywhere. This is the "private host does not
625
+ // appear anywhere in the emitted auto-upgrade log line" regression
626
+ // George asked for.
627
+ //
628
+ // Mock-faithfulness note: this test does NOT rely on the
629
+ // `getErrorMeta` mock returning a realistic shape; the fix omits the
630
+ // meta arg entirely at the `[npm-install-blocked]` call site, so a
631
+ // `() => ({})` stub would no longer mask a regression of the meta
632
+ // path. To guard a future refactor that re-adds a meta arg with the
633
+ // raw `NpmInstallBlocked`, we assert the call has exactly one arg
634
+ // below.
635
+ const privateHost = "registry-leak-sentinel.acme-corp.internal";
636
+ await mockNpmFallbackExec(
637
+ [
638
+ "npm error code E401",
639
+ `npm error 401 Unauthorized - GET https://${privateHost}:4873/@superblocksteam%2fcli`,
640
+ `npm error request to https://${privateHost}/foo failed, reason: getaddrinfo ENOTFOUND ${privateHost}`,
641
+ ].join("\n"),
642
+ new Error("Command failed: pnpm install ..."),
643
+ );
644
+
645
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
646
+ /Command failed: pnpm install/,
647
+ );
648
+
649
+ const blockedCall = loggerMock.error.mock.calls.find(
650
+ (c) =>
651
+ typeof c[0] === "string" && c[0].includes("[npm-install-blocked]"),
652
+ );
653
+ expect(blockedCall).toBeDefined();
654
+
655
+ // Stringify the entire call tuple so we catch the host whether it
656
+ // leaks through the message body, a (future) meta arg's
657
+ // `error.message`, a stack, or any other field a future refactor
658
+ // might add. `acme-corp.internal` is checked separately so a
659
+ // host-only redaction (one token) that left the suffix would also
660
+ // trip the test.
661
+ const serialized = JSON.stringify(blockedCall);
662
+ expect(serialized).not.toContain(privateHost);
663
+ expect(serialized).not.toContain("acme-corp.internal");
664
+
665
+ // No meta arg today; pin that so any future code that re-introduces
666
+ // `getErrorMeta(blocked)` (or any other host-bearing payload) is
667
+ // forced through this regression test.
668
+ expect(blockedCall!.length).toBe(1);
669
+
670
+ // Spot-check that the bucket tokens replaced the host (the diagnostic
671
+ // shape of the npm phrasing is preserved even after redaction).
672
+ const message = blockedCall![0] as string;
673
+ expect(message).toMatch(/summary=/);
674
+ expect(message).toContain("<private>");
675
+ });
676
+
677
+ it("APPS-4324: logs reason=not_in_registry on E404 stderr", async () => {
678
+ await mockNpmFallbackExec(
679
+ [
680
+ "npm error code E404",
681
+ "npm error 404 Not Found - GET https://npm.private.example.com/@superblocksteam%2fcli - Not found",
682
+ ].join("\n"),
683
+ new Error("Command failed: pnpm install ..."),
684
+ );
685
+
686
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
687
+ /Command failed: pnpm install/,
688
+ );
689
+
690
+ expect(loggerMock.error).toHaveBeenCalledWith(
691
+ expect.stringMatching(/\[npm-install-blocked\] reason=not_in_registry/),
692
+ );
693
+ });
694
+
695
+ it("APPS-4324: falls through to the existing generic error path for ERESOLVE (no false-positive)", async () => {
696
+ await mockNpmFallbackExec(
697
+ [
698
+ "npm error code ERESOLVE",
699
+ "npm error ERESOLVE unable to resolve dependency tree",
700
+ ].join("\n"),
701
+ new Error("Command failed: pnpm install ..."),
702
+ );
703
+
704
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
705
+ /Command failed: pnpm install/,
706
+ );
707
+
708
+ // The classifier returns null for ERESOLVE — no structured log fires.
709
+ expect(loggerMock.error).not.toHaveBeenCalledWith(
710
+ expect.stringContaining("[npm-install-blocked]"),
711
+ );
712
+ // The existing generic per-failure log still fires so the operator
713
+ // still sees something in the crash-loop output.
714
+ expect(loggerMock.error).toHaveBeenCalledWith(
715
+ expect.stringContaining("Failed to upgrade packages"),
716
+ );
717
+ });
718
+
719
+ it("APPS-4324: falls back to error.stderr when stderrBuffer is empty (fast-fail race)", async () => {
720
+ // Bugbot finding on the APPS-4324 branch: a subprocess that fails fast
721
+ // can emit and exit before our `cp.stderr.on('data', …)` listener
722
+ // attaches, leaving `stderrBuffer` empty. The exec wrapper still
723
+ // aggregates the output onto `error.stderr`, so the classifier must
724
+ // read that as the fallback or the structured diagnostic this PR
725
+ // exists to guarantee goes missing exactly when it's needed most.
726
+ const errWithStderr: Error & { stderr?: string } = new Error(
727
+ "Command failed: pnpm install ...",
728
+ );
729
+ await mockNpmFallbackExec("", errWithStderr, {
730
+ skipEmit: true,
731
+ errorStderr: [
732
+ "npm error code ECONNREFUSED",
733
+ "npm error network request to https://registry.npmjs.org/foo failed",
734
+ ].join("\n"),
735
+ });
736
+
737
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
738
+ /Command failed: pnpm install/,
739
+ );
740
+
741
+ expect(loggerMock.error).toHaveBeenCalledWith(
742
+ expect.stringMatching(
743
+ /\[npm-install-blocked\] reason=registry_unreachable/,
744
+ ),
745
+ );
746
+ });
747
+
748
+ it("APPS-4324: accumulates stderr across multiple chunks before classifying", async () => {
749
+ // Real `cp.stderr` emits multiple `data` events; the OS pipe buffer
750
+ // can split the npm output mid-line. Emit two chunks that bisect the
751
+ // `npm error code ECONNREFUSED` line so the classifier only succeeds
752
+ // if `automatic-upgrades.ts` concatenates the buffer instead of
753
+ // matching per-chunk.
754
+ await mockNpmFallbackExec(
755
+ [
756
+ "npm error code ECONNR",
757
+ "EFUSED\nnpm error network request to https://registry.npmjs.org/foo failed",
758
+ ],
759
+ new Error("Command failed: pnpm install ..."),
760
+ );
761
+
762
+ await expect(runAndAwaitUpgrade()).rejects.toThrow(
763
+ /Command failed: pnpm install/,
764
+ );
765
+
766
+ expect(loggerMock.error).toHaveBeenCalledWith(
767
+ expect.stringMatching(
768
+ /\[npm-install-blocked\] reason=registry_unreachable/,
769
+ ),
770
+ );
771
+ });
772
+
343
773
  it("leaves sdk-api alone when the server response omits sdkApi (older deployment)", async () => {
344
774
  global.fetch = vi.fn(async () => ({
345
775
  ok: true,
@@ -376,4 +806,96 @@ describe("checkVersionsAndWritePackageJson", () => {
376
806
  );
377
807
  });
378
808
  });
809
+
810
+ // -----------------------------------------------------------------------
811
+ // Argv + env plumbing for the CLI auto-upgrade.
812
+ //
813
+ // Userconfig pinning lives in the spawned subprocess env
814
+ // (`NPM_CONFIG_USERCONFIG`) rather than a CLI flag. `--userconfig=…`
815
+ // is npm-only — pnpm rejects it (pnpm/pnpm#6036). The env var route
816
+ // works for both via @pnpm/npm-conf's userconfig recognition.
817
+ //
818
+ // Build path is exercised against the pure-function helpers so the
819
+ // assertions don't have to drive the exec subprocess machinery.
820
+ // -----------------------------------------------------------------------
821
+
822
+ describe("buildGlobalInstallArgs", () => {
823
+ const PACKAGE_NAME = "@superblocksteam/cli@2.1.0";
824
+
825
+ it("returns the npm baseline argv (no --userconfig)", () => {
826
+ const args = buildGlobalInstallArgs({ agent: "npm" }, PACKAGE_NAME);
827
+
828
+ expect(args).toContain("--fund=false");
829
+ expect(args).toContain("--save-exact");
830
+ expect(args).toContain("--audit=false");
831
+ expect(args).toContain("--force");
832
+ expect(args).toContain(PACKAGE_NAME);
833
+ // Userconfig is plumbed via env, not flag (pnpm/pnpm#6036).
834
+ expect(args.some((a) => a.startsWith("--userconfig="))).toBe(false);
835
+ });
836
+
837
+ it("returns the pnpm baseline argv (drops npm-only --audit=false, no --userconfig)", () => {
838
+ const args = buildGlobalInstallArgs({ agent: "pnpm" }, PACKAGE_NAME);
839
+
840
+ expect(args).toContain("--fund=false");
841
+ expect(args).toContain("--save-exact");
842
+ expect(args).not.toContain("--audit=false");
843
+ expect(args).toContain("--force");
844
+ expect(args).toContain(PACKAGE_NAME);
845
+ // Same: pnpm rejects --userconfig (pnpm/pnpm#6036), so the env
846
+ // var route is the only mechanism.
847
+ expect(args.some((a) => a.startsWith("--userconfig="))).toBe(false);
848
+ });
849
+ });
850
+
851
+ describe("buildInstallEnv", () => {
852
+ const USERCONFIG_PATH = path.join(os.homedir(), ".superblocks", "npmrc");
853
+
854
+ it("sets NPM_CONFIG_USERCONFIG (npm + pnpm <= 10)", () => {
855
+ const env = buildInstallEnv(USERCONFIG_PATH);
856
+
857
+ expect(env.NPM_CONFIG_USERCONFIG).toBe(USERCONFIG_PATH);
858
+ });
859
+
860
+ it("sets PNPM_CONFIG_USERCONFIG (pnpm 11+)", () => {
861
+ // pnpm 11 stopped honouring `NPM_CONFIG_USERCONFIG`. Both env
862
+ // vars must be set so a single overlay works across npm, pnpm
863
+ // <= 10, and pnpm 11+. See `userconfig-env.integration.test.mts`
864
+ // for the regression guard against the pnpm 11 contract change.
865
+ const env = buildInstallEnv(USERCONFIG_PATH);
866
+
867
+ expect(env.PNPM_CONFIG_USERCONFIG).toBe(USERCONFIG_PATH);
868
+ });
869
+
870
+ it("accepts a custom base env (for callers scrubbing inherited config)", () => {
871
+ // The base-env override lets callers pre-scrub `npm_config_*`
872
+ // inherited from a parent pnpm/npm process, then layer the
873
+ // userconfig pin on top. `logsDir` (2nd arg) is omitted here.
874
+ const env = buildInstallEnv(USERCONFIG_PATH, undefined, {
875
+ PATH: "/custom/path",
876
+ HOME: "/custom/home",
877
+ });
878
+
879
+ expect(env.PATH).toBe("/custom/path");
880
+ expect(env.HOME).toBe("/custom/home");
881
+ expect(env.NPM_CONFIG_USERCONFIG).toBe(USERCONFIG_PATH);
882
+ expect(env.PNPM_CONFIG_USERCONFIG).toBe(USERCONFIG_PATH);
883
+ });
884
+
885
+ it("routes npm's debug log via NPM_CONFIG_LOGS_DIR when a logs dir is given", () => {
886
+ // The live-edit pod points npm's `logs-dir` at
887
+ // `<app>/.superblocks/logs` so a failed install leaves a collectable
888
+ // debug log in a predictable place.
889
+ const logsDir = path.join("/srv/app", ".superblocks", "logs");
890
+ const env = buildInstallEnv(USERCONFIG_PATH, logsDir, {});
891
+
892
+ expect(env.NPM_CONFIG_LOGS_DIR).toBe(logsDir);
893
+ });
894
+
895
+ it("omits NPM_CONFIG_LOGS_DIR when no logs dir is given (keeps npm's default _logs)", () => {
896
+ const env = buildInstallEnv(USERCONFIG_PATH, undefined, {});
897
+
898
+ expect(env.NPM_CONFIG_LOGS_DIR).toBeUndefined();
899
+ });
900
+ });
379
901
  });