@smoothbricks/cli 0.11.15 → 0.11.16

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.
@@ -433,6 +433,43 @@ describe('private npm published-version status', () => {
433
433
  });
434
434
  });
435
435
 
436
+ it('reads through a repository .npmrc whose own auth line names an unset env', async () => {
437
+ // The shape that cost a day: AxE commits
438
+ // `//host/path:_authToken=${AXE_NPM_PUBLISH_TOKEN}` at the workspace root.
439
+ // npm ranks that project file ABOVE the userconfig this CLI writes, so with
440
+ // the publish env unset it sent the unexpanded value and the registry
441
+ // answered 401 — with a perfectly good read credential in hand.
442
+ await withStatusFixture(async ({ fixture, root, userconfig }) => {
443
+ await fixture.privateRegistry.publishPackage({ name: PRIVATE_PACKAGE, version: PRIVATE_VERSION });
444
+ const resolved = loopbackRegistry(fixture.privateRegistry);
445
+ await writeFile(
446
+ join(root, '.npmrc'),
447
+ `${resolved.scope}:registry=${resolved.registry}\n${resolved.authKey}=\${NOT_SET_ANYWHERE}\n`,
448
+ );
449
+
450
+ // Control: with the userconfig alone the project file wins, so the token
451
+ // npm sends is the unexpanded reference — the registry sees no usable
452
+ // credential. Asserted on the wire, because a fixture that answers
453
+ // anonymous reads would let the server's verdict hide it.
454
+ await npmPublishedVersionExists(root, PRIVATE_PACKAGE, PRIVATE_VERSION, {
455
+ registry: fixture.privateRegistry.registry,
456
+ userconfig,
457
+ });
458
+ const overridden = fixture.privateRegistry.requestsFor('probe').at(-1);
459
+ expect(overridden?.authorization ?? '').not.toContain(FIXTURE_READ_TOKEN);
460
+
461
+ await expect(
462
+ npmPublishedVersionExists(root, PRIVATE_PACKAGE, PRIVATE_VERSION, {
463
+ registry: fixture.privateRegistry.registry,
464
+ userconfig,
465
+ credential: { authKey: resolved.authKey, tokenEnv: FIXTURE_READ_TOKEN_ENV },
466
+ }),
467
+ ).resolves.toBe(true);
468
+ const authenticated = fixture.privateRegistry.requestsFor('probe').at(-1);
469
+ expect(authenticated?.authorization ?? '').toContain(FIXTURE_READ_TOKEN);
470
+ });
471
+ });
472
+
436
473
  it('reports a genuine not-found as absent', async () => {
437
474
  await withStatusFixture(async ({ fixture, root, userconfig }) => {
438
475
  fixture.privateRegistry.failWith(404);
@@ -86,17 +86,37 @@ describe('private npm workflow token selection', () => {
86
86
  });
87
87
 
88
88
  it('ignores workspace and link specs so a producer install does not look like a registry consume', async () => {
89
+ // Without a declared read token, the .npmrc credential is a publish
90
+ // credential and must never be promoted into the read role by inference.
89
91
  await withRepo(
90
92
  {
91
93
  privatePackage: `${SCOPE}/sdk`,
92
94
  rootDeps: { [`${SCOPE}/sdk`]: 'workspace:*' },
93
95
  extraDeps: { [`${SCOPE}/other`]: 'link:@priv.test/other' },
94
96
  npmrcAuthEnv: PUBLISH_ENV,
97
+ declared: { scope: SCOPE, publishTokenEnv: PUBLISH_ENV },
98
+ },
99
+ (root) => {
100
+ expect(resolvePrivateNpmWorkflowConfig(root)).toEqual({
101
+ scope: SCOPE,
102
+ publishTokenEnv: PUBLISH_ENV,
103
+ });
104
+ },
105
+ );
106
+ });
107
+
108
+ it('renders a declared read token for a producer, whose later releases read the previous tag state', async () => {
109
+ await withRepo(
110
+ {
111
+ privatePackage: `${SCOPE}/sdk`,
112
+ rootDeps: { [`${SCOPE}/sdk`]: 'workspace:*' },
113
+ npmrcAuthEnv: PUBLISH_ENV,
95
114
  declared: { scope: SCOPE, readTokenEnv: READ_ENV, publishTokenEnv: PUBLISH_ENV },
96
115
  },
97
116
  (root) => {
98
117
  expect(resolvePrivateNpmWorkflowConfig(root)).toEqual({
99
118
  scope: SCOPE,
119
+ readTokenEnv: READ_ENV,
100
120
  publishTokenEnv: PUBLISH_ENV,
101
121
  });
102
122
  },
@@ -48,6 +48,7 @@ export {
48
48
  type PrivateNpmRegistryConfigErrorKind,
49
49
  type PublishDestination,
50
50
  privateNpmPublishArgs,
51
+ privateNpmTokenEnvForMode,
51
52
  privateNpmUserconfigContent,
52
53
  publishPrivateWithDiagnostics,
53
54
  type Result as PrivateNpmResult,
@@ -58,6 +59,7 @@ export {
58
59
  withPrivateNpmUserconfig,
59
60
  } from './private-npm.js';
60
61
 
62
+ import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
61
63
  import { ciApiContext } from '../github-ci/api.js';
62
64
  import { readProjectTargets } from '../nx/index.js';
63
65
  import {
@@ -114,6 +116,7 @@ import {
114
116
  npmPublishedVersionExists as npmPublishedVersionExistsOnRegistry,
115
117
  type PrivateNpmRegistry,
116
118
  privateNpmPublishArgs,
119
+ privateNpmTokenEnvForMode,
117
120
  publishPrivateWithDiagnostics,
118
121
  selectPublishDestination,
119
122
  selectRegistryForPackage,
@@ -754,7 +757,13 @@ async function publishPrivatePackedPackage(
754
757
  await publishPrivateWithDiagnostics(
755
758
  pkg,
756
759
  {
757
- publish: () => runNpm(root, args, { NPM_CONFIG_USERCONFIG: userconfig }),
760
+ publish: () =>
761
+ runNpm(root, args, {
762
+ NPM_CONFIG_USERCONFIG: userconfig,
763
+ // Env config outranks a committed project `.npmrc` auth line; see
764
+ // npmStatusEnv in private-npm.ts for why that matters here.
765
+ [`npm_config_${registry.authKey}`]: process.env[privateNpmTokenEnvForMode(registry, 'publish')] ?? '',
766
+ }),
758
767
  // The open publish userconfig already authenticates this registry,
759
768
  // so the failure probe reuses it. Opening a second read-mode
760
769
  // userconfig here demanded a read credential the publishing job need
@@ -764,6 +773,7 @@ async function publishPrivatePackedPackage(
764
773
  npmPublishedVersionExistsOnRegistry(root, pkg.name, pkg.version, {
765
774
  registry: registry.registry,
766
775
  userconfig,
776
+ credential: { authKey: registry.authKey, tokenEnv: privateNpmTokenEnvForMode(registry, 'publish') },
767
777
  }),
768
778
  log: (message) => console.log(message),
769
779
  error: (message) => console.error(message),
@@ -1197,6 +1207,17 @@ function releaseRepairShell(root: string, platformOutputs: readonly string[]): R
1197
1207
  const sourceSha = await gitHead(root);
1198
1208
  const outputs = platformOutputs.map((base) => join(base, sourceSha));
1199
1209
  if (outputs.length === 0) {
1210
+ // No producer job handed outputs over, so this runner is the producer:
1211
+ // build the platform legs at the checked-out ref. `build` alone gives a
1212
+ // package only the legs its own graph depends on — the host one and
1213
+ // whatever it declares — and the release gate demands the whole declared
1214
+ // native closure, so a foreign leg it never asked for was simply absent.
1215
+ console.log('Repair pending releases: building cross-platform outputs on this runner.');
1216
+ await githubCiNxRunMany(root, {
1217
+ targets: PLATFORM_TARGET_GLOBS.join(','),
1218
+ projects: releasePackageProjects(packages),
1219
+ allowEmptyProjects: true,
1220
+ });
1200
1221
  return;
1201
1222
  }
1202
1223
  console.log(`Repair pending releases: applying cross-platform outputs from ${outputs.join(', ')}.`);
@@ -1865,7 +1886,11 @@ async function npmVersionExists(root: string, name: string, version: string): Pr
1865
1886
  return npmPublishedVersionExistsOnRegistry(root, name, version);
1866
1887
  }
1867
1888
  return withPrivateNpmUserconfig(registry, 'read', (userconfig) =>
1868
- npmPublishedVersionExistsOnRegistry(root, name, version, { registry: registry.registry, userconfig }),
1889
+ npmPublishedVersionExistsOnRegistry(root, name, version, {
1890
+ registry: registry.registry,
1891
+ userconfig,
1892
+ credential: { authKey: registry.authKey, tokenEnv: privateNpmTokenEnvForMode(registry, 'read') },
1893
+ }),
1869
1894
  );
1870
1895
  }
1871
1896
 
@@ -247,7 +247,12 @@ export function resolvePrivateNpmWorkflowConfig(root: string): PackagePrivateNpm
247
247
  return undefined;
248
248
  }
249
249
  const npmrcEnv = npmrcAuthTokenEnv(root, declared.scope);
250
- const readTokenEnv = consumes ? (declared.readTokenEnv ?? npmrcEnv) : undefined;
250
+ // A publishing workspace reads the registry too: every release after the
251
+ // first checks the previous tag's durable state, and that check runs in the
252
+ // producer jobs that never see the publish credential. A declared read token
253
+ // therefore always renders; only the .npmrc fallback stays consumer-only, so
254
+ // a publish token is never promoted into a read role by inference.
255
+ const readTokenEnv = declared.readTokenEnv ?? (consumes ? npmrcEnv : undefined);
251
256
  const publishTokenEnv = publishes ? (declared.publishTokenEnv ?? npmrcEnv) : undefined;
252
257
  if (!readTokenEnv && !publishTokenEnv) {
253
258
  return undefined;
@@ -412,10 +417,30 @@ export async function withPrivateNpmUserconfig<T>(
412
417
  export interface NpmStatusOptions {
413
418
  registry?: string;
414
419
  userconfig?: string;
420
+ /** Path-scoped auth key and token env, so the credential can ride env config. */
421
+ credential?: { authKey: string; tokenEnv: string };
415
422
  }
416
423
 
417
- function npmStatusEnv(userconfig: string | undefined): Record<string, string> | undefined {
418
- return userconfig ? { NPM_CONFIG_USERCONFIG: userconfig } : undefined;
424
+ /**
425
+ * npm config precedence is cli > env > project `.npmrc` > userconfig. A
426
+ * repository that commits its own `//host/path:_authToken=${SOME_ENV}` line
427
+ * therefore OVERRIDES the userconfig this module writes, and when that env is
428
+ * unset npm sends the unexpanded value: the registry answers 401 on reads with
429
+ * a perfectly good read credential, naming nothing. So the credential also
430
+ * rides as env config, which the project file cannot outrank, and the token
431
+ * value stays out of every file on disk.
432
+ */
433
+ function npmStatusEnv(
434
+ userconfig: string | undefined,
435
+ credential?: { authKey: string; tokenEnv: string },
436
+ ): Record<string, string> | undefined {
437
+ if (!userconfig) return undefined;
438
+ const env: Record<string, string> = { NPM_CONFIG_USERCONFIG: userconfig };
439
+ const token = credential ? process.env[credential.tokenEnv] : undefined;
440
+ if (credential && token) {
441
+ env[`npm_config_${credential.authKey}`] = token;
442
+ }
443
+ return env;
419
444
  }
420
445
 
421
446
  function npmCommandFailedMessage(npmArgs: string[], exitCode: number, stdout = '', stderr = ''): string {
@@ -439,7 +464,7 @@ export async function npmPublishedVersionExists(
439
464
  if (options.registry) {
440
465
  args.push('--registry', options.registry);
441
466
  }
442
- const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig));
467
+ const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig, options.credential));
443
468
  if (result.exitCode === 0) {
444
469
  return true;
445
470
  }
@@ -455,7 +480,7 @@ export async function npmPackageExists(root: string, name: string, options: NpmS
455
480
  if (options.registry) {
456
481
  args.push('--registry', options.registry);
457
482
  }
458
- const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig));
483
+ const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig, options.credential));
459
484
  if (result.exitCode === 0) {
460
485
  return true;
461
486
  }