@smoothbricks/cli 0.11.13 → 0.11.15

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.
@@ -389,148 +389,69 @@ describe('resolveSecretEnvironment', () => {
389
389
  });
390
390
 
391
391
  /**
392
- * The remote cache half is exercised the way the managed shell runs it the
393
- * raw script as a program, its stdout `eval`-ed — because that round trip is
394
- * the contract: shell text on stdout, guidance on stderr, and a status that
395
- * never keeps a shell from opening.
392
+ * Redaction is a byte operation on output the caller already captured, so the
393
+ * harness carries both directions as base64: a text-only round trip would
394
+ * hide exactly the invalid-UTF-8 bytes a failing install can emit.
396
395
  */
397
- describe('remote cache shell export', () => {
398
- const SERVER = 'https://nx-cache.example.net';
399
-
400
- async function exportFor(root: string, env: Record<string, string>): Promise<{ stdout: string; stderr: string }> {
401
- const proc = Bun.spawn({
402
- cmd: ['bun', RAW_RESOLVER, root],
403
- env: { PATH: process.env['PATH'], HOME: process.env['HOME'], ...env },
404
- stdin: 'ignore',
405
- stdout: 'pipe',
406
- stderr: 'pipe',
407
- });
408
- const [stdout, stderr, exitCode] = await Promise.all([
409
- new Response(proc.stdout).text(),
410
- new Response(proc.stderr).text(),
411
- proc.exited,
412
- ]);
413
- // A cache is an optimization: nothing it does may fail shell entry.
414
- expect(exitCode).toBe(0);
415
- return { stdout, stderr };
416
- }
417
-
418
- /** The value a shell ends up with after eval-ing the script's output. */
419
- async function evaluated(root: string, env: Record<string, string>, name: string): Promise<string> {
420
- const { stdout } = await exportFor(root, env);
421
- const shell = Bun.spawnSync({
422
- cmd: ['sh', '-c', `${stdout}printf %s "\${${name}:-}"`],
423
- env: { PATH: process.env['PATH'] ?? '/usr/bin:/bin' },
424
- });
425
- expect(shell.exitCode).toBe(0);
426
- return shell.stdout.toString();
427
- }
396
+ const MASK_SCRIPT = `
397
+ const resolver = await import(Bun.argv[1]);
398
+ const { output, values } = JSON.parse(Bun.argv[2]);
399
+ const captured = Buffer.from(output, 'base64');
400
+ const masked = resolver.maskSecretValues(captured, values);
401
+ process.stdout.write(JSON.stringify({
402
+ masked: Buffer.from(masked).toString('base64'),
403
+ captured: captured.toString('base64'),
404
+ }));
405
+ `;
428
406
 
429
- it('exports the pair from an ambient token and nothing from an absent declaration', async () => {
430
- await withFixture(
431
- { secrets: {}, remoteCache: { server: SERVER, tokenSecret: 'NX_REMOTE_CACHE_TOKEN' } },
432
- async (root) => {
433
- expect((await exportFor(root, { NX_REMOTE_CACHE_TOKEN: 'ambient-token' })).stdout).toBe(
434
- `export NX_SELF_HOSTED_REMOTE_CACHE_SERVER='${SERVER}'\nexport NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN='ambient-token'\n`,
435
- );
436
- },
407
+ describe('maskSecretValues', () => {
408
+ const mask = async (output: Uint8Array, values: readonly string[]): Promise<{ masked: Buffer; captured: Buffer }> => {
409
+ const { stdout, stderr, exitCode } = await runInBootstrap(
410
+ MASK_SCRIPT,
411
+ JSON.stringify({ output: Buffer.from(output).toString('base64'), values }),
437
412
  );
438
- await withFixture({ secrets: {} }, async (root) => {
439
- expect(await exportFor(root, { NX_REMOTE_CACHE_TOKEN: 'ambient-token' })).toEqual({ stdout: '', stderr: '' });
440
- });
441
- });
413
+ expect(stderr).toBe('');
414
+ expect(exitCode).toBe(0);
415
+ const value: unknown = JSON.parse(stdout);
416
+ if (
417
+ typeof value !== 'object' ||
418
+ value === null ||
419
+ !('masked' in value) ||
420
+ typeof value.masked !== 'string' ||
421
+ !('captured' in value) ||
422
+ typeof value.captured !== 'string'
423
+ ) {
424
+ throw new Error(`bootstrap harness returned a malformed envelope: ${stdout}`);
425
+ }
426
+ return { masked: Buffer.from(value.masked, 'base64'), captured: Buffer.from(value.captured, 'base64') };
427
+ };
442
428
 
443
- it('exports nothing and names the variable when the token has no value', async () => {
444
- await withFixture(
445
- { secrets: {}, remoteCache: { server: SERVER, tokenSecret: 'NX_REMOTE_CACHE_TOKEN' } },
446
- async (root) => {
447
- const { stdout, stderr } = await exportFor(root, {});
448
- // Half a pair is worse than none: Nx enables its cache on the server
449
- // alone and then fails every task on 401.
450
- expect(stdout).toBe('');
451
- expect(stderr).toContain('NX_REMOTE_CACHE_TOKEN');
452
- expect(stderr).toContain(SERVER);
453
- },
454
- );
429
+ it('replaces every occurrence and leaves the surrounding failure legible', async () => {
430
+ const output = Buffer.from('error: 401 for https://x:hunter2@registry/pkg\nretrying with hunter2\n');
431
+ const { masked, captured } = await mask(output, ['hunter2']);
432
+ expect(masked.toString()).toBe('error: 401 for https://x:*******@registry/pkg\nretrying with *******\n');
433
+ expect(masked.length).toBe(output.length);
434
+ // The caller keeps its captured bytes: redaction returns a copy, and a
435
+ // node Buffer's `slice` would have handed back a view of these.
436
+ expect(captured.toString()).toBe(output.toString());
455
437
  });
456
438
 
457
- it('reports a broken provider as a lost cache, never as a failed install', async () => {
458
- await withFixture(
459
- {
460
- secrets: { NX_REMOTE_CACHE_TOKEN: { command: ['definitely-not-a-real-smoo-binary-xyz'] } },
461
- remoteCache: { server: SERVER, tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
462
- },
463
- async (root) => {
464
- const { stdout, stderr } = await exportFor(root, {});
465
- expect(stdout).toBe('');
466
- expect(stderr).toContain('NX_REMOTE_CACHE_TOKEN');
467
- expect(stderr).toContain('remote cache off');
468
- // Nothing here stopped an install, and the message may not say it did.
469
- expect(stderr).not.toContain('dependencies were not installed');
470
- },
471
- );
439
+ it('redacts each declared value even when one is a prefix of another', async () => {
440
+ expect((await mask(Buffer.from('AB ABCD AB'), ['ABCD', 'AB'])).masked.toString()).toBe('** **** **');
472
441
  });
473
442
 
474
- it('leaves a server the environment already carries alone', async () => {
475
- await withFixture(
476
- { secrets: {}, remoteCache: { server: SERVER, tokenSecret: 'NX_REMOTE_CACHE_TOKEN' } },
477
- async (root) => {
478
- // A CI job env names the address its own runners reach; the public
479
- // origin must not replace it.
480
- expect(
481
- await exportFor(root, {
482
- NX_SELF_HOSTED_REMOTE_CACHE_SERVER: 'http://10.89.0.1:8765',
483
- NX_REMOTE_CACHE_TOKEN: 'ambient-token',
484
- }),
485
- ).toEqual({ stdout: '', stderr: '' });
486
- },
487
- );
443
+ it('survives a false start on the first byte', async () => {
444
+ expect((await mask(Buffer.from('aab aaab'), ['aab'])).masked.toString()).toBe('*** a***');
488
445
  });
489
446
 
490
- it('resolves a provider-declared token, and only that one secret', async () => {
491
- await withFixture(
492
- {
493
- secrets: {
494
- NX_REMOTE_CACHE_TOKEN: { command: emit('provider-token') },
495
- SMOO_OTHER: { command: ['definitely-not-a-real-smoo-binary-xyz'] },
496
- },
497
- remoteCache: { server: SERVER, tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
498
- },
499
- async (root) => {
500
- // The unrunnable sibling proves no other declared secret is resolved
501
- // here, and that none of them reaches the shell.
502
- const { stdout } = await exportFor(root, {});
503
- expect(stdout).toContain("export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN='provider-token'");
504
- expect(stdout).not.toContain('SMOO_OTHER');
505
- },
506
- );
447
+ it('masks whole bytes and carries invalid UTF-8 through untouched', async () => {
448
+ const output = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('kå'), Buffer.from([0x00])]);
449
+ const { masked } = await mask(output, ['kå']);
450
+ expect([...masked]).toEqual([0xff, 0xfe, 0x2a, 0x2a, 0x2a, 0x00]);
507
451
  });
508
452
 
509
- it('survives eval with a token full of shell metacharacters', async () => {
510
- const hostile = `it's $(touch /tmp/smoo-cache-pwned) \`x\` "q" \\`;
511
- await withFixture(
512
- { secrets: {}, remoteCache: { server: SERVER, tokenSecret: 'NX_REMOTE_CACHE_TOKEN' } },
513
- async (root) => {
514
- expect(
515
- await evaluated(root, { NX_REMOTE_CACHE_TOKEN: hostile }, 'NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN'),
516
- ).toBe(hostile);
517
- },
518
- );
519
- });
520
-
521
- it('refuses a declaration Nx could not use, naming the field', async () => {
522
- for (const remoteCache of [
523
- { server: `${SERVER}/`, tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
524
- { server: '', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
525
- { server: SERVER, tokenSecret: 'has-dash' },
526
- { server: SERVER },
527
- 'https://nx-cache.example.net',
528
- ]) {
529
- await withFixture({ secrets: {}, remoteCache }, async (root) => {
530
- const { stdout, stderr } = await exportFor(root, { NX_REMOTE_CACHE_TOKEN: 'ambient-token' });
531
- expect(stdout).toBe('');
532
- expect(stderr).toContain('smoo.remoteCache');
533
- });
534
- }
453
+ it('leaves output alone when nothing is declared or a value is empty', async () => {
454
+ expect((await mask(Buffer.from('nothing to hide'), [])).masked.toString()).toBe('nothing to hide');
455
+ expect((await mask(Buffer.from('nothing to hide'), [''])).masked.toString()).toBe('nothing to hide');
535
456
  });
536
457
  });
@@ -1,6 +1,8 @@
1
1
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
2
3
  import { tmpdir } from 'node:os';
3
4
  import { join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
4
6
  import type { ChangelogOptions } from 'nx/src/command-line/release/command-object.js';
5
7
  import type { NxReleaseConfiguration } from 'nx/src/config/nx-json.js';
6
8
  import { type ReleasePackageInfo, releaseTag } from './core.js';
@@ -34,7 +36,7 @@ export interface GithubReleaseWriteShell {
34
36
 
35
37
  export async function renderNxProjectChangelogContents(input: RenderNxProjectChangelogInput): Promise<string> {
36
38
  return withNxWorkspaceRoot(input.root, async () => {
37
- const { createAPI } = await import('nx/src/command-line/release/changelog.js');
39
+ const { createAPI } = await importWorkspaceNx(input.root, 'src/command-line/release/changelog.js');
38
40
  const result = await createAPI(
39
41
  nxRenderOnlyReleaseConfig,
40
42
  false,
@@ -124,8 +126,35 @@ function isPrereleaseVersion(version: string): boolean {
124
126
  return version.includes('-');
125
127
  }
126
128
 
129
+ /**
130
+ * A module of the WORKSPACE's Nx, not of smoo's own dependency. smoo installs
131
+ * with its own `nx` in the global virtual store, where the workspace's
132
+ * node_modules is invisible: that Nx cannot resolve the workspace's
133
+ * `versionActions` plugin (`Unable to resolve the "versionActions"
134
+ * implementation ... "@smoothbricks/nx-plugin/version-actions"`), and it is
135
+ * a second Nx version driving one release. Every in-process Nx call for a
136
+ * workspace resolves from that workspace's root, exactly as `nx` on its PATH
137
+ * would.
138
+ */
139
+ interface WorkspaceNxModules {
140
+ 'src/utils/workspace-root.js': typeof import('nx/src/utils/workspace-root.js');
141
+ 'src/command-line/release/version.js': typeof import('nx/src/command-line/release/version.js');
142
+ 'src/command-line/release/changelog.js': typeof import('nx/src/command-line/release/changelog.js');
143
+ }
144
+
145
+ export async function importWorkspaceNx<Subpath extends keyof WorkspaceNxModules>(
146
+ root: string,
147
+ subpath: Subpath,
148
+ ): Promise<WorkspaceNxModules[Subpath]> {
149
+ const resolved = createRequire(join(root, 'package.json')).resolve(`nx/${subpath}`);
150
+ // A dynamic specifier types as `any`; the map above is the declared shape of
151
+ // the module the workspace's Nx serves at that subpath.
152
+ const module: WorkspaceNxModules[Subpath] = await import(pathToFileURL(resolved).href);
153
+ return module;
154
+ }
155
+
127
156
  export async function withNxWorkspaceRoot<T>(root: string, run: () => Promise<T>): Promise<T> {
128
- const workspaceRootModule = await import('nx/src/utils/workspace-root.js');
157
+ const workspaceRootModule = await importWorkspaceNx(root, 'src/utils/workspace-root.js');
129
158
  const previousWorkspaceRoot = workspaceRootModule.workspaceRoot;
130
159
  const previousEnvWorkspaceRoot = process.env.NX_WORKSPACE_ROOT_PATH;
131
160
  const previousCwd = process.cwd();
@@ -88,6 +88,7 @@ import {
88
88
  import {
89
89
  createOrUpdateGithubRelease,
90
90
  githubReleaseLookupExists,
91
+ importWorkspaceNx,
91
92
  renderNxProjectChangelogContents,
92
93
  withNxWorkspaceRoot,
93
94
  } from './github-release.js';
@@ -881,8 +882,8 @@ async function runNxReleaseVersion(
881
882
 
882
883
  async function runNxReleaseVersionPreview(root: string, projects: string, bump: string): Promise<ReleasePackage[]> {
883
884
  return withNxWorkspaceRoot(root, async () => {
884
- const { createAPI } = await import('nx/src/command-line/release/version.js');
885
- const { createAPI: createChangelogAPI } = await import('nx/src/command-line/release/changelog.js');
885
+ const { createAPI } = await importWorkspaceNx(root, 'src/command-line/release/version.js');
886
+ const { createAPI: createChangelogAPI } = await importWorkspaceNx(root, 'src/command-line/release/changelog.js');
886
887
  const projectNames = projects.split(',').filter(Boolean);
887
888
  const result = await createAPI(
888
889
  {},