@superblocksteam/sdk 2.0.123 → 2.0.124-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts +37 -1
  3. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  4. package/dist/cli-replacement/automatic-upgrades.js +162 -10
  5. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  6. package/dist/cli-replacement/automatic-upgrades.test.js +377 -8
  7. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  8. package/dist/cli-replacement/dependency-install-classifier.d.mts +21 -0
  9. package/dist/cli-replacement/dependency-install-classifier.d.mts.map +1 -0
  10. package/dist/cli-replacement/dependency-install-classifier.mjs +83 -0
  11. package/dist/cli-replacement/dependency-install-classifier.mjs.map +1 -0
  12. package/dist/cli-replacement/dependency-install-classifier.test.d.mts +2 -0
  13. package/dist/cli-replacement/dependency-install-classifier.test.d.mts.map +1 -0
  14. package/dist/cli-replacement/dependency-install-classifier.test.mjs +51 -0
  15. package/dist/cli-replacement/dependency-install-classifier.test.mjs.map +1 -0
  16. package/dist/cli-replacement/dev-s3-restore.test.mjs +403 -14
  17. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  18. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +33 -2
  19. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  20. package/dist/cli-replacement/dev-token-priming.test.d.mts +31 -0
  21. package/dist/cli-replacement/dev-token-priming.test.d.mts.map +1 -0
  22. package/dist/cli-replacement/dev-token-priming.test.mjs +87 -0
  23. package/dist/cli-replacement/dev-token-priming.test.mjs.map +1 -0
  24. package/dist/cli-replacement/dev.d.mts +47 -0
  25. package/dist/cli-replacement/dev.d.mts.map +1 -1
  26. package/dist/cli-replacement/dev.interception.test.d.mts +2 -0
  27. package/dist/cli-replacement/dev.interception.test.d.mts.map +1 -0
  28. package/dist/cli-replacement/dev.interception.test.mjs +68 -0
  29. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -0
  30. package/dist/cli-replacement/dev.mjs +486 -65
  31. package/dist/cli-replacement/dev.mjs.map +1 -1
  32. package/dist/cli-replacement/home-npmrc.d.mts +180 -0
  33. package/dist/cli-replacement/home-npmrc.d.mts.map +1 -0
  34. package/dist/cli-replacement/home-npmrc.mjs +283 -0
  35. package/dist/cli-replacement/home-npmrc.mjs.map +1 -0
  36. package/dist/cli-replacement/home-npmrc.test.d.mts +10 -0
  37. package/dist/cli-replacement/home-npmrc.test.d.mts.map +1 -0
  38. package/dist/cli-replacement/home-npmrc.test.mjs +582 -0
  39. package/dist/cli-replacement/home-npmrc.test.mjs.map +1 -0
  40. package/dist/cli-replacement/install-packages.classify.test.d.mts +2 -0
  41. package/dist/cli-replacement/install-packages.classify.test.d.mts.map +1 -0
  42. package/dist/cli-replacement/install-packages.classify.test.mjs +125 -0
  43. package/dist/cli-replacement/install-packages.classify.test.mjs.map +1 -0
  44. package/dist/cli-replacement/install-packages.npm-registry.test.d.mts +2 -0
  45. package/dist/cli-replacement/install-packages.npm-registry.test.d.mts.map +1 -0
  46. package/dist/cli-replacement/install-packages.npm-registry.test.mjs +260 -0
  47. package/dist/cli-replacement/install-packages.npm-registry.test.mjs.map +1 -0
  48. package/dist/cli-replacement/post-upgrade-lockfile-strip.d.mts +58 -0
  49. package/dist/cli-replacement/post-upgrade-lockfile-strip.d.mts.map +1 -0
  50. package/dist/cli-replacement/post-upgrade-lockfile-strip.mjs +224 -0
  51. package/dist/cli-replacement/post-upgrade-lockfile-strip.mjs.map +1 -0
  52. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.d.mts +11 -0
  53. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.d.mts.map +1 -0
  54. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.mjs +317 -0
  55. package/dist/cli-replacement/post-upgrade-lockfile-strip.test.mjs.map +1 -0
  56. package/dist/cli-replacement/userconfig-env.integration.test.d.mts +26 -0
  57. package/dist/cli-replacement/userconfig-env.integration.test.d.mts.map +1 -0
  58. package/dist/cli-replacement/userconfig-env.integration.test.mjs +148 -0
  59. package/dist/cli-replacement/userconfig-env.integration.test.mjs.map +1 -0
  60. package/dist/dev-utils/dev-server-metrics.d.mts +25 -0
  61. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  62. package/dist/dev-utils/dev-server-metrics.mjs +84 -0
  63. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  64. package/dist/dev-utils/dev-server-metrics.test.d.mts +2 -0
  65. package/dist/dev-utils/dev-server-metrics.test.d.mts.map +1 -0
  66. package/dist/dev-utils/dev-server-metrics.test.mjs +26 -0
  67. package/dist/dev-utils/dev-server-metrics.test.mjs.map +1 -0
  68. package/dist/dev-utils/dev-server.d.mts +23 -1
  69. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  70. package/dist/dev-utils/dev-server.mjs +21 -9
  71. package/dist/dev-utils/dev-server.mjs.map +1 -1
  72. package/dist/dev-utils/dev-server.status.test.d.mts +2 -0
  73. package/dist/dev-utils/dev-server.status.test.d.mts.map +1 -0
  74. package/dist/dev-utils/dev-server.status.test.mjs +41 -0
  75. package/dist/dev-utils/dev-server.status.test.mjs.map +1 -0
  76. package/dist/dev-utils/token-manager.d.ts +31 -0
  77. package/dist/dev-utils/token-manager.d.ts.map +1 -1
  78. package/dist/dev-utils/token-manager.js +34 -0
  79. package/dist/dev-utils/token-manager.js.map +1 -1
  80. package/dist/telemetry/local-obs.js +1 -1
  81. package/dist/telemetry/local-obs.js.map +1 -1
  82. package/dist/telemetry/util.js +1 -1
  83. package/dist/types/scoped-jwt-token-payload.d.ts +1 -0
  84. package/dist/types/scoped-jwt-token-payload.d.ts.map +1 -1
  85. package/dist/version-control.d.mts.map +1 -1
  86. package/dist/version-control.mjs +6 -7
  87. package/dist/version-control.mjs.map +1 -1
  88. package/package.json +12 -12
  89. package/src/cli-replacement/automatic-upgrades.test.ts +530 -8
  90. package/src/cli-replacement/automatic-upgrades.ts +179 -7
  91. package/src/cli-replacement/dependency-install-classifier.mts +118 -0
  92. package/src/cli-replacement/dependency-install-classifier.test.mts +72 -0
  93. package/src/cli-replacement/dev-s3-restore.test.mts +554 -14
  94. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +35 -2
  95. package/src/cli-replacement/dev-token-priming.test.mts +103 -0
  96. package/src/cli-replacement/dev.interception.test.mts +80 -0
  97. package/src/cli-replacement/dev.mts +597 -95
  98. package/src/cli-replacement/home-npmrc.mts +409 -0
  99. package/src/cli-replacement/home-npmrc.test.mts +757 -0
  100. package/src/cli-replacement/install-packages.classify.test.mts +168 -0
  101. package/src/cli-replacement/install-packages.npm-registry.test.mts +345 -0
  102. package/src/cli-replacement/post-upgrade-lockfile-strip.mts +296 -0
  103. package/src/cli-replacement/post-upgrade-lockfile-strip.test.mts +482 -0
  104. package/src/cli-replacement/userconfig-env.integration.test.mts +189 -0
  105. package/src/dev-utils/dev-server-metrics.mts +96 -0
  106. package/src/dev-utils/dev-server-metrics.test.mts +38 -0
  107. package/src/dev-utils/dev-server.mts +48 -8
  108. package/src/dev-utils/dev-server.status.test.mts +58 -0
  109. package/src/dev-utils/token-manager.ts +36 -0
  110. package/src/telemetry/local-obs.ts +1 -1
  111. package/src/telemetry/util.ts +1 -1
  112. package/src/types/scoped-jwt-token-payload.ts +1 -0
  113. package/src/version-control.mts +8 -6
  114. package/tsconfig.tsbuildinfo +1 -1
  115. package/.turbo/turbo-publish-package.log +0 -0
@@ -0,0 +1,757 @@
1
+ /**
2
+ * Tests for the CLI-startup `~/.superblocks/npmrc` writer.
3
+ *
4
+ * Strategy: stand up a tmpdir as a fake `$HOME` and a fake
5
+ * `NpmRegistryClient` whose `getConfig()` returns the canned response
6
+ * under test. Asserts cover the four state transitions plus the 0o400
7
+ * mode bit.
8
+ */
9
+
10
+ import {
11
+ mkdir,
12
+ mkdtemp,
13
+ readFile,
14
+ rm,
15
+ stat,
16
+ writeFile,
17
+ } from "node:fs/promises";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
+
23
+ import * as npmRegistry from "@superblocksteam/vite-plugin-file-sync/npm-registry";
24
+ import type {
25
+ NpmRegistryClient,
26
+ NpmRegistryFetchResult,
27
+ } from "@superblocksteam/vite-plugin-file-sync/npm-registry";
28
+
29
+ // Wrap the real npm-registry module but make `snapshotInitialNpmrc` a spy
30
+ // that delegates to the genuine implementation by default. Only the
31
+ // APPS-4428 failure test overrides it (to simulate a silent EXDEV-class
32
+ // snapshot failure) so the userconfig-preservation guard can be exercised
33
+ // without crossing filesystems; every other test keeps the real
34
+ // hardlink-backed snapshot behaviour.
35
+ vi.mock("@superblocksteam/vite-plugin-file-sync/npm-registry", async () => {
36
+ const actual = await vi.importActual<typeof npmRegistry>(
37
+ "@superblocksteam/vite-plugin-file-sync/npm-registry",
38
+ );
39
+ return {
40
+ ...actual,
41
+ snapshotInitialNpmrc: vi.fn(actual.snapshotInitialNpmrc),
42
+ };
43
+ });
44
+
45
+ import type { Logger } from "../telemetry/logging.js";
46
+ import {
47
+ HOME_NPMRC_MODE,
48
+ superblocksLogsPath,
49
+ superblocksNpmrcBackupPath,
50
+ superblocksNpmrcPath,
51
+ syncHomeNpmrc,
52
+ } from "./home-npmrc.mjs";
53
+
54
+ /** Typed handle to the mocked snapshot fn (delegates to the real impl by
55
+ * default; overridden only in the failure test). */
56
+ const snapshotInitialNpmrcMock = vi.mocked(npmRegistry.snapshotInitialNpmrc);
57
+
58
+ /** Resolves the userconfig path under the test's tmpdir `homeDir`. Keeps
59
+ * the test free of an inline literal so a future relocation only touches
60
+ * `superblocksNpmrcPath`. */
61
+ function npmrcPath(homeDir: string): string {
62
+ return superblocksNpmrcPath(homeDir);
63
+ }
64
+
65
+ type LoggerMock = {
66
+ info: ReturnType<typeof vi.fn>;
67
+ warn: ReturnType<typeof vi.fn>;
68
+ error: ReturnType<typeof vi.fn>;
69
+ debug: ReturnType<typeof vi.fn>;
70
+ };
71
+
72
+ function makeLogger(): LoggerMock {
73
+ return {
74
+ info: vi.fn(),
75
+ warn: vi.fn(),
76
+ error: vi.fn(),
77
+ debug: vi.fn(),
78
+ };
79
+ }
80
+
81
+ function asLogger(mock: LoggerMock): Logger {
82
+ return mock as unknown as Logger;
83
+ }
84
+
85
+ /**
86
+ * Minimal stub that satisfies the `NpmRegistryClient` shape
87
+ * `syncHomeNpmrc` actually exercises (only `getConfig`). Casting through
88
+ * `as unknown as NpmRegistryClient` keeps the wider class surface from
89
+ * forcing test churn.
90
+ */
91
+ function makeClient(impl: () => Promise<NpmRegistryFetchResult>): {
92
+ client: NpmRegistryClient;
93
+ getConfig: ReturnType<typeof vi.fn>;
94
+ } {
95
+ const getConfig = vi.fn(impl);
96
+ const client = {
97
+ getConfig,
98
+ } as unknown as NpmRegistryClient;
99
+ return { client, getConfig };
100
+ }
101
+
102
+ const CONFIGURED: NpmRegistryFetchResult = {
103
+ source: "configured",
104
+ config: {
105
+ configured: true,
106
+ default: {
107
+ url: "https://artifactory.example.com/api/npm/npm/",
108
+ token: "test-token-abc",
109
+ },
110
+ },
111
+ };
112
+
113
+ const STALE: NpmRegistryFetchResult = {
114
+ source: "stale",
115
+ config: {
116
+ configured: true,
117
+ default: {
118
+ url: "https://artifactory.example.com/api/npm/npm/",
119
+ token: "test-token-abc",
120
+ },
121
+ },
122
+ };
123
+
124
+ const NOT_CONFIGURED: NpmRegistryFetchResult = {
125
+ source: "not-configured",
126
+ config: { configured: false },
127
+ };
128
+
129
+ const UNREACHABLE: NpmRegistryFetchResult = {
130
+ source: "unreachable",
131
+ config: { configured: false },
132
+ };
133
+
134
+ describe("syncHomeNpmrc", () => {
135
+ let homeDir: string;
136
+
137
+ beforeEach(async () => {
138
+ homeDir = await mkdtemp(path.join(os.tmpdir(), "home-npmrc-test-"));
139
+ });
140
+
141
+ afterEach(async () => {
142
+ await rm(homeDir, { recursive: true, force: true });
143
+ // `mockReset` (not `mockClear`) so the `mockImplementationOnce` queue
144
+ // is also drained. With `mockClear` a queued one-time impl from a
145
+ // test that never invoked the spy (e.g. an early throw) would leak
146
+ // into the next test and produce a hard-to-diagnose cascade failure.
147
+ // In vitest 4 `mockReset` restores the implementation passed to
148
+ // `vi.fn(impl)` — the real `snapshotInitialNpmrc` — so subsequent
149
+ // tests keep delegating to the genuine hardlink-backed snapshot.
150
+ snapshotInitialNpmrcMock.mockReset();
151
+ });
152
+
153
+ describe("configured → write", () => {
154
+ it("writes ~/.superblocks/npmrc with the registry config at mode 0o400", async () => {
155
+ const { client } = makeClient(async () => CONFIGURED);
156
+ const logger = makeLogger();
157
+
158
+ const result = await syncHomeNpmrc({
159
+ npmRegistryClient: client,
160
+ logger: asLogger(logger),
161
+ homeDir,
162
+ });
163
+
164
+ expect(result.outcome).toBe("written");
165
+ expect(result.source).toBe("configured");
166
+ expect(result.path).toBe(npmrcPath(homeDir));
167
+
168
+ const content = await readFile(npmrcPath(homeDir), "utf-8");
169
+ expect(content).toContain(
170
+ "registry=https://artifactory.example.com/api/npm/npm/",
171
+ );
172
+ expect(content).toContain(
173
+ "//artifactory.example.com/api/npm/npm/:_authToken=test-token-abc",
174
+ );
175
+
176
+ const stats = await stat(npmrcPath(homeDir));
177
+ // mask off the file-type bits; only mode matters.
178
+ expect(stats.mode & 0o777).toBe(HOME_NPMRC_MODE);
179
+ });
180
+
181
+ it("creates the ~/.superblocks parent dir when absent (local CLI cold start)", async () => {
182
+ // Pods have /home/node/.superblocks created at image build, but a
183
+ // developer running `superblocks dev` against a fresh $HOME may
184
+ // not. `syncHomeNpmrc` must `mkdir -p` before writing or the
185
+ // atomic-rename inside `writeNpmrc` fails ENOENT.
186
+ const { client } = makeClient(async () => CONFIGURED);
187
+ const logger = makeLogger();
188
+
189
+ await expect(
190
+ stat(path.join(homeDir, ".superblocks")),
191
+ ).rejects.toMatchObject({ code: "ENOENT" });
192
+
193
+ const result = await syncHomeNpmrc({
194
+ npmRegistryClient: client,
195
+ logger: asLogger(logger),
196
+ homeDir,
197
+ });
198
+
199
+ expect(result.outcome).toBe("written");
200
+ const dirStats = await stat(path.join(homeDir, ".superblocks"));
201
+ expect(dirStats.isDirectory()).toBe(true);
202
+ const fileStats = await stat(npmrcPath(homeDir));
203
+ expect(fileStats.mode & 0o777).toBe(HOME_NPMRC_MODE);
204
+ });
205
+
206
+ it("treats `stale` (last-known-good) the same as `configured`", async () => {
207
+ const { client } = makeClient(async () => STALE);
208
+ const logger = makeLogger();
209
+
210
+ const result = await syncHomeNpmrc({
211
+ npmRegistryClient: client,
212
+ logger: asLogger(logger),
213
+ homeDir,
214
+ });
215
+
216
+ expect(result.outcome).toBe("written");
217
+ expect(result.source).toBe("stale");
218
+ const stats = await stat(npmrcPath(homeDir));
219
+ expect(stats.mode & 0o777).toBe(HOME_NPMRC_MODE);
220
+ });
221
+
222
+ it("overwrites a 0o400 file from a previous sync", async () => {
223
+ // The whole point of the atomic-rename pattern is that the target
224
+ // file's mode does not block subsequent re-writes. Regression test
225
+ // for that claim — switching away from atomic rename would EACCES.
226
+ const { client } = makeClient(async () => CONFIGURED);
227
+ const logger = makeLogger();
228
+
229
+ await syncHomeNpmrc({
230
+ npmRegistryClient: client,
231
+ logger: asLogger(logger),
232
+ homeDir,
233
+ });
234
+ const second = await syncHomeNpmrc({
235
+ npmRegistryClient: client,
236
+ logger: asLogger(logger),
237
+ homeDir,
238
+ });
239
+
240
+ expect(second.outcome).toBe("written");
241
+ const stats = await stat(npmrcPath(homeDir));
242
+ expect(stats.mode & 0o777).toBe(HOME_NPMRC_MODE);
243
+ });
244
+
245
+ it("overwrites an image-baked default with the server config when set, but preserves the @superblocksteam scope and its auth", async () => {
246
+ // EE pods ship with `/home/node/.superblocks/npmrc` pre-populated
247
+ // with a GHPR scope mapping + token. When the runtime org
248
+ // configures its own private registry:
249
+ // - The server-fetched default registry wins over any baked
250
+ // default (none here, but the assertion pins that
251
+ // `registry=…artifactory…` line lands).
252
+ // - The baked `@superblocksteam:registry=…npm.pkg.github.com/`
253
+ // scope mapping must SURVIVE (it's in `PRESERVE_NPMRC_SCOPES`
254
+ // so `@superblocksteam/*` packages keep flowing through GHPR).
255
+ // - And its matching `//npm.pkg.github.com/:_authToken=…` line
256
+ // must survive with it — otherwise every `@superblocksteam/*`
257
+ // install 401s against GHPR (APPS-4300). The earlier shape of
258
+ // this test asserted the auth line was dropped, which was the
259
+ // bug.
260
+ const baked =
261
+ "//npm.pkg.github.com/:_authToken=ghpr-baked\n" +
262
+ "@superblocksteam:registry=https://npm.pkg.github.com/\n";
263
+ await mkdir(path.join(homeDir, ".superblocks"), { recursive: true });
264
+ await writeFile(npmrcPath(homeDir), baked, { mode: 0o600 });
265
+
266
+ const { client } = makeClient(async () => CONFIGURED);
267
+ const logger = makeLogger();
268
+
269
+ const result = await syncHomeNpmrc({
270
+ npmRegistryClient: client,
271
+ logger: asLogger(logger),
272
+ homeDir,
273
+ });
274
+
275
+ expect(result.outcome).toBe("written");
276
+ const content = await readFile(npmrcPath(homeDir), "utf-8");
277
+ expect(content).toContain(
278
+ "registry=https://artifactory.example.com/api/npm/npm/",
279
+ );
280
+ expect(content).toContain(
281
+ "@superblocksteam:registry=https://npm.pkg.github.com/",
282
+ );
283
+ expect(content).toContain("//npm.pkg.github.com/:_authToken=ghpr-baked");
284
+ });
285
+ });
286
+
287
+ describe("not-configured → restore image-default (or remove if no backup)", () => {
288
+ it("restores the image-default userconfig from the backup captured on a prior configured boot (APPS-4328)", async () => {
289
+ // Simulates the lifecycle gpoulios-sb flagged on PR #19621:
290
+ // 1. Org is configured → syncHomeNpmrc writes private-registry
291
+ // lines AND captures the image-baked baseline to
292
+ // ~/.superblocks/npmrc.default via hardlink.
293
+ // 2. Org is deconfigured → syncHomeNpmrc must restore the
294
+ // baseline so subsequent npm/pnpm pinned at the userconfig
295
+ // do not keep resolving through the previous org's registry.
296
+ const baked =
297
+ "//npm.pkg.github.com/:_authToken=ghpr-baked\n" +
298
+ "@superblocksteam:registry=https://npm.pkg.github.com/\n";
299
+ await mkdir(path.join(homeDir, ".superblocks"), { recursive: true });
300
+ await writeFile(npmrcPath(homeDir), baked, { mode: 0o600 });
301
+
302
+ // Step 1: configured → rewrite + snapshot.
303
+ const configuredClient = makeClient(async () => CONFIGURED).client;
304
+ await syncHomeNpmrc({
305
+ npmRegistryClient: configuredClient,
306
+ logger: asLogger(makeLogger()),
307
+ homeDir,
308
+ });
309
+ const rewritten = await readFile(npmrcPath(homeDir), "utf-8");
310
+ expect(rewritten).toContain(
311
+ "registry=https://artifactory.example.com/api/npm/npm/",
312
+ );
313
+ expect(rewritten).not.toBe(baked);
314
+ expect(await readFile(superblocksNpmrcBackupPath(homeDir), "utf-8")).toBe(
315
+ baked,
316
+ );
317
+
318
+ // Step 2: not-configured → restore image-default.
319
+ const notConfiguredClient = makeClient(async () => NOT_CONFIGURED).client;
320
+ const logger = makeLogger();
321
+ const result = await syncHomeNpmrc({
322
+ npmRegistryClient: notConfiguredClient,
323
+ logger: asLogger(logger),
324
+ homeDir,
325
+ });
326
+
327
+ expect(result.outcome).toBe("restored-not-configured");
328
+ const restored = await readFile(npmrcPath(homeDir), "utf-8");
329
+ expect(restored).toBe(baked);
330
+ expect(restored).not.toContain(
331
+ "registry=https://artifactory.example.com/api/npm/npm/",
332
+ );
333
+ });
334
+
335
+ it("removes the Superblocks-owned userconfig when no backup exists and the org has been deconfigured", async () => {
336
+ // Cold-boot case: a Superblocks-owned userconfig is on disk from
337
+ // a previous run (or a manual smoke test) but no .npmrc.default
338
+ // backup is alongside it. Leaving the file in place would let
339
+ // long-lived npm/pnpm subprocesses pinned at the userconfig
340
+ // continue resolving through a stale registry. Remove it
341
+ // instead so the next install falls through to public npm.
342
+ const stale =
343
+ "registry=https://artifactory.example.com/api/npm/npm/\n" +
344
+ "//artifactory.example.com/api/npm/npm/:_authToken=stale\n";
345
+ await mkdir(path.join(homeDir, ".superblocks"), { recursive: true });
346
+ await writeFile(npmrcPath(homeDir), stale, { mode: 0o600 });
347
+ await expect(
348
+ stat(superblocksNpmrcBackupPath(homeDir)),
349
+ ).rejects.toMatchObject({ code: "ENOENT" });
350
+
351
+ const { client } = makeClient(async () => NOT_CONFIGURED);
352
+ const logger = makeLogger();
353
+
354
+ const result = await syncHomeNpmrc({
355
+ npmRegistryClient: client,
356
+ logger: asLogger(logger),
357
+ homeDir,
358
+ });
359
+
360
+ expect(result.outcome).toBe("restored-not-configured");
361
+ await expect(stat(npmrcPath(homeDir))).rejects.toMatchObject({
362
+ code: "ENOENT",
363
+ });
364
+ });
365
+
366
+ it("fails closed on snapshot failure: skips the managed rewrite AND preserves the baked file on later deconfigure (APPS-4428)", async () => {
367
+ // The hole: `snapshotInitialNpmrc` is best-effort and can fail
368
+ // silently (e.g. EXDEV when ~/.superblocks/ and the backup straddle
369
+ // filesystems). When that happens, a real baked-in userconfig
370
+ // exists with NO restore baseline.
371
+ //
372
+ // PR #19690 first try only guarded the unlink on the deconfigure
373
+ // path, but still let `writeNpmrc` rewrite the file with the
374
+ // managed private-registry content. gpoulios-sb caught that this
375
+ // leaves a stale Artifactory token + registry on disk after the
376
+ // org is deconfigured — the exact stale-userconfig leak the
377
+ // restore path is supposed to avoid.
378
+ //
379
+ // Fix: fail closed on the snapshot-failed branch. The configured
380
+ // sync refuses to overwrite the baked userconfig, so the
381
+ // deconfigure path inherits an unchanged baked file (which the
382
+ // snapshot-failure guard then correctly leaves in place).
383
+ const baked =
384
+ "//npm.pkg.github.com/:_authToken=ghpr-baked\n" +
385
+ "@superblocksteam:registry=https://npm.pkg.github.com/\n";
386
+ await mkdir(path.join(homeDir, ".superblocks"), { recursive: true });
387
+ await writeFile(npmrcPath(homeDir), baked, { mode: 0o600 });
388
+
389
+ // Step 1: configured → snapshot fails silently. The managed
390
+ // rewrite MUST be skipped; the baked content survives untouched.
391
+ snapshotInitialNpmrcMock.mockImplementationOnce(async () => "failed");
392
+ const configuredClient = makeClient(async () => CONFIGURED).client;
393
+ const configuredLogger = makeLogger();
394
+ const rewriteResult = await syncHomeNpmrc({
395
+ npmRegistryClient: configuredClient,
396
+ logger: asLogger(configuredLogger),
397
+ homeDir,
398
+ });
399
+
400
+ expect(rewriteResult.outcome).toBe("skipped-snapshot-failed");
401
+ expect(snapshotInitialNpmrcMock).toHaveBeenCalledWith(
402
+ npmrcPath(homeDir),
403
+ superblocksNpmrcBackupPath(homeDir),
404
+ );
405
+ await expect(
406
+ snapshotInitialNpmrcMock.mock.results[0]?.value,
407
+ ).resolves.toBe("failed");
408
+ // The userconfig is still the baked content — no Artifactory
409
+ // registry/token, no managed lines.
410
+ const preserved = await readFile(npmrcPath(homeDir), "utf-8");
411
+ expect(preserved).toBe(baked);
412
+ expect(preserved).not.toContain("artifactory.example.com");
413
+ // And no backup exists (the snapshot failed).
414
+ await expect(
415
+ stat(superblocksNpmrcBackupPath(homeDir)),
416
+ ).rejects.toMatchObject({ code: "ENOENT" });
417
+ // The refusal to overwrite is surfaced as a warn for telemetry.
418
+ expect(configuredLogger.warn).toHaveBeenCalledWith(
419
+ expect.stringContaining("userconfig snapshot failed"),
420
+ expect.objectContaining({
421
+ path: npmrcPath(homeDir),
422
+ source: "configured",
423
+ }),
424
+ );
425
+
426
+ // Step 2: not-configured. With no backup and a recorded snapshot
427
+ // failure, the userconfig (still the baked content) must survive.
428
+ // The deconfigure path's destructive unlink is the original
429
+ // APPS-4328 hole; the snapshot-failure guard withholds it here and
430
+ // returns the dedicated `skipped-snapshot-failed` outcome — NOT
431
+ // `restored-not-configured`, since nothing was restored or removed.
432
+ const notConfiguredClient = makeClient(async () => NOT_CONFIGURED).client;
433
+ const deconfigureLogger = makeLogger();
434
+ const result = await syncHomeNpmrc({
435
+ npmRegistryClient: notConfiguredClient,
436
+ logger: asLogger(deconfigureLogger),
437
+ homeDir,
438
+ });
439
+
440
+ expect(result.outcome).toBe("skipped-snapshot-failed");
441
+ const survived = await readFile(npmrcPath(homeDir), "utf-8");
442
+ expect(survived).toBe(baked);
443
+ expect(deconfigureLogger.warn).toHaveBeenCalledWith(
444
+ expect.stringContaining("userconfig snapshot failed earlier"),
445
+ expect.objectContaining({ path: npmrcPath(homeDir) }),
446
+ );
447
+ // And no spurious "restored image-default" info to confuse the
448
+ // operator — the warn above already explains the leave-in-place.
449
+ expect(deconfigureLogger.info).not.toHaveBeenCalled();
450
+ });
451
+
452
+ it("does not create ~/.superblocks/npmrc when the file does not exist", async () => {
453
+ const { client } = makeClient(async () => NOT_CONFIGURED);
454
+ const logger = makeLogger();
455
+
456
+ const result = await syncHomeNpmrc({
457
+ npmRegistryClient: client,
458
+ logger: asLogger(logger),
459
+ homeDir,
460
+ });
461
+
462
+ expect(result.outcome).toBe("restored-not-configured");
463
+ await expect(stat(npmrcPath(homeDir))).rejects.toMatchObject({
464
+ code: "ENOENT",
465
+ });
466
+ });
467
+
468
+ it("captures the image-baked userconfig as .npmrc.default on the first configured write", async () => {
469
+ // Regression guard for the capture half of APPS-4328: the
470
+ // hardlink must land BEFORE writeNpmrc rewrites the userconfig
471
+ // or the backup will hold the rewritten (private-registry)
472
+ // content instead of the image baseline. Verified by checking
473
+ // that the captured file equals the original baked content
474
+ // byte-for-byte AFTER the configured rewrite has happened.
475
+ const baked =
476
+ "//npm.pkg.github.com/:_authToken=ghpr-baked\n" +
477
+ "@superblocksteam:registry=https://npm.pkg.github.com/\n";
478
+ await mkdir(path.join(homeDir, ".superblocks"), { recursive: true });
479
+ await writeFile(npmrcPath(homeDir), baked, { mode: 0o600 });
480
+
481
+ const { client } = makeClient(async () => CONFIGURED);
482
+ await syncHomeNpmrc({
483
+ npmRegistryClient: client,
484
+ logger: asLogger(makeLogger()),
485
+ homeDir,
486
+ });
487
+
488
+ const captured = await readFile(
489
+ superblocksNpmrcBackupPath(homeDir),
490
+ "utf-8",
491
+ );
492
+ expect(captured).toBe(baked);
493
+ });
494
+ });
495
+
496
+ describe("unreachable → leave file alone", () => {
497
+ it("does NOT delete the userconfig when the server is unreachable with cold cache", async () => {
498
+ // A cold cache + transient outage at startup must not punch a hole
499
+ // in the userconfig right before the CLI auto-upgrade runs. The
500
+ // client surfaces `source: "unreachable"` precisely so we can
501
+ // branch here.
502
+ const { client, getConfig } = makeClient(async () => CONFIGURED);
503
+ const logger = makeLogger();
504
+ await syncHomeNpmrc({
505
+ npmRegistryClient: client,
506
+ logger: asLogger(logger),
507
+ homeDir,
508
+ });
509
+ const before = await readFile(npmrcPath(homeDir), "utf-8");
510
+
511
+ getConfig.mockResolvedValueOnce(UNREACHABLE);
512
+ const result = await syncHomeNpmrc({
513
+ npmRegistryClient: client,
514
+ logger: asLogger(logger),
515
+ homeDir,
516
+ });
517
+
518
+ expect(result.outcome).toBe("skipped-unreachable");
519
+ expect(logger.warn).toHaveBeenCalledWith(
520
+ expect.stringContaining("registry server unreachable"),
521
+ expect.objectContaining({
522
+ path: npmrcPath(homeDir),
523
+ }),
524
+ );
525
+ const after = await readFile(npmrcPath(homeDir), "utf-8");
526
+ expect(after).toBe(before);
527
+ });
528
+
529
+ it("does not create ~/.superblocks/npmrc when the server is unreachable and no file exists", async () => {
530
+ const { client } = makeClient(async () => UNREACHABLE);
531
+ const logger = makeLogger();
532
+
533
+ const result = await syncHomeNpmrc({
534
+ npmRegistryClient: client,
535
+ logger: asLogger(logger),
536
+ homeDir,
537
+ });
538
+
539
+ expect(result.outcome).toBe("skipped-unreachable");
540
+ await expect(stat(npmrcPath(homeDir))).rejects.toMatchObject({
541
+ code: "ENOENT",
542
+ });
543
+ });
544
+ });
545
+
546
+ // -------------------------------------------------------------------------
547
+ // APPS-4368: ignore-scripts policy baked into ~/.npmrc
548
+ // -------------------------------------------------------------------------
549
+
550
+ describe("ignore-scripts policy (APPS-4368)", () => {
551
+ const NOT_CONFIGURED_SCRIPTS_OFF: NpmRegistryFetchResult = {
552
+ source: "not-configured",
553
+ config: { configured: false, allowInstallScripts: false },
554
+ };
555
+
556
+ const CONFIGURED_SCRIPTS_OFF: NpmRegistryFetchResult = {
557
+ source: "configured",
558
+ config: {
559
+ configured: true,
560
+ default: {
561
+ url: "https://artifactory.example.com/api/npm/npm/",
562
+ token: "test-token-abc",
563
+ },
564
+ allowInstallScripts: false,
565
+ },
566
+ };
567
+
568
+ it("writes a policy-only userconfig when not-configured + allowInstallScripts=false", async () => {
569
+ const { client } = makeClient(async () => NOT_CONFIGURED_SCRIPTS_OFF);
570
+ const logger = makeLogger();
571
+
572
+ const result = await syncHomeNpmrc({
573
+ npmRegistryClient: client,
574
+ logger: asLogger(logger),
575
+ homeDir,
576
+ });
577
+
578
+ expect(result.outcome).toBe("written");
579
+ const content = await readFile(npmrcPath(homeDir), "utf-8");
580
+ expect(content).toContain("ignore-scripts=true");
581
+ const stats = await stat(npmrcPath(homeDir));
582
+ expect(stats.mode & 0o777).toBe(HOME_NPMRC_MODE);
583
+ });
584
+
585
+ it("includes ignore-scripts=true alongside registry lines when configured + policy=false", async () => {
586
+ const { client } = makeClient(async () => CONFIGURED_SCRIPTS_OFF);
587
+ const logger = makeLogger();
588
+
589
+ const result = await syncHomeNpmrc({
590
+ npmRegistryClient: client,
591
+ logger: asLogger(logger),
592
+ homeDir,
593
+ });
594
+
595
+ expect(result.outcome).toBe("written");
596
+ const content = await readFile(npmrcPath(homeDir), "utf-8");
597
+ expect(content).toContain("ignore-scripts=true");
598
+ expect(content).toContain(
599
+ "registry=https://artifactory.example.com/api/npm/npm/",
600
+ );
601
+ });
602
+
603
+ it("cleans up the policy-only userconfig after flipping to scripts-allowed", async () => {
604
+ const { client, getConfig } = makeClient(
605
+ async () => NOT_CONFIGURED_SCRIPTS_OFF,
606
+ );
607
+ const logger = makeLogger();
608
+ // First sync: policy-only write
609
+ await syncHomeNpmrc({
610
+ npmRegistryClient: client,
611
+ logger: asLogger(logger),
612
+ homeDir,
613
+ });
614
+ const content = await readFile(npmrcPath(homeDir), "utf-8");
615
+ expect(content).toContain("ignore-scripts=true");
616
+
617
+ // Flip to not-configured + scripts allowed → restore removes the
618
+ // policy-only file (no backup exists since we never went through
619
+ // the configured path, and unlinkTargetWhenBackupMissing is true).
620
+ getConfig.mockResolvedValueOnce(NOT_CONFIGURED);
621
+ const result = await syncHomeNpmrc({
622
+ npmRegistryClient: client,
623
+ logger: asLogger(logger),
624
+ homeDir,
625
+ });
626
+ expect(result.outcome).toBe("restored-not-configured");
627
+ await expect(stat(npmrcPath(homeDir))).rejects.toMatchObject({
628
+ code: "ENOENT",
629
+ });
630
+ });
631
+
632
+ it("cleans up after repeated policy syncs then flip (no poisoned backup)", async () => {
633
+ // Regression: on the second sync with policy=false the write path
634
+ // must NOT snapshot the policy file as the backup baseline.
635
+ // Otherwise a later flip to scripts-allowed restores the policy
636
+ // content (both target and backup are identical) → no-op, leaving
637
+ // ignore-scripts=true in place permanently.
638
+ const { client, getConfig } = makeClient(
639
+ async () => NOT_CONFIGURED_SCRIPTS_OFF,
640
+ );
641
+ const logger = makeLogger();
642
+
643
+ // Two consecutive syncs with scripts off.
644
+ await syncHomeNpmrc({
645
+ npmRegistryClient: client,
646
+ logger: asLogger(logger),
647
+ homeDir,
648
+ });
649
+ await syncHomeNpmrc({
650
+ npmRegistryClient: client,
651
+ logger: asLogger(logger),
652
+ homeDir,
653
+ });
654
+ expect(await readFile(npmrcPath(homeDir), "utf-8")).toContain(
655
+ "ignore-scripts=true",
656
+ );
657
+ // Backup must NOT exist (we never went through the configured path).
658
+ await expect(
659
+ stat(superblocksNpmrcBackupPath(homeDir)),
660
+ ).rejects.toMatchObject({ code: "ENOENT" });
661
+
662
+ // Flip: scripts allowed → file must be removed.
663
+ getConfig.mockResolvedValueOnce(NOT_CONFIGURED);
664
+ const result = await syncHomeNpmrc({
665
+ npmRegistryClient: client,
666
+ logger: asLogger(logger),
667
+ homeDir,
668
+ });
669
+ expect(result.outcome).toBe("restored-not-configured");
670
+ await expect(stat(npmrcPath(homeDir))).rejects.toMatchObject({
671
+ code: "ENOENT",
672
+ });
673
+ });
674
+
675
+ it("writes the policy-only userconfig even when a pre-existing file is present (Superblocks-owned dir)", async () => {
676
+ // The userconfig now lives in ~/.superblocks/npmrc (a Superblocks-
677
+ // owned directory), so there is no foreign-file guard — the writer
678
+ // always owns the file.
679
+ const existingContent = "registry=https://registry.npmjs.org/\n";
680
+ await mkdir(path.join(homeDir, ".superblocks"), { recursive: true });
681
+ await writeFile(npmrcPath(homeDir), existingContent, {
682
+ mode: 0o600,
683
+ });
684
+ const { client } = makeClient(async () => NOT_CONFIGURED_SCRIPTS_OFF);
685
+ const logger = makeLogger();
686
+
687
+ const result = await syncHomeNpmrc({
688
+ npmRegistryClient: client,
689
+ logger: asLogger(logger),
690
+ homeDir,
691
+ });
692
+
693
+ expect(result.outcome).toBe("written");
694
+ const content = await readFile(npmrcPath(homeDir), "utf-8");
695
+ expect(content).toContain("ignore-scripts=true");
696
+ });
697
+ });
698
+
699
+ describe("error handling", () => {
700
+ it("returns outcome 'error' and logs a warn when the client throws", async () => {
701
+ // RBAC denial / malformed-request branches surface as throws from
702
+ // `NpmRegistryClient`. Dev-server startup must not crash on those
703
+ // — log + leave the userconfig untouched.
704
+ const seedLogger = makeLogger();
705
+ const { client: seedClient } = makeClient(async () => CONFIGURED);
706
+ await syncHomeNpmrc({
707
+ npmRegistryClient: seedClient,
708
+ logger: asLogger(seedLogger),
709
+ homeDir,
710
+ });
711
+ const before = await readFile(npmrcPath(homeDir), "utf-8");
712
+
713
+ const { client } = makeClient(async () => {
714
+ throw new Error("HTTP 403 from npm-registry endpoint");
715
+ });
716
+ const logger = makeLogger();
717
+
718
+ const result = await syncHomeNpmrc({
719
+ npmRegistryClient: client,
720
+ logger: asLogger(logger),
721
+ homeDir,
722
+ });
723
+
724
+ expect(result.outcome).toBe("error");
725
+ expect(logger.warn).toHaveBeenCalledWith(
726
+ expect.stringContaining("client.getConfig() failed"),
727
+ expect.objectContaining({
728
+ path: npmrcPath(homeDir),
729
+ }),
730
+ );
731
+ const after = await readFile(npmrcPath(homeDir), "utf-8");
732
+ expect(after).toBe(before);
733
+ });
734
+ });
735
+
736
+ describe("superblocksNpmrcPath helper", () => {
737
+ it("returns <homeDir>/.superblocks/npmrc", () => {
738
+ expect(superblocksNpmrcPath(homeDir)).toBe(
739
+ path.join(homeDir, ".superblocks", "npmrc"),
740
+ );
741
+ });
742
+
743
+ it("falls back to os.homedir() when no argument is provided", () => {
744
+ expect(superblocksNpmrcPath()).toBe(
745
+ path.join(os.homedir(), ".superblocks", "npmrc"),
746
+ );
747
+ });
748
+ });
749
+
750
+ describe("superblocksLogsPath helper", () => {
751
+ it("returns <appDir>/.superblocks/logs (app-based, not home-based)", () => {
752
+ expect(superblocksLogsPath("/srv/app")).toBe(
753
+ path.join("/srv/app", ".superblocks", "logs"),
754
+ );
755
+ });
756
+ });
757
+ });