@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
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Tests for post-upgrade-lockfile-strip.
3
+ *
4
+ * Strategy: stand up a real on-disk fixture mimicking the dev-server pod
5
+ * layout (`<prefix>/lib/node_modules/@superblocksteam/cli/...`), then assert
6
+ * the strip mutates the right files and is idempotent / non-fatal on edge
7
+ * cases. Avoids mocking the strip helpers because the disk-shape they expect
8
+ * is the actual contract under test.
9
+ */
10
+
11
+ import {
12
+ mkdir,
13
+ mkdtemp,
14
+ readFile,
15
+ realpath,
16
+ rm,
17
+ symlink,
18
+ writeFile,
19
+ } from "node:fs/promises";
20
+ import * as os from "node:os";
21
+ import * as path from "node:path";
22
+
23
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
24
+
25
+ import type { Logger } from "../telemetry/logging.js";
26
+ import {
27
+ findGlobalCliInstallDir,
28
+ stripUpgradedCliLockfiles,
29
+ } from "./post-upgrade-lockfile-strip.mjs";
30
+
31
+ /**
32
+ * Vitest mocks structurally don't match the `Logger` interface signatures
33
+ * (`Mock<Procedure>` vs `(...messages: unknown[]) => void`), so we keep the
34
+ * mock object loosely typed and cast through `Logger` at call sites. The
35
+ * `LoggerMock` alias exists so tests can still introspect calls with
36
+ * `logger.warn.mock.calls` without losing the mock-specific helpers.
37
+ */
38
+ type LoggerMock = {
39
+ info: ReturnType<typeof vi.fn>;
40
+ warn: ReturnType<typeof vi.fn>;
41
+ error: ReturnType<typeof vi.fn>;
42
+ debug: ReturnType<typeof vi.fn>;
43
+ };
44
+
45
+ function makeLogger(): LoggerMock {
46
+ return {
47
+ info: vi.fn(),
48
+ warn: vi.fn(),
49
+ error: vi.fn(),
50
+ debug: vi.fn(),
51
+ };
52
+ }
53
+
54
+ function asLogger(mock: LoggerMock): Logger {
55
+ return mock as unknown as Logger;
56
+ }
57
+
58
+ /**
59
+ * Lockfile fixture with mixed registries — only public-host `resolved` URLs
60
+ * should be stripped; `integrity` must survive.
61
+ */
62
+ function lockfile(version: string) {
63
+ return {
64
+ name: "@superblocksteam/cli",
65
+ version,
66
+ lockfileVersion: 3,
67
+ requires: true,
68
+ packages: {
69
+ "": {
70
+ name: "@superblocksteam/cli",
71
+ version,
72
+ },
73
+ "node_modules/some-dep": {
74
+ version: "1.0.0",
75
+ resolved: "https://registry.npmjs.org/some-dep/-/some-dep-1.0.0.tgz",
76
+ integrity: "sha512-abc",
77
+ },
78
+ "node_modules/already-stripped": {
79
+ version: "2.0.0",
80
+ integrity: "sha512-def",
81
+ },
82
+ },
83
+ };
84
+ }
85
+
86
+ async function makeCliInstallTree(root: string): Promise<{
87
+ prefix: string;
88
+ cliDir: string;
89
+ cliLockfile: string;
90
+ cliHiddenLockfile: string;
91
+ globalHiddenLockfile: string;
92
+ binShim: string;
93
+ realBin: string;
94
+ }> {
95
+ const prefix = path.join(root, ".npm-global");
96
+ const cliDir = path.join(
97
+ prefix,
98
+ "lib",
99
+ "node_modules",
100
+ "@superblocksteam",
101
+ "cli",
102
+ );
103
+ await mkdir(cliDir, { recursive: true });
104
+ await mkdir(path.join(cliDir, "node_modules"), { recursive: true });
105
+ await mkdir(path.join(cliDir, "bin"), { recursive: true });
106
+ await mkdir(path.join(prefix, "bin"), { recursive: true });
107
+
108
+ // package.json identifying this as @superblocksteam/cli
109
+ await writeFile(
110
+ path.join(cliDir, "package.json"),
111
+ JSON.stringify({ name: "@superblocksteam/cli", version: "2.1.0" }, null, 2),
112
+ );
113
+
114
+ // CLI's own lockfile, with stale public-host resolved URLs
115
+ const cliLockfile = path.join(cliDir, "package-lock.json");
116
+ await writeFile(
117
+ cliLockfile,
118
+ JSON.stringify(lockfile("2.1.0"), null, 2) + "\n",
119
+ );
120
+
121
+ // CLI's bundled hidden lockfile
122
+ const cliHiddenLockfile = path.join(
123
+ cliDir,
124
+ "node_modules",
125
+ ".package-lock.json",
126
+ );
127
+ await writeFile(
128
+ cliHiddenLockfile,
129
+ JSON.stringify(lockfile("2.1.0"), null, 2) + "\n",
130
+ );
131
+
132
+ // npm-global hidden lockfile (lists @superblocksteam/cli itself)
133
+ const globalHiddenLockfile = path.join(
134
+ prefix,
135
+ "lib",
136
+ "node_modules",
137
+ ".package-lock.json",
138
+ );
139
+ await writeFile(
140
+ globalHiddenLockfile,
141
+ JSON.stringify(
142
+ {
143
+ name: "global-prefix",
144
+ lockfileVersion: 3,
145
+ packages: {
146
+ "node_modules/@superblocksteam/cli": {
147
+ version: "2.1.0",
148
+ resolved:
149
+ "https://registry.npmjs.org/@superblocksteam/cli/-/cli-2.1.0.tgz",
150
+ integrity: "sha512-cli",
151
+ },
152
+ },
153
+ },
154
+ null,
155
+ 2,
156
+ ) + "\n",
157
+ );
158
+
159
+ // Real binary the shim points at
160
+ const realBin = path.join(cliDir, "bin", "run.js");
161
+ await writeFile(realBin, "#!/usr/bin/env node\n");
162
+
163
+ // The PATH-resolved shim is a symlink under <prefix>/bin
164
+ const binShim = path.join(prefix, "bin", "superblocks");
165
+ await symlink(realBin, binShim);
166
+
167
+ return {
168
+ prefix,
169
+ cliDir,
170
+ cliLockfile,
171
+ cliHiddenLockfile,
172
+ globalHiddenLockfile,
173
+ binShim,
174
+ realBin,
175
+ };
176
+ }
177
+
178
+ describe("post-upgrade-lockfile-strip", () => {
179
+ let tmpRoot: string;
180
+ let logger: LoggerMock;
181
+
182
+ beforeEach(async () => {
183
+ // Realpath up front so test expectations match `fs.realpath` output on
184
+ // macOS, where `/var` is a symlink to `/private/var` and any subsequent
185
+ // realpath() resolves through it. Without this every "expect dir" line
186
+ // would have to manually realpath() each comparison.
187
+ tmpRoot = await realpath(
188
+ await mkdtemp(path.join(os.tmpdir(), "apps-4196-")),
189
+ );
190
+ logger = makeLogger();
191
+ });
192
+
193
+ afterEach(async () => {
194
+ await rm(tmpRoot, { recursive: true, force: true });
195
+ });
196
+
197
+ describe("findGlobalCliInstallDir", () => {
198
+ it("resolves the CLI install root via which + realpath + walkup", async () => {
199
+ const tree = await makeCliInstallTree(tmpRoot);
200
+
201
+ const which = vi.fn(async () => `${tree.binShim}\n`);
202
+ const result = await findGlobalCliInstallDir(asLogger(logger), which);
203
+
204
+ expect(result).toBe(tree.cliDir);
205
+ expect(which).toHaveBeenCalledWith("superblocks");
206
+ });
207
+
208
+ it("returns undefined when which/where fails (binary not in PATH)", async () => {
209
+ const which = vi.fn(async () => {
210
+ throw new Error("not found");
211
+ });
212
+ const result = await findGlobalCliInstallDir(asLogger(logger), which);
213
+
214
+ expect(result).toBeUndefined();
215
+ expect(logger.warn).toHaveBeenCalledWith(
216
+ expect.stringContaining("which/where superblocks failed"),
217
+ expect.any(Object),
218
+ );
219
+ });
220
+
221
+ it("returns undefined when which stdout is empty", async () => {
222
+ const which = vi.fn(async () => "\n");
223
+ const result = await findGlobalCliInstallDir(asLogger(logger), which);
224
+
225
+ expect(result).toBeUndefined();
226
+ expect(logger.warn).toHaveBeenCalledWith(
227
+ expect.stringContaining("superblocks binary not found in PATH"),
228
+ );
229
+ });
230
+
231
+ it("uses only the first line when which returns multiple paths", async () => {
232
+ const tree = await makeCliInstallTree(tmpRoot);
233
+ const which = vi.fn(
234
+ async () => `${tree.binShim}\n/another/path/superblocks\n`,
235
+ );
236
+
237
+ const result = await findGlobalCliInstallDir(asLogger(logger), which);
238
+ expect(result).toBe(tree.cliDir);
239
+ });
240
+
241
+ it("accepts @superblocksteam/cli-ephemeral as the CLI package name", async () => {
242
+ const tree = await makeCliInstallTree(tmpRoot);
243
+ // Rewrite the manifest as the ephemeral alias
244
+ await writeFile(
245
+ path.join(tree.cliDir, "package.json"),
246
+ JSON.stringify(
247
+ { name: "@superblocksteam/cli-ephemeral", version: "2.1.0" },
248
+ null,
249
+ 2,
250
+ ),
251
+ );
252
+
253
+ const which = vi.fn(async () => `${tree.binShim}\n`);
254
+ const result = await findGlobalCliInstallDir(asLogger(logger), which);
255
+ expect(result).toBe(tree.cliDir);
256
+ });
257
+
258
+ it("returns undefined when no CLI manifest is found within the walk bound", async () => {
259
+ // Symlink targets a path whose ancestors carry no @superblocksteam/cli manifest.
260
+ const wrongRoot = path.join(tmpRoot, "wrong");
261
+ await mkdir(wrongRoot, { recursive: true });
262
+ const realBin = path.join(wrongRoot, "bin");
263
+ await writeFile(realBin, "#!/usr/bin/env node\n");
264
+ const shim = path.join(tmpRoot, "shim");
265
+ await symlink(realBin, shim);
266
+
267
+ const which = vi.fn(async () => `${shim}\n`);
268
+ const result = await findGlobalCliInstallDir(asLogger(logger), which);
269
+ expect(result).toBeUndefined();
270
+ });
271
+ });
272
+
273
+ describe("stripUpgradedCliLockfiles", () => {
274
+ it("strips resolved from all three lockfile locations", async () => {
275
+ const tree = await makeCliInstallTree(tmpRoot);
276
+
277
+ const result = await stripUpgradedCliLockfiles(asLogger(logger), {
278
+ installDir: tree.cliDir,
279
+ });
280
+
281
+ expect(result.installDir).toBe(tree.cliDir);
282
+ // The success info log is the operator-facing signal that the strip
283
+ // ran; assert it fires exactly once on the happy path.
284
+ expect(logger.info).toHaveBeenCalledTimes(1);
285
+ expect(logger.warn).not.toHaveBeenCalled();
286
+
287
+ // CLI's own lockfile: public-host resolved gone, integrity preserved
288
+ const cli = JSON.parse(await readFile(tree.cliLockfile, "utf-8"));
289
+ expect(cli.packages["node_modules/some-dep"].resolved).toBeUndefined();
290
+ expect(cli.packages["node_modules/some-dep"].integrity).toBe(
291
+ "sha512-abc",
292
+ );
293
+
294
+ // CLI's hidden lockfile
295
+ const hidden = JSON.parse(
296
+ await readFile(tree.cliHiddenLockfile, "utf-8"),
297
+ );
298
+ expect(hidden.packages["node_modules/some-dep"].resolved).toBeUndefined();
299
+
300
+ // Global hidden lockfile (the @superblocksteam/cli listing itself)
301
+ const global = JSON.parse(
302
+ await readFile(tree.globalHiddenLockfile, "utf-8"),
303
+ );
304
+ expect(
305
+ global.packages["node_modules/@superblocksteam/cli"].resolved,
306
+ ).toBeUndefined();
307
+ expect(
308
+ global.packages["node_modules/@superblocksteam/cli"].integrity,
309
+ ).toBe("sha512-cli");
310
+ });
311
+
312
+ it("is idempotent — second call produces no further changes", async () => {
313
+ const tree = await makeCliInstallTree(tmpRoot);
314
+
315
+ await stripUpgradedCliLockfiles(asLogger(logger), {
316
+ installDir: tree.cliDir,
317
+ });
318
+ const after1 = await readFile(tree.cliLockfile, "utf-8");
319
+
320
+ await stripUpgradedCliLockfiles(asLogger(logger), {
321
+ installDir: tree.cliDir,
322
+ });
323
+ const after2 = await readFile(tree.cliLockfile, "utf-8");
324
+
325
+ expect(after2).toBe(after1);
326
+ });
327
+
328
+ it("returns no installDir and does not throw when discovery fails", async () => {
329
+ const which = vi.fn(async () => {
330
+ throw new Error("not found");
331
+ });
332
+
333
+ const result = await stripUpgradedCliLockfiles(asLogger(logger), {
334
+ whichRunner: which,
335
+ });
336
+ expect(result).toEqual({});
337
+ // No success log should fire when there was no work to do.
338
+ expect(logger.info).not.toHaveBeenCalled();
339
+ });
340
+
341
+ it("does not throw when global hidden lockfile is absent (npm v6 layout)", async () => {
342
+ const tree = await makeCliInstallTree(tmpRoot);
343
+ await rm(tree.globalHiddenLockfile);
344
+
345
+ await expect(
346
+ stripUpgradedCliLockfiles(asLogger(logger), {
347
+ installDir: tree.cliDir,
348
+ }),
349
+ ).resolves.toEqual({ installDir: tree.cliDir });
350
+
351
+ // The other two were still stripped
352
+ const cli = JSON.parse(await readFile(tree.cliLockfile, "utf-8"));
353
+ expect(cli.packages["node_modules/some-dep"].resolved).toBeUndefined();
354
+ });
355
+
356
+ it("returns and warns when the inner strip exceeds the timeout", async () => {
357
+ // Simulate a hung strip (e.g. wedged I/O on a network-mounted prefix).
358
+ // Without the timeout, this would extend `cliUpgradePromise` and block
359
+ // dev-server startup; the timeout converts that into a bounded warn.
360
+ const tree = await makeCliInstallTree(tmpRoot);
361
+
362
+ const stripModule =
363
+ await import("@superblocksteam/vite-plugin-file-sync/npm-registry");
364
+ const stripSpy = vi
365
+ .spyOn(stripModule, "stripResolvedFromLockfile")
366
+ .mockImplementation(() => new Promise(() => {}));
367
+
368
+ try {
369
+ const result = await stripUpgradedCliLockfiles(asLogger(logger), {
370
+ installDir: tree.cliDir,
371
+ timeoutMs: 10,
372
+ });
373
+
374
+ expect(result).toEqual({ installDir: tree.cliDir });
375
+ expect(logger.warn).toHaveBeenCalledTimes(1);
376
+ expect(logger.warn).toHaveBeenCalledWith(
377
+ expect.stringContaining("strip exceeded timeout"),
378
+ expect.objectContaining({
379
+ installDir: tree.cliDir,
380
+ timeoutMs: 10,
381
+ }),
382
+ );
383
+ expect(logger.info).not.toHaveBeenCalled();
384
+ } finally {
385
+ stripSpy.mockRestore();
386
+ }
387
+ });
388
+
389
+ it("suppresses a post-timeout rejection so the abandoned strip cannot crash the pod", async () => {
390
+ // After the timeout wins the race we abandon `stripWork`. If a later
391
+ // rejection from the inner helpers (e.g. EACCES on the atomic rename)
392
+ // had no subscriber, Node would surface it as an unhandled rejection
393
+ // and exit the dev-server process. This test wires up a strip that
394
+ // rejects strictly after the timeout fires, then asserts the wrapper
395
+ // resolves cleanly AND logs the post-timeout rejection via its tail
396
+ // handler.
397
+ const tree = await makeCliInstallTree(tmpRoot);
398
+
399
+ let rejectStrip: (error: Error) => void = () => {};
400
+ const pendingStrip = new Promise<void>((_, reject) => {
401
+ rejectStrip = reject;
402
+ });
403
+
404
+ const unhandled: unknown[] = [];
405
+ const onUnhandled = (reason: unknown): void => {
406
+ unhandled.push(reason);
407
+ };
408
+ process.on("unhandledRejection", onUnhandled);
409
+
410
+ const stripModule =
411
+ await import("@superblocksteam/vite-plugin-file-sync/npm-registry");
412
+ const stripSpy = vi
413
+ .spyOn(stripModule, "stripResolvedFromLockfile")
414
+ .mockImplementation(() => pendingStrip);
415
+
416
+ try {
417
+ const result = await stripUpgradedCliLockfiles(asLogger(logger), {
418
+ installDir: tree.cliDir,
419
+ timeoutMs: 10,
420
+ });
421
+
422
+ expect(result).toEqual({ installDir: tree.cliDir });
423
+ expect(logger.warn).toHaveBeenCalledWith(
424
+ expect.stringContaining("strip exceeded timeout"),
425
+ expect.objectContaining({ installDir: tree.cliDir }),
426
+ );
427
+
428
+ rejectStrip(new Error("simulated post-timeout rejection"));
429
+ // Let the rejection propagate and the tail .catch() run.
430
+ await new Promise((resolve) => setImmediate(resolve));
431
+
432
+ expect(unhandled).toEqual([]);
433
+ expect(logger.warn).toHaveBeenCalledWith(
434
+ expect.stringContaining("background strip rejected after timeout"),
435
+ expect.objectContaining({
436
+ installDir: tree.cliDir,
437
+ error: expect.objectContaining({
438
+ message: "simulated post-timeout rejection",
439
+ }),
440
+ }),
441
+ );
442
+ } finally {
443
+ process.off("unhandledRejection", onUnhandled);
444
+ stripSpy.mockRestore();
445
+ }
446
+ });
447
+
448
+ it("does not throw or emit the success log when an inner strip rejects unexpectedly", async () => {
449
+ // Replace the CLI's lockfile with a directory at the same path — the
450
+ // inner helper's readFile will see EISDIR. The lower-level helpers
451
+ // documented as swallowing errors should keep the Promise.all from
452
+ // rejecting, but if a future refactor changes that, this test asserts
453
+ // the wrapper's belt-and-suspenders catch still keeps the function
454
+ // non-fatal AND suppresses the success log.
455
+ const tree = await makeCliInstallTree(tmpRoot);
456
+ await rm(tree.cliLockfile);
457
+ await mkdir(tree.cliLockfile);
458
+
459
+ const stripModule =
460
+ await import("@superblocksteam/vite-plugin-file-sync/npm-registry");
461
+ const stripSpy = vi
462
+ .spyOn(stripModule, "stripResolvedFromLockfile")
463
+ .mockRejectedValue(new Error("simulated unexpected throw"));
464
+
465
+ try {
466
+ await expect(
467
+ stripUpgradedCliLockfiles(asLogger(logger), {
468
+ installDir: tree.cliDir,
469
+ }),
470
+ ).resolves.toEqual({ installDir: tree.cliDir });
471
+
472
+ // Wrapper logged a warn for the unexpected error; the success info
473
+ // log MUST NOT have fired (otherwise pod logs would falsely claim
474
+ // the strip completed).
475
+ expect(logger.warn).toHaveBeenCalledTimes(1);
476
+ expect(logger.info).not.toHaveBeenCalled();
477
+ } finally {
478
+ stripSpy.mockRestore();
479
+ }
480
+ });
481
+ });
482
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Integration test: real `npm` and `pnpm` subprocesses read the
3
+ * Superblocks-owned userconfig when our env overlay is set. Pins the
4
+ * contract that `buildInstallEnv` and the dev-server startup install
5
+ * rely on.
6
+ *
7
+ * Why this exists:
8
+ * - Unit tests assert the spawned `env` includes the userconfig keys,
9
+ * but they cannot tell us whether npm/pnpm actually *honor* them.
10
+ * Earlier iterations of this PR tripped on two real CLI gaps that
11
+ * the unit tests missed:
12
+ * 1. pnpm rejects `--userconfig=` as a CLI flag (pnpm/pnpm#6036)
13
+ * 2. pnpm 11 stopped honouring `NPM_CONFIG_USERCONFIG` (config
14
+ * keys must now be `pnpm_config_*` instead).
15
+ * - This file spawns the real binaries and asserts the registry
16
+ * comes back from our fixture userconfig.
17
+ *
18
+ * Env scrubbing: pnpm/npm inject many `(npm|pnpm)_config_*` env vars
19
+ * into spawned child processes. Those take precedence over userconfig
20
+ * (env > userconfig in the npm config priority chain), which means the
21
+ * fixture would be silently overridden if we forwarded the test
22
+ * runner's env as-is. We strip those vars on the spawn so the assertion
23
+ * exercises the userconfig pathway in isolation.
24
+ */
25
+
26
+ import { execFile } from "node:child_process";
27
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
28
+ import * as os from "node:os";
29
+ import * as path from "node:path";
30
+ import { promisify } from "node:util";
31
+
32
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
33
+
34
+ import { buildInstallEnv } from "./automatic-upgrades.js";
35
+
36
+ const execFileAsync = promisify(execFile);
37
+
38
+ const FIXTURE_REGISTRY = "https://test-registry.superblocks.example.invalid/";
39
+
40
+ /**
41
+ * Strip every `(npm|pnpm)_config_*` var the test runner inherited so
42
+ * the userconfig file is the only registry source. Then layer the
43
+ * production env overlay on top. The assertion is then "with our
44
+ * env overlay applied, both binaries read the fixture" — exactly
45
+ * what production sees in a pod (no inherited package-manager env).
46
+ */
47
+ function spawnEnv(userconfigPath: string): NodeJS.ProcessEnv {
48
+ const scrubbed: NodeJS.ProcessEnv = {};
49
+ for (const [k, v] of Object.entries(process.env)) {
50
+ if (/^(npm|pnpm)_config_/i.test(k)) continue;
51
+ scrubbed[k] = v;
52
+ }
53
+ // 2nd arg is `logsDir` (omitted here); the scrubbed env is the base env.
54
+ return buildInstallEnv(userconfigPath, undefined, scrubbed);
55
+ }
56
+
57
+ /** Probe whether the binary is on PATH. Cleanly skips the test when
58
+ * the tool is unavailable (shouldn't happen in CI, but lets us run
59
+ * this file locally without special setup). */
60
+ async function whichOrSkip(bin: string): Promise<string | undefined> {
61
+ try {
62
+ const { stdout } = await execFileAsync(
63
+ process.platform === "win32" ? "where" : "which",
64
+ [bin],
65
+ );
66
+ const candidate = stdout.split(/\r?\n/)[0].trim();
67
+ return candidate || undefined;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ describe("userconfig env plumbing (real npm + pnpm subprocesses)", () => {
74
+ let tmpDir: string;
75
+ let userconfigPath: string;
76
+
77
+ beforeEach(async () => {
78
+ tmpDir = await mkdtemp(path.join(os.tmpdir(), "userconfig-integration-"));
79
+ userconfigPath = path.join(tmpDir, "npmrc");
80
+ await writeFile(userconfigPath, `registry=${FIXTURE_REGISTRY}\n`, {
81
+ mode: 0o400,
82
+ });
83
+ });
84
+
85
+ afterEach(async () => {
86
+ await rm(tmpDir, { recursive: true, force: true });
87
+ });
88
+
89
+ it("npm honors NPM_CONFIG_USERCONFIG", async () => {
90
+ const npmBin = await whichOrSkip("npm");
91
+ if (!npmBin) return;
92
+
93
+ const { stdout } = await execFileAsync(
94
+ npmBin,
95
+ ["config", "get", "registry"],
96
+ { env: spawnEnv(userconfigPath), cwd: tmpDir },
97
+ );
98
+
99
+ expect(stdout.trim()).toBe(FIXTURE_REGISTRY);
100
+ });
101
+
102
+ it("pnpm honors the userconfig (NPM_CONFIG_USERCONFIG or PNPM_CONFIG_USERCONFIG)", async () => {
103
+ // Critical: pnpm 11 stopped honouring NPM_CONFIG_USERCONFIG. The
104
+ // production env overlay sets BOTH NPM_CONFIG_USERCONFIG and
105
+ // PNPM_CONFIG_USERCONFIG to cover pnpm <= 10 and pnpm 11+. If a
106
+ // future pnpm bump changes the contract again, this test fails
107
+ // loudly.
108
+ const pnpmBin = await whichOrSkip("pnpm");
109
+ if (!pnpmBin) return;
110
+
111
+ const { stdout } = await execFileAsync(
112
+ pnpmBin,
113
+ ["config", "get", "registry"],
114
+ { env: spawnEnv(userconfigPath), cwd: tmpDir },
115
+ );
116
+
117
+ expect(stdout.trim()).toBe(FIXTURE_REGISTRY);
118
+ });
119
+
120
+ it("pnpm REJECTS --userconfig= as a CLI flag (pnpm/pnpm#6036)", async () => {
121
+ // This is the contract that rules out the per-flag approach for
122
+ // pnpm. Documents the CLI-flag asymmetry: npm accepts the flag,
123
+ // pnpm rejects it.
124
+ const pnpmBin = await whichOrSkip("pnpm");
125
+ if (!pnpmBin) return;
126
+
127
+ await expect(
128
+ execFileAsync(
129
+ pnpmBin,
130
+ [`--userconfig=${userconfigPath}`, "config", "get", "registry"],
131
+ { cwd: tmpDir },
132
+ ),
133
+ ).rejects.toThrow(/Unknown option.*userconfig/);
134
+ });
135
+
136
+ it("pnpm 11 ignores NPM_CONFIG_USERCONFIG when PNPM_CONFIG_USERCONFIG is unset", async () => {
137
+ // Regression guard for the pnpm 11 behaviour change. If this test
138
+ // ever starts FAILING (i.e. pnpm 11 reads the fixture from
139
+ // NPM_CONFIG_USERCONFIG alone) we can drop the PNPM_CONFIG_*
140
+ // overlay — but until then, both vars are required.
141
+ //
142
+ // We can only run this when pnpm >= 11 is installed; for older
143
+ // pnpm versions NPM_CONFIG_USERCONFIG works and the regression
144
+ // doesn't apply.
145
+ const pnpmBin = await whichOrSkip("pnpm");
146
+ if (!pnpmBin) return;
147
+
148
+ const { stdout: versionOut } = await execFileAsync(pnpmBin, ["--version"]);
149
+ const major = Number.parseInt(versionOut.trim().split(".")[0], 10);
150
+ if (!Number.isFinite(major) || major < 11) return;
151
+
152
+ const scrubbed: NodeJS.ProcessEnv = {};
153
+ for (const [k, v] of Object.entries(process.env)) {
154
+ if (/^(npm|pnpm)_config_/i.test(k)) continue;
155
+ scrubbed[k] = v;
156
+ }
157
+ scrubbed.NPM_CONFIG_USERCONFIG = userconfigPath;
158
+ // Deliberately do NOT set PNPM_CONFIG_USERCONFIG.
159
+
160
+ const { stdout } = await execFileAsync(
161
+ pnpmBin,
162
+ ["config", "get", "registry"],
163
+ { env: scrubbed, cwd: tmpDir },
164
+ );
165
+
166
+ expect(stdout.trim()).not.toBe(FIXTURE_REGISTRY);
167
+ });
168
+
169
+ it("npm does NOT read the fixture registry when no userconfig env var is set", async () => {
170
+ // Sanity check that the test isn't passing because the binary
171
+ // defaults to our fixture URL.
172
+ const npmBin = await whichOrSkip("npm");
173
+ if (!npmBin) return;
174
+
175
+ const scrubbed: NodeJS.ProcessEnv = {};
176
+ for (const [k, v] of Object.entries(process.env)) {
177
+ if (/^(npm|pnpm)_config_/i.test(k)) continue;
178
+ scrubbed[k] = v;
179
+ }
180
+
181
+ const { stdout } = await execFileAsync(
182
+ npmBin,
183
+ ["config", "get", "registry"],
184
+ { env: scrubbed, cwd: tmpDir },
185
+ );
186
+
187
+ expect(stdout.trim()).not.toBe(FIXTURE_REGISTRY);
188
+ });
189
+ });