@mondaydotcomorg/z2h-cli 0.6.0 → 0.6.2

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 (93) hide show
  1. package/dist/commands/create-workspace.d.ts.map +1 -1
  2. package/dist/commands/create-workspace.js +2 -5
  3. package/dist/commands/create.js +2 -2
  4. package/dist/constants.d.ts +18 -0
  5. package/dist/constants.d.ts.map +1 -1
  6. package/dist/constants.js +41 -0
  7. package/dist/esm/commands/create-workspace.d.ts.map +1 -1
  8. package/dist/esm/commands/create-workspace.mjs +2 -5
  9. package/dist/esm/commands/create.mjs +2 -2
  10. package/dist/esm/constants.d.ts +18 -0
  11. package/dist/esm/constants.d.ts.map +1 -1
  12. package/dist/esm/constants.mjs +35 -1
  13. package/dist/esm/index.mjs +1 -1
  14. package/dist/esm/migrations/003-npm-broker-scope-split.d.ts +14 -0
  15. package/dist/esm/migrations/003-npm-broker-scope-split.d.ts.map +1 -0
  16. package/dist/esm/migrations/003-npm-broker-scope-split.mjs +49 -0
  17. package/dist/esm/migrations/__tests__/003-npm-broker-scope-split.test.d.ts +2 -0
  18. package/dist/esm/migrations/__tests__/003-npm-broker-scope-split.test.d.ts.map +1 -0
  19. package/dist/esm/migrations/auto-migrate.d.ts.map +1 -1
  20. package/dist/esm/migrations/auto-migrate.mjs +4 -1
  21. package/dist/esm/shadow/sync.d.ts.map +1 -1
  22. package/dist/esm/shadow/sync.mjs +4 -6
  23. package/dist/esm/util/auth/auth-store.d.ts +10 -0
  24. package/dist/esm/util/auth/auth-store.d.ts.map +1 -1
  25. package/dist/esm/util/auth/auth-store.mjs +20 -2
  26. package/dist/esm/util/auth/broker-credential-provider.d.ts +0 -1
  27. package/dist/esm/util/auth/broker-credential-provider.d.ts.map +1 -1
  28. package/dist/esm/util/auth/broker-credential-provider.mjs +2 -5
  29. package/dist/esm/util/npm/__tests__/broker-config.test.d.ts +2 -0
  30. package/dist/esm/util/npm/__tests__/broker-config.test.d.ts.map +1 -0
  31. package/dist/esm/util/npm/__tests__/install.test.d.ts +2 -0
  32. package/dist/esm/util/npm/__tests__/install.test.d.ts.map +1 -0
  33. package/dist/esm/util/npm/broker-config.d.ts +20 -0
  34. package/dist/esm/util/npm/broker-config.d.ts.map +1 -0
  35. package/dist/esm/util/npm/broker-config.mjs +59 -0
  36. package/dist/esm/util/npm/ensure-token.d.ts +19 -0
  37. package/dist/esm/util/npm/ensure-token.d.ts.map +1 -0
  38. package/dist/esm/util/npm/ensure-token.mjs +22 -0
  39. package/dist/esm/util/npm/install.d.ts +18 -0
  40. package/dist/esm/util/npm/install.d.ts.map +1 -0
  41. package/dist/esm/util/npm/install.mjs +74 -0
  42. package/dist/index.js +1 -1
  43. package/dist/migrations/003-npm-broker-scope-split.d.ts +14 -0
  44. package/dist/migrations/003-npm-broker-scope-split.d.ts.map +1 -0
  45. package/dist/migrations/003-npm-broker-scope-split.js +51 -0
  46. package/dist/migrations/__tests__/003-npm-broker-scope-split.test.d.ts +2 -0
  47. package/dist/migrations/__tests__/003-npm-broker-scope-split.test.d.ts.map +1 -0
  48. package/dist/migrations/auto-migrate.d.ts.map +1 -1
  49. package/dist/migrations/auto-migrate.js +4 -1
  50. package/dist/shadow/sync.d.ts.map +1 -1
  51. package/dist/shadow/sync.js +4 -6
  52. package/dist/util/auth/auth-store.d.ts +10 -0
  53. package/dist/util/auth/auth-store.d.ts.map +1 -1
  54. package/dist/util/auth/auth-store.js +19 -0
  55. package/dist/util/auth/broker-credential-provider.d.ts +0 -1
  56. package/dist/util/auth/broker-credential-provider.d.ts.map +1 -1
  57. package/dist/util/auth/broker-credential-provider.js +2 -6
  58. package/dist/util/npm/__tests__/broker-config.test.d.ts +2 -0
  59. package/dist/util/npm/__tests__/broker-config.test.d.ts.map +1 -0
  60. package/dist/util/npm/__tests__/install.test.d.ts +2 -0
  61. package/dist/util/npm/__tests__/install.test.d.ts.map +1 -0
  62. package/dist/util/npm/broker-config.d.ts +20 -0
  63. package/dist/util/npm/broker-config.d.ts.map +1 -0
  64. package/dist/util/npm/broker-config.js +63 -0
  65. package/dist/util/npm/ensure-token.d.ts +19 -0
  66. package/dist/util/npm/ensure-token.d.ts.map +1 -0
  67. package/dist/util/npm/ensure-token.js +24 -0
  68. package/dist/util/npm/install.d.ts +18 -0
  69. package/dist/util/npm/install.d.ts.map +1 -0
  70. package/dist/util/npm/install.js +76 -0
  71. package/migrations.json +5 -0
  72. package/package.json +1 -1
  73. package/src/commands/__tests__/create-workspace.test.ts +9 -11
  74. package/src/commands/__tests__/create.test.ts +9 -6
  75. package/src/commands/create-workspace.ts +2 -5
  76. package/src/commands/create.ts +2 -2
  77. package/src/constants.ts +41 -0
  78. package/src/index.ts +1 -1
  79. package/src/migrations/003-npm-broker-scope-split.ts +52 -0
  80. package/src/migrations/__tests__/003-npm-broker-scope-split.test.ts +99 -0
  81. package/src/migrations/auto-migrate.ts +4 -1
  82. package/src/shadow/sync.ts +4 -6
  83. package/src/templates/workspace/npmrc +3 -2
  84. package/src/templates/workspace/yarnrc.yml +6 -2
  85. package/src/util/__tests__/workspace-templates-shipped.test.ts +19 -0
  86. package/src/util/auth/__tests__/broker-credential-provider.test.ts +2 -1
  87. package/src/util/auth/auth-store.ts +19 -1
  88. package/src/util/auth/broker-credential-provider.ts +1 -5
  89. package/src/util/npm/__tests__/broker-config.test.ts +48 -0
  90. package/src/util/npm/__tests__/install.test.ts +94 -0
  91. package/src/util/npm/broker-config.ts +63 -0
  92. package/src/util/npm/ensure-token.ts +28 -0
  93. package/src/util/npm/install.ts +84 -0
@@ -3,12 +3,14 @@ import os from 'node:os';
3
3
  import { mkdtemp, rm, writeJson, readJson, ensureDir, pathExists } from 'fs-extra';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
- import { execa } from 'execa';
6
+ import { runYarnInstall } from '../../util/npm/install';
7
7
  import { createWorkspaceCommand } from '../create-workspace';
8
8
 
9
- // Mock execa so `yarn install` doesn't actually run.
10
- vi.mock('execa', () => ({
11
- execa: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
9
+ // Mock the install helper so `yarn install` (with its broker preflight) doesn't
10
+ // actually run. The token-injection + 401 re-auth behavior is covered in
11
+ // util/npm/__tests__/install.test.ts.
12
+ vi.mock('../../util/npm/install', () => ({
13
+ runYarnInstall: vi.fn().mockResolvedValue(undefined),
12
14
  }));
13
15
 
14
16
  describe('create-workspace', () => {
@@ -17,7 +19,7 @@ describe('create-workspace', () => {
17
19
  beforeEach(async () => {
18
20
  tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-cw-'));
19
21
  process.env.Z2H_WORKSPACE_HOME = tmp;
20
- vi.mocked(execa).mockClear();
22
+ vi.mocked(runYarnInstall).mockClear();
21
23
  // After mkdtemp the dir exists and is empty; we want absent for the
22
24
  // "happy path" tests, so remove it.
23
25
  await rm(tmp, { recursive: true, force: true });
@@ -46,11 +48,7 @@ describe('create-workspace', () => {
46
48
  expect(await pathExists(path.join(tmp, '.npmrc'))).toBe(true);
47
49
  expect(await pathExists(path.join(tmp, 'README.md'))).toBe(true);
48
50
 
49
- expect(execa).toHaveBeenCalledWith(
50
- 'yarn',
51
- expect.arrayContaining(['install']),
52
- expect.objectContaining({ cwd: tmp })
53
- );
51
+ expect(runYarnInstall).toHaveBeenCalledWith(expect.objectContaining({ cwd: tmp }));
54
52
  });
55
53
 
56
54
  it('refuses if the target dir is non-empty', async () => {
@@ -58,7 +56,7 @@ describe('create-workspace', () => {
58
56
  await writeJson(path.join(tmp, 'somefile.json'), { hello: 'world' });
59
57
 
60
58
  await expect(createWorkspaceCommand({})).rejects.toThrow(/Workspace already exists/);
61
- expect(execa).not.toHaveBeenCalled();
59
+ expect(runYarnInstall).not.toHaveBeenCalled();
62
60
  });
63
61
 
64
62
  it('does not include z2h-cli in workspace devDeps (managed globally)', async () => {
@@ -3,12 +3,15 @@ import os from 'node:os';
3
3
  import { mkdtemp, rm, writeJson, readJson, ensureDir, pathExists, writeFile } from 'fs-extra';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
- import { execa } from 'execa';
6
+ import { runYarnInstall } from '../../util/npm/install';
7
7
  import { createCommand } from '../create';
8
8
  import * as manifest from '../../util/manifest';
9
9
 
10
- vi.mock('execa', () => ({
11
- execa: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }),
10
+ // Mock the install helper so `yarn install` (with its broker preflight) doesn't
11
+ // actually run. Token-injection + 401 re-auth is covered in
12
+ // util/npm/__tests__/install.test.ts.
13
+ vi.mock('../../util/npm/install', () => ({
14
+ runYarnInstall: vi.fn().mockResolvedValue(undefined),
12
15
  }));
13
16
 
14
17
  vi.spyOn(manifest, 'readManifest').mockResolvedValue({});
@@ -29,7 +32,7 @@ describe('create', () => {
29
32
  beforeEach(async () => {
30
33
  tmp = await mkdtemp(path.join(os.tmpdir(), 'z2h-c-'));
31
34
  process.env.Z2H_WORKSPACE_HOME = tmp;
32
- vi.mocked(execa).mockClear();
35
+ vi.mocked(runYarnInstall).mockClear();
33
36
  });
34
37
 
35
38
  afterEach(async () => {
@@ -39,7 +42,7 @@ describe('create', () => {
39
42
 
40
43
  it('refuses when there is no workspace marker', async () => {
41
44
  await expect(createCommand('demo-app', {})).rejects.toThrow(/create-workspace/);
42
- expect(execa).not.toHaveBeenCalled();
45
+ expect(runYarnInstall).not.toHaveBeenCalled();
43
46
  });
44
47
 
45
48
  it('rejects invalid app names', async () => {
@@ -76,7 +79,7 @@ describe('create', () => {
76
79
  expect(content).toContain('export default function DemoApp()');
77
80
  expect(content).not.toContain('__APP_COMPONENT__');
78
81
 
79
- expect(execa).toHaveBeenCalledWith('yarn', ['install'], expect.any(Object));
82
+ expect(runYarnInstall).toHaveBeenCalledWith(expect.objectContaining({ cwd: tmp }));
80
83
  });
81
84
 
82
85
  it('scaffolds an html-embed app and copies the html file', async () => {
@@ -1,10 +1,10 @@
1
1
  import path from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { ensureDir, writeFile, pathExists, readdir, copy } from 'fs-extra';
4
- import { execa } from 'execa';
5
4
  import { info, output, setOutputMode } from '../util/logger';
6
5
  import { getWorkspaceHome } from '../util/workspace';
7
6
  import { getTemplatesDir } from '../util/templates';
7
+ import { runYarnInstall } from '../util/npm/install';
8
8
 
9
9
  export interface CreateWorkspaceOptions {
10
10
  json?: boolean;
@@ -46,10 +46,7 @@ export async function createWorkspaceCommand(opts: CreateWorkspaceOptions): Prom
46
46
  ]);
47
47
 
48
48
  info(`setting up your Z2H workspace — this may take a minute...`);
49
- await execa('yarn', ['install'], {
50
- cwd: home,
51
- stdio: 'inherit',
52
- });
49
+ await runYarnInstall({ cwd: home });
53
50
 
54
51
  output(`Your workspace is ready! 🚀 Apps will live at ${home}`, {
55
52
  ok: true,
@@ -1,13 +1,13 @@
1
1
  import path from 'node:path';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { ensureDir, pathExists, readdir, writeFile, copy } from 'fs-extra';
4
- import { execa } from 'execa';
5
4
  import { info, output, setOutputMode } from '../util/logger';
6
5
  import { assertWorkspace } from '../util/workspace';
7
6
  import { getTemplatesDir, renderTemplate, APP_TEMPLATES, type AppTemplate } from '../util/templates';
8
7
  import { readManifest } from '../util/manifest';
9
8
  import { mfAppName } from '../util/app-name';
10
9
  import { APP_NAME_PATTERN } from '../constants';
10
+ import { runYarnInstall } from '../util/npm/install';
11
11
 
12
12
  export interface CreateOptions {
13
13
  template?: string;
@@ -116,7 +116,7 @@ export async function createCommand(appName: string, opts: CreateOptions): Promi
116
116
  }
117
117
 
118
118
  info(`installing dependencies for ${appName}...`);
119
- await execa('yarn', ['install'], { cwd: workspace, stdio: 'inherit' });
119
+ await runYarnInstall({ cwd: workspace });
120
120
 
121
121
  output(`[z2h-cli] app ready at ${appDir}`, {
122
122
  ok: true,
package/src/constants.ts CHANGED
@@ -56,3 +56,44 @@ export const MANIFEST_READ_URL = `${BUCKET_READ_DNS_URL}/${MANIFEST_KEY}`;
56
56
  // backend STS broker rejects anything else with 400; the CLI can validate
57
57
  // upfront for friendlier errors.
58
58
  export const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{1,49}$/;
59
+
60
+ // ─────────── Broker origin + npm registry (shared §3 contract) ─────
61
+ // The bigbrain-zth broker fronts both the STS credential route and the npm
62
+ // proxy. `Z2H_BROKER_BASE_URL` overrides the origin for tests/staging.
63
+ export const DEFAULT_BROKER_BASE_URL = 'https://bigbrain-zth.bigbrain.me';
64
+
65
+ /**
66
+ * Resolves the broker origin (no trailing slash). Honors
67
+ * `Z2H_BROKER_BASE_URL` (tests/staging), else the prod default. Shared by the
68
+ * STS credential provider and the npm-broker registry URL.
69
+ */
70
+ export function brokerBaseUrl(): string {
71
+ return process.env.Z2H_BROKER_BASE_URL || DEFAULT_BROKER_BASE_URL;
72
+ }
73
+
74
+ // The authenticated npm reverse-proxy is mounted here on the broker. Only the
75
+ // `@mondaydotcomorg` scope is served; everything else resolves from npmjs
76
+ // directly. See docs/superpowers/specs/2026-06-03-npm-brokering-design.md §3.
77
+ export const NPM_BROKER_PATH = '/z2h-cli/npm';
78
+
79
+ // The scope brokered through the proxy — the ONLY scope it serves.
80
+ export const BROKER_NPM_SCOPE = '@mondaydotcomorg';
81
+
82
+ // Public registry for everything outside the brokered scope (unchanged speed).
83
+ export const PUBLIC_NPM_REGISTRY_URL = 'https://registry.npmjs.org/';
84
+
85
+ // Env var the client config (`~/.npmrc`, `.yarnrc.yml`) interpolates at
86
+ // invocation time; its value is the z2h JWT, injected per-spawn by the CLI.
87
+ // Deliberately NOT `NPM_TOKEN` — avoids colliding with the monorepo's own dev
88
+ // token and makes clear it is the z2h JWT, not a real npm token.
89
+ export const Z2H_NPM_TOKEN_ENV = 'Z2H_NPM_TOKEN' as const;
90
+
91
+ /**
92
+ * Broker npm registry URL (no trailing slash), e.g. prod
93
+ * `https://bigbrain-zth.bigbrain.me/z2h-cli/npm`. Built on `brokerBaseUrl()`
94
+ * so the staging override flows through. The trailing-slash forms required by
95
+ * the `.npmrc`/`.yarnrc.yml` configs are added where those files are written.
96
+ */
97
+ export function brokerNpmRegistryUrl(): string {
98
+ return `${brokerBaseUrl()}${NPM_BROKER_PATH}`;
99
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from 'node:module';
2
- import { Command } from 'commander';
3
2
  import { readFileSync } from 'node:fs';
3
+ import { Command } from 'commander';
4
4
  import { devCommand } from './commands/dev';
5
5
  import { buildCommand } from './commands/build';
6
6
  import { cleanCommand } from './commands/clean';
@@ -0,0 +1,52 @@
1
+ import { workspaceYarnrcContents, workspaceNpmrcContents } from '../util/npm/broker-config';
2
+ import type { Tree } from '@nx/devkit';
3
+
4
+ // Legacy marker: the old configs interpolated a real npm token via
5
+ // `${NPM_TOKEN}`. Its presence means the file predates npm brokering and must
6
+ // be rewritten to the scope-split. Once rewritten (placeholder is now
7
+ // `${Z2H_NPM_TOKEN}`) this no longer matches, so the migration is idempotent.
8
+ const LEGACY_TOKEN_PLACEHOLDER = '${NPM_TOKEN}';
9
+
10
+ /**
11
+ * Rewrite a single rc file to `contents` iff it exists and still carries the
12
+ * legacy `${NPM_TOKEN}` placeholder. Returns nothing; mutates the Tree.
13
+ */
14
+ function rewriteIfLegacy(tree: Tree, filePath: string, contents: string): void {
15
+ if (!tree.exists(filePath)) {
16
+ return;
17
+ }
18
+ const current = tree.read(filePath, 'utf-8') ?? '';
19
+ if (!current.includes(LEGACY_TOKEN_PLACEHOLDER)) {
20
+ return; // already migrated (or never legacy) — leave untouched
21
+ }
22
+ tree.write(filePath, contents);
23
+ }
24
+
25
+ /**
26
+ * Migrate the workspace npm/yarn config from the legacy single-registry
27
+ * `${NPM_TOKEN}` form to the `@mondaydotcomorg`→broker scope-split
28
+ * (`${Z2H_NPM_TOKEN}` placeholder). Rewrites the workspace-root `.yarnrc.yml`
29
+ * and `.npmrc`, plus any consumer-app copies that still carry the legacy
30
+ * placeholder (migration 001 normally removes per-consumer `.yarnrc.yml`, so
31
+ * this is just defensive cleanup).
32
+ *
33
+ * The global `~/.npmrc` broker block is written separately by `migrateCommand`
34
+ * (it lives outside the workspace Tree).
35
+ */
36
+ export default function npmBrokerScopeSplit(tree: Tree): void {
37
+ const yarnrc = workspaceYarnrcContents();
38
+ const npmrc = workspaceNpmrcContents();
39
+
40
+ // Workspace root.
41
+ rewriteIfLegacy(tree, '.yarnrc.yml', yarnrc);
42
+ rewriteIfLegacy(tree, '.npmrc', npmrc);
43
+
44
+ // Any consumer apps that still carry their own legacy rc files.
45
+ for (const entry of tree.children('.')) {
46
+ if (entry.startsWith('.')) {
47
+ continue;
48
+ }
49
+ rewriteIfLegacy(tree, `${entry}/.yarnrc.yml`, yarnrc);
50
+ rewriteIfLegacy(tree, `${entry}/.npmrc`, npmrc);
51
+ }
52
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createTree } from '@nx/devkit/testing';
3
+ import npmBrokerScopeSplit from '../003-npm-broker-scope-split';
4
+ import type { Tree } from '@nx/devkit';
5
+
6
+ // The legacy workspace files brokering replaces.
7
+ const LEGACY_YARNRC = [
8
+ 'nodeLinker: node-modules',
9
+ '',
10
+ "npmAuthToken: '${NPM_TOKEN}'",
11
+ '',
12
+ "npmRegistryServer: 'https://registry.npmjs.org/'",
13
+ '',
14
+ 'yarnPath: .yarn/releases/yarn-4.5.1.cjs',
15
+ '',
16
+ ].join('\n');
17
+
18
+ const LEGACY_NPMRC = [
19
+ 'always-auth=true',
20
+ '//registry.npmjs.org/:_authToken=${NPM_TOKEN}',
21
+ 'registry=https://registry.npmjs.org/',
22
+ '',
23
+ ].join('\n');
24
+
25
+ function makeTree(): Tree {
26
+ return createTree();
27
+ }
28
+
29
+ describe('migration 003 — npm broker scope split', () => {
30
+ it('rewrites a legacy ${NPM_TOKEN} workspace .yarnrc.yml to the scope-split', () => {
31
+ const tree = makeTree();
32
+ tree.write('.yarnrc.yml', LEGACY_YARNRC);
33
+
34
+ npmBrokerScopeSplit(tree);
35
+
36
+ const out = tree.read('.yarnrc.yml', 'utf-8')!;
37
+ expect(out).not.toContain('${NPM_TOKEN}');
38
+ expect(out).toContain(' mondaydotcomorg:');
39
+ expect(out).toContain(' npmRegistryServer: "https://bigbrain-zth.bigbrain.me/z2h-cli/npm"');
40
+ expect(out).toContain(' npmAuthToken: "${Z2H_NPM_TOKEN}"');
41
+ expect(out).toContain('npmRegistryServer: "https://registry.npmjs.org/"');
42
+ });
43
+
44
+ it('rewrites a legacy ${NPM_TOKEN} workspace .npmrc to the scope-split', () => {
45
+ const tree = makeTree();
46
+ tree.write('.npmrc', LEGACY_NPMRC);
47
+
48
+ npmBrokerScopeSplit(tree);
49
+
50
+ const out = tree.read('.npmrc', 'utf-8')!;
51
+ expect(out).not.toContain('${NPM_TOKEN}');
52
+ expect(out).toContain('@mondaydotcomorg:registry=https://bigbrain-zth.bigbrain.me/z2h-cli/npm/');
53
+ expect(out).toContain('//bigbrain-zth.bigbrain.me/z2h-cli/npm/:_authToken=${Z2H_NPM_TOKEN}');
54
+ expect(out).toContain('registry=https://registry.npmjs.org/');
55
+ });
56
+
57
+ it('is idempotent — a second run on already-migrated files is a no-op', () => {
58
+ const tree = makeTree();
59
+ tree.write('.yarnrc.yml', LEGACY_YARNRC);
60
+ tree.write('.npmrc', LEGACY_NPMRC);
61
+
62
+ npmBrokerScopeSplit(tree);
63
+ const yarnrcAfterFirst = tree.read('.yarnrc.yml', 'utf-8')!;
64
+ const npmrcAfterFirst = tree.read('.npmrc', 'utf-8')!;
65
+
66
+ npmBrokerScopeSplit(tree);
67
+ expect(tree.read('.yarnrc.yml', 'utf-8')!).toBe(yarnrcAfterFirst);
68
+ expect(tree.read('.npmrc', 'utf-8')!).toBe(npmrcAfterFirst);
69
+ });
70
+
71
+ it('leaves non-legacy files untouched (no ${NPM_TOKEN} marker)', () => {
72
+ const tree = makeTree();
73
+ const custom = 'nodeLinker: node-modules\nnpmRegistryServer: "https://my.custom/"\n';
74
+ tree.write('.yarnrc.yml', custom);
75
+
76
+ npmBrokerScopeSplit(tree);
77
+
78
+ expect(tree.read('.yarnrc.yml', 'utf-8')!).toBe(custom);
79
+ });
80
+
81
+ it('rewrites a consumer-app legacy .yarnrc.yml it finds', () => {
82
+ const tree = makeTree();
83
+ tree.write('my-app/package.json', '{"name":"my-app"}');
84
+ tree.write('my-app/.yarnrc.yml', LEGACY_YARNRC);
85
+
86
+ npmBrokerScopeSplit(tree);
87
+
88
+ const out = tree.read('my-app/.yarnrc.yml', 'utf-8')!;
89
+ expect(out).not.toContain('${NPM_TOKEN}');
90
+ expect(out).toContain(' npmAuthToken: "${Z2H_NPM_TOKEN}"');
91
+ });
92
+
93
+ it('does nothing when no rc files exist', () => {
94
+ const tree = makeTree();
95
+ expect(() => npmBrokerScopeSplit(tree)).not.toThrow();
96
+ expect(tree.exists('.yarnrc.yml')).toBe(false);
97
+ expect(tree.exists('.npmrc')).toBe(false);
98
+ });
99
+ });
@@ -144,7 +144,10 @@ export async function autoMigrate(cliVersion: string, dryRun = false): Promise<v
144
144
 
145
145
  const migrationsJson = (await readJson(migrationsJsonPath)) as MigrationsJson;
146
146
 
147
- // Filter to pending migrations (version > lastVersion AND <= cliVersion)
147
+ // Filter to pending migrations (version > lastVersion AND <= cliVersion).
148
+ // CONVENTION: a migration's `version` field must equal the CLI release it
149
+ // ships with (not the feature branch version). This ensures the `gt` check
150
+ // always catches a migration on the exact upgrade that introduces it.
148
151
  const pending = Object.entries(migrationsJson.migrations)
149
152
  .filter(([, entry]) => semver.gt(entry.version, lastVersion) && semver.lte(entry.version, cliVersion))
150
153
  .sort(([, a], [, b]) => semver.compare(a.version, b.version));
@@ -1,6 +1,6 @@
1
1
  import { pathExists, readFile, writeFile } from 'fs-extra';
2
- import { execa } from 'execa';
3
2
  import { info } from '../util/logger';
3
+ import { runYarnInstall } from '../util/npm/install';
4
4
  import { buildShadowPkg, hashConsumerDeps, writeShadowPkg } from './merge-package-json';
5
5
  import type { ConsumerPaths, ConsumerPkg } from '../types';
6
6
 
@@ -20,11 +20,9 @@ export async function syncShadow(paths: ConsumerPaths, consumerPkg: ConsumerPkg)
20
20
  if (oldHash !== newHash) {
21
21
  info(oldHash ? 'consumer deps changed — yarn install' : 'first run — yarn install');
22
22
  // Yarn 4 is cache-first and non-interactive by default in non-TTY
23
- // contexts, so plain `yarn install` is all we need.
24
- await execa('yarn', ['install'], {
25
- cwd: paths.consumerDir,
26
- stdio: 'inherit',
27
- });
23
+ // contexts, so plain `yarn install` is all we need. runYarnInstall injects
24
+ // the npm-broker token, preflights auth, and re-auths on a broker 401.
25
+ await runYarnInstall({ cwd: paths.consumerDir });
28
26
  await writeFile(paths.shadowDepsHash, newHash);
29
27
  }
30
28
  }
@@ -1,3 +1,4 @@
1
- always-auth=true
2
- //registry.npmjs.org/:_authToken=${NPM_TOKEN}
1
+ @mondaydotcomorg:registry=https://bigbrain-zth.bigbrain.me/z2h-cli/npm/
2
+ //bigbrain-zth.bigbrain.me/z2h-cli/npm/:_authToken=${Z2H_NPM_TOKEN}
3
3
  registry=https://registry.npmjs.org/
4
+ always-auth=true
@@ -1,7 +1,11 @@
1
1
  nodeLinker: node-modules
2
2
 
3
- npmAuthToken: '${NPM_TOKEN}'
3
+ npmScopes:
4
+ mondaydotcomorg:
5
+ npmRegistryServer: "https://bigbrain-zth.bigbrain.me/z2h-cli/npm"
6
+ npmAuthToken: "${Z2H_NPM_TOKEN}"
7
+ npmAlwaysAuth: true
4
8
 
5
- npmRegistryServer: 'https://registry.npmjs.org/'
9
+ npmRegistryServer: "https://registry.npmjs.org/"
6
10
 
7
11
  yarnPath: .yarn/releases/yarn-4.5.1.cjs
@@ -33,4 +33,23 @@ describe('workspace templates ship with the package', () => {
33
33
  const raw = await readFile(path.join(getTemplatesDir(), 'workspace', 'yarnrc.yml'), 'utf8');
34
34
  expect(raw).toMatch(/yarnPath:\s*\.yarn\/releases\/yarn-4\.5\.1\.cjs/);
35
35
  });
36
+
37
+ it('workspace yarnrc scope-splits @mondaydotcomorg to the broker with the Z2H placeholder, no legacy token', async () => {
38
+ const raw = await readFile(path.join(getTemplatesDir(), 'workspace', 'yarnrc.yml'), 'utf8');
39
+ expect(raw).not.toContain('${NPM_TOKEN}');
40
+ expect(raw).toContain('npmScopes:');
41
+ expect(raw).toContain('mondaydotcomorg:');
42
+ expect(raw).toContain('npmRegistryServer: "https://bigbrain-zth.bigbrain.me/z2h-cli/npm"');
43
+ expect(raw).toContain('npmAuthToken: "${Z2H_NPM_TOKEN}"');
44
+ // Default registry stays public/direct.
45
+ expect(raw).toContain('npmRegistryServer: "https://registry.npmjs.org/"');
46
+ });
47
+
48
+ it('workspace npmrc routes the broker scope + keeps a public default registry, no legacy token', async () => {
49
+ const raw = await readFile(path.join(getTemplatesDir(), 'workspace', 'npmrc'), 'utf8');
50
+ expect(raw).not.toContain('${NPM_TOKEN}');
51
+ expect(raw).toContain('@mondaydotcomorg:registry=https://bigbrain-zth.bigbrain.me/z2h-cli/npm/');
52
+ expect(raw).toContain('//bigbrain-zth.bigbrain.me/z2h-cli/npm/:_authToken=${Z2H_NPM_TOKEN}');
53
+ expect(raw).toContain('registry=https://registry.npmjs.org/');
54
+ });
36
55
  });
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { brokerCredentialProvider, DEFAULT_BROKER_BASE_URL, STS_BROKER_PATH } from '../broker-credential-provider';
2
+ import { brokerCredentialProvider, STS_BROKER_PATH } from '../broker-credential-provider';
3
+ import { DEFAULT_BROKER_BASE_URL } from '../../../constants';
3
4
  import * as authStore from '../auth-store';
4
5
 
5
6
  const STUB_CREDS = {
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import os from 'node:os';
3
- import { pathExists, readJson } from 'fs-extra';
3
+ import { pathExists, readJson, readJsonSync } from 'fs-extra';
4
4
 
5
5
  // npm-style convention: per-user config under ~/.zth (mirrors ~/.npmrc, ~/.aws/).
6
6
  // Kept out of the workspace dir so accidental `git init` in ~/zth-projects can't
@@ -29,3 +29,21 @@ export async function readZthAuth(): Promise<ZthAuth> {
29
29
  }
30
30
  return { token: parsed.token };
31
31
  }
32
+
33
+ /**
34
+ * Synchronous, never-throws read of the z2h JWT from `~/.zth/auth.json`.
35
+ * Returns the token string, or `null` when the file is absent, unreadable,
36
+ * malformed, or missing the `token` field.
37
+ *
38
+ * Used when building the env for a spawned package-manager process, where a
39
+ * missing token must degrade gracefully (best-effort self-update) rather than
40
+ * blow up — and where an inline sync read keeps the spawn call site simple.
41
+ */
42
+ export function tryReadAuthTokenSync(): string | null {
43
+ try {
44
+ const parsed = readJsonSync(Z2H_AUTH_FILE) as Partial<ZthAuth> | null;
45
+ return parsed?.token ?? null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
@@ -1,7 +1,7 @@
1
+ import { brokerBaseUrl } from '../../constants';
1
2
  import { readZthAuth } from './auth-store';
2
3
  import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@aws-sdk/types';
3
4
 
4
- export const DEFAULT_BROKER_BASE_URL = 'https://bigbrain-zth.bigbrain.me';
5
5
  export const STS_BROKER_PATH = '/z2h-cli/sts/credentials';
6
6
 
7
7
  // Refresh the cached credentials this many ms before they expire so the next
@@ -15,10 +15,6 @@ interface BrokerResponse {
15
15
  expiration?: string;
16
16
  }
17
17
 
18
- function brokerBaseUrl(): string {
19
- return process.env.Z2H_BROKER_BASE_URL || DEFAULT_BROKER_BASE_URL;
20
- }
21
-
22
18
  async function fetchCredentialsOnce(appName: string): Promise<AwsCredentialIdentity> {
23
19
  const { token } = await readZthAuth();
24
20
  const url = `${brokerBaseUrl()}${STS_BROKER_PATH}`;
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { brokerNpmrcLines, workspaceNpmrcContents, workspaceYarnrcContents } from '../broker-config';
3
+
4
+ afterEach(() => {
5
+ vi.unstubAllEnvs();
6
+ });
7
+
8
+ describe('broker-config (prod defaults)', () => {
9
+ it('npmrc lines route the brokered scope and supply the placeholder only', () => {
10
+ const lines = brokerNpmrcLines();
11
+ expect(lines).toEqual([
12
+ '@mondaydotcomorg:registry=https://bigbrain-zth.bigbrain.me/z2h-cli/npm/',
13
+ '//bigbrain-zth.bigbrain.me/z2h-cli/npm/:_authToken=${Z2H_NPM_TOKEN}',
14
+ ]);
15
+ });
16
+
17
+ it('workspace .npmrc keeps a public default registry + always-auth and no legacy token', () => {
18
+ const npmrc = workspaceNpmrcContents();
19
+ expect(npmrc).toContain('@mondaydotcomorg:registry=https://bigbrain-zth.bigbrain.me/z2h-cli/npm/');
20
+ expect(npmrc).toContain('registry=https://registry.npmjs.org/');
21
+ expect(npmrc).toContain('always-auth=true');
22
+ expect(npmrc).not.toContain('${NPM_TOKEN}');
23
+ expect(npmrc).toContain('${Z2H_NPM_TOKEN}');
24
+ });
25
+
26
+ it('workspace .yarnrc.yml scope-splits the brokered scope and leaves the default registry public', () => {
27
+ const yarnrc = workspaceYarnrcContents();
28
+ expect(yarnrc).toContain('npmScopes:');
29
+ expect(yarnrc).toContain(' mondaydotcomorg:');
30
+ expect(yarnrc).toContain(' npmRegistryServer: "https://bigbrain-zth.bigbrain.me/z2h-cli/npm"');
31
+ expect(yarnrc).toContain(' npmAuthToken: "${Z2H_NPM_TOKEN}"');
32
+ expect(yarnrc).toContain(' npmAlwaysAuth: true');
33
+ expect(yarnrc).toContain('npmRegistryServer: "https://registry.npmjs.org/"');
34
+ expect(yarnrc).toContain('yarnPath: .yarn/releases/yarn-4.5.1.cjs');
35
+ expect(yarnrc).not.toContain('${NPM_TOKEN}');
36
+ });
37
+ });
38
+
39
+ describe('broker-config (staging override)', () => {
40
+ it('honors Z2H_BROKER_BASE_URL for the registry host in every form', () => {
41
+ vi.stubEnv('Z2H_BROKER_BASE_URL', 'https://bigbrainstaging.me');
42
+ expect(brokerNpmrcLines()).toEqual([
43
+ '@mondaydotcomorg:registry=https://bigbrainstaging.me/z2h-cli/npm/',
44
+ '//bigbrainstaging.me/z2h-cli/npm/:_authToken=${Z2H_NPM_TOKEN}',
45
+ ]);
46
+ expect(workspaceYarnrcContents()).toContain(' npmRegistryServer: "https://bigbrainstaging.me/z2h-cli/npm"');
47
+ });
48
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { execa } from 'execa';
3
+ import { runYarnInstall } from '../install';
4
+ import * as ensureToken from '../ensure-token';
5
+ import * as authStore from '../../auth/auth-store';
6
+
7
+ vi.mock('execa', () => ({
8
+ execa: vi.fn(),
9
+ }));
10
+
11
+ function makeChild({
12
+ rejectWith,
13
+ stderrChunks = [],
14
+ }: {
15
+ rejectWith?: Error;
16
+ stderrChunks?: string[];
17
+ } = {}) {
18
+ if (rejectWith) {
19
+ (rejectWith as Error & { stderr?: string }).stderr = stderrChunks.join('');
20
+ }
21
+ const promise = rejectWith ? Promise.reject(rejectWith) : Promise.resolve({ exitCode: 0 });
22
+ promise.catch(() => undefined);
23
+ return {
24
+ then: promise.then.bind(promise),
25
+ catch: promise.catch.bind(promise),
26
+ finally: promise.finally.bind(promise),
27
+ };
28
+ }
29
+
30
+ beforeEach(() => {
31
+ vi.restoreAllMocks();
32
+ vi.mocked(execa).mockReset();
33
+ vi.spyOn(ensureToken, 'ensureZ2hToken').mockResolvedValue();
34
+ vi.spyOn(authStore, 'tryReadAuthTokenSync').mockReturnValue('jwt-stub');
35
+ });
36
+
37
+ describe('runYarnInstall', () => {
38
+ it('preflights the token then spawns `yarn install` with Z2H_NPM_TOKEN in env', async () => {
39
+ vi.mocked(execa).mockReturnValueOnce(makeChild() as never);
40
+
41
+ await runYarnInstall({ cwd: '/ws' });
42
+
43
+ expect(ensureToken.ensureZ2hToken).toHaveBeenCalledTimes(1);
44
+ expect(execa).toHaveBeenCalledWith(
45
+ 'yarn',
46
+ ['install'],
47
+ expect.objectContaining({
48
+ cwd: '/ws',
49
+ env: expect.objectContaining({ Z2H_NPM_TOKEN: 'jwt-stub' }),
50
+ })
51
+ );
52
+ });
53
+
54
+ it('merges extraEnv on top of the broker token', async () => {
55
+ vi.mocked(execa).mockReturnValueOnce(makeChild() as never);
56
+
57
+ await runYarnInstall({ cwd: '/ws', extraEnv: { PUBLIC_URL: 'https://x' } });
58
+
59
+ expect(execa).toHaveBeenCalledWith(
60
+ 'yarn',
61
+ ['install'],
62
+ expect.objectContaining({
63
+ env: expect.objectContaining({ Z2H_NPM_TOKEN: 'jwt-stub', PUBLIC_URL: 'https://x' }),
64
+ })
65
+ );
66
+ });
67
+
68
+ it('on a broker 401, re-auths (forceRefresh) and retries the install once', async () => {
69
+ const e401 = new Error('Process exited with code 1');
70
+ vi.mocked(execa)
71
+ .mockReturnValueOnce(makeChild({ rejectWith: e401, stderrChunks: ['npm error code E401\n'] }) as never)
72
+ .mockReturnValueOnce(makeChild() as never);
73
+
74
+ await runYarnInstall({ cwd: '/ws' });
75
+
76
+ // Preflight + forced refresh after the 401.
77
+ expect(ensureToken.ensureZ2hToken).toHaveBeenCalledTimes(2);
78
+ expect(vi.mocked(ensureToken.ensureZ2hToken).mock.calls[1][0]).toEqual({ forceRefresh: true });
79
+ // Two install attempts.
80
+ expect(execa).toHaveBeenCalledTimes(2);
81
+ });
82
+
83
+ it('rethrows a non-auth failure without retrying', async () => {
84
+ const depErr = new Error('ENOTFOUND some-package');
85
+ vi.mocked(execa).mockReturnValueOnce(
86
+ makeChild({ rejectWith: depErr, stderrChunks: ["error Couldn't find package\n"] }) as never
87
+ );
88
+
89
+ await expect(runYarnInstall({ cwd: '/ws' })).rejects.toThrow(/ENOTFOUND/);
90
+ expect(execa).toHaveBeenCalledTimes(1);
91
+ // No forced re-auth on a non-auth error.
92
+ expect(ensureToken.ensureZ2hToken).toHaveBeenCalledTimes(1);
93
+ });
94
+ });