From caf5a72c611ef82d16f4d51f27eea09ef2d6cb86 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 4 Jun 2026 11:00:29 +0200 Subject: [PATCH 1/5] Add @cratis/components.eslint rules package Introduce @cratis/components.eslint, ESLint rules for projects that consume Cratis Components, to compose on top of @cratis/eslint-config: - no-root-barrel-import: require importing from a @cratis/components subpath (e.g. @cratis/components/CommandDialog) instead of the package root barrel. - no-primereact-dialog: forbid importing primereact/dialog; use the Cratis dialog wrappers (CommandDialog, Dialogs) which add Arc binding and theming. Register the ESLint workspace in the repo workspaces. Co-Authored-By: Claude Opus 4.8 --- ESLint/README.md | 43 +++++++++++++++++++++ ESLint/index.js | 17 ++++++++ ESLint/lib/noPrimereactDialog.js | 44 +++++++++++++++++++++ ESLint/lib/noRootBarrelImport.js | 47 +++++++++++++++++++++++ ESLint/package.json | 39 +++++++++++++++++++ ESLint/recommended.js | 29 ++++++++++++++ ESLint/test/rules.test.js | 66 ++++++++++++++++++++++++++++++++ ESLint/vitest.config.js | 6 +++ package.json | 3 +- 9 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 ESLint/README.md create mode 100644 ESLint/index.js create mode 100644 ESLint/lib/noPrimereactDialog.js create mode 100644 ESLint/lib/noRootBarrelImport.js create mode 100644 ESLint/package.json create mode 100644 ESLint/recommended.js create mode 100644 ESLint/test/rules.test.js create mode 100644 ESLint/vitest.config.js diff --git a/ESLint/README.md b/ESLint/README.md new file mode 100644 index 0000000..248a027 --- /dev/null +++ b/ESLint/README.md @@ -0,0 +1,43 @@ +# @cratis/components.eslint + +ESLint rules for projects that consume Cratis Components. Compose these on top of the +Cratis base config, [`@cratis/eslint-config`](https://www.npmjs.com/package/@cratis/eslint-config). + +| Rule | What it does | +|---|---| +| `no-root-barrel-import` | Disallows importing from the `@cratis/components` root barrel. Use a subpath export (`@cratis/components/CommandDialog`, `@cratis/components/DataPage`, `@cratis/components/Toolbar`, …) — the root pulls the whole optional-peer-heavy surface and hides intent. | +| `no-primereact-dialog` | Disallows importing `Dialog` from `primereact/dialog`. Use `CommandDialog` from `@cratis/components/CommandDialog`, or `Dialog` from `@cratis/components/Dialogs` — the wrappers add Arc command binding, overlay/focus fixes, and theming. | + +Both cover `import` and re-`export … from` forms. + +## Install + +```sh +yarn add -D @cratis/components.eslint @cratis/eslint-config eslint +``` + +## Use + +```js +// eslint.config.mjs +import cratis from '@cratis/eslint-config'; +import components from '@cratis/components.eslint'; + +export default [ + ...cratis.configs.consumer, + ...components.configs.recommended, + // …your project rules +]; +``` + +### Options + +```js +'@cratis/components/no-root-barrel-import': ['error', { + packageName: '@cratis/components', // barrel to forbid + allow: [], // exact specifiers to permit +}], +'@cratis/components/no-primereact-dialog': ['error', { + source: 'primereact/dialog', // module to forbid +}], +``` diff --git a/ESLint/index.js b/ESLint/index.js new file mode 100644 index 0000000..d6d1172 --- /dev/null +++ b/ESLint/index.js @@ -0,0 +1,17 @@ +import recommended, { plugin } from './recommended.js'; +import { noPrimereactDialog } from './lib/noPrimereactDialog.js'; +import { noRootBarrelImport } from './lib/noRootBarrelImport.js'; + +// Cratis Components ESLint rules. Composes on top of @cratis/eslint-config. +// +// configs.recommended no primereact dialogs + no root-barrel imports +// rules the rule implementations, for custom wiring +export const configs = { recommended }; +export const rules = { + 'no-primereact-dialog': noPrimereactDialog, + 'no-root-barrel-import': noRootBarrelImport, +}; + +export { plugin, noPrimereactDialog, noRootBarrelImport }; + +export default { configs, rules, plugin }; diff --git a/ESLint/lib/noPrimereactDialog.js b/ESLint/lib/noPrimereactDialog.js new file mode 100644 index 0000000..b486502 --- /dev/null +++ b/ESLint/lib/noPrimereactDialog.js @@ -0,0 +1,44 @@ +const DEFAULTS = { source: 'primereact/dialog' }; + +// Disallow importing PrimeReact's Dialog directly. Cratis Components wraps it with +// behavior (Arc command binding, overlay/focus fixes, theming) via CommandDialog and the +// Dialogs surface; reaching past those to `primereact/dialog` bypasses all of it. Covers +// `import` and re-`export … from` forms. +export const noPrimereactDialog = { + meta: { + type: 'suggestion', + docs: { + description: 'Disallow importing Dialog from primereact/dialog; use the Cratis Components dialog wrappers.', + recommended: true, + }, + schema: [{ + type: 'object', + properties: { + source: { type: 'string' }, + }, + additionalProperties: false, + }], + messages: { + useWrapper: "Do not import from '{{source}}'. Use CommandDialog from '@cratis/components/CommandDialog', or Dialog from '@cratis/components/Dialogs'.", + }, + }, + create(context) { + const options = { ...DEFAULTS, ...(context.options[0] ?? {}) }; + + const check = node => { + const source = node.source; + if (!source || typeof source.value !== 'string') return; + if (source.value === options.source) { + context.report({ node: source, messageId: 'useWrapper', data: { source: options.source } }); + } + }; + + return { + ImportDeclaration: check, + ExportNamedDeclaration: check, + ExportAllDeclaration: check, + }; + }, +}; + +export default noPrimereactDialog; diff --git a/ESLint/lib/noRootBarrelImport.js b/ESLint/lib/noRootBarrelImport.js new file mode 100644 index 0000000..00f8f2f --- /dev/null +++ b/ESLint/lib/noRootBarrelImport.js @@ -0,0 +1,47 @@ +const DEFAULTS = { packageName: '@cratis/components', allow: [] }; + +// Disallow importing from the Cratis Components root barrel. The package exposes +// purpose-built subpath exports (`@cratis/components/CommandDialog`, +// `@cratis/components/DataPage`, `@cratis/components/Toolbar`, …); importing the root +// pulls the whole optional-peer-heavy surface and hides intent. Covers `import` and +// re-`export … from` forms. +export const noRootBarrelImport = { + meta: { + type: 'suggestion', + docs: { + description: 'Disallow importing from the @cratis/components root barrel; use a subpath export.', + recommended: true, + }, + schema: [{ + type: 'object', + properties: { + packageName: { type: 'string' }, + allow: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }], + messages: { + useSubpath: "Import from a '{{packageName}}' subpath (e.g. '{{packageName}}/CommandDialog'), not the package root barrel.", + }, + }, + create(context) { + const options = { ...DEFAULTS, ...(context.options[0] ?? {}) }; + const allow = new Set(options.allow); + + const check = node => { + const source = node.source; + if (!source || typeof source.value !== 'string') return; + if (source.value === options.packageName && !allow.has(source.value)) { + context.report({ node: source, messageId: 'useSubpath', data: { packageName: options.packageName } }); + } + }; + + return { + ImportDeclaration: check, + ExportNamedDeclaration: check, + ExportAllDeclaration: check, + }; + }, +}; + +export default noRootBarrelImport; diff --git a/ESLint/package.json b/ESLint/package.json new file mode 100644 index 0000000..5e662b6 --- /dev/null +++ b/ESLint/package.json @@ -0,0 +1,39 @@ +{ + "name": "@cratis/components.eslint", + "version": "0.0.0", + "description": "Cratis Components ESLint rules: import from subpaths, not the root barrel, and use the Cratis dialog wrappers instead of primereact/dialog. Compose on top of @cratis/eslint-config.", + "author": "Cratis", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/Cratis/Components" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "eslint", + "eslintplugin", + "eslint-plugin", + "cratis", + "components" + ], + "files": [ + "index.js", + "recommended.js", + "lib", + "README.md" + ], + "exports": { + "./package.json": "./package.json", + ".": "./index.js" + }, + "scripts": { + "test": "yarn g:test", + "ci": "yarn g:test" + }, + "peerDependencies": { + "eslint": ">=9" + } +} diff --git a/ESLint/recommended.js b/ESLint/recommended.js new file mode 100644 index 0000000..08d029a --- /dev/null +++ b/ESLint/recommended.js @@ -0,0 +1,29 @@ +import { noPrimereactDialog } from './lib/noPrimereactDialog.js'; +import { noRootBarrelImport } from './lib/noRootBarrelImport.js'; + +export const plugin = { + meta: { name: '@cratis/components.eslint' }, + rules: { + 'no-primereact-dialog': noPrimereactDialog, + 'no-root-barrel-import': noRootBarrelImport, + }, +}; + +// Cratis Components import-surface rules. Compose AFTER the Cratis base +// (`@cratis/eslint-config`) in a consuming project: +// +// import cratis from '@cratis/eslint-config'; +// import components from '@cratis/components.eslint'; +// export default [...cratis.configs.consumer, ...components.configs.recommended]; +const recommended = [ + { + files: ['**/*.ts', '**/*.tsx'], + plugins: { '@cratis/components': plugin }, + rules: { + '@cratis/components/no-primereact-dialog': 'error', + '@cratis/components/no-root-barrel-import': 'error', + }, + }, +]; + +export default recommended; diff --git a/ESLint/test/rules.test.js b/ESLint/test/rules.test.js new file mode 100644 index 0000000..74a339f --- /dev/null +++ b/ESLint/test/rules.test.js @@ -0,0 +1,66 @@ +import { RuleTester } from 'eslint'; +import { afterAll, describe, it } from 'vitest'; +import { noPrimereactDialog } from '../lib/noPrimereactDialog.js'; +import { noRootBarrelImport } from '../lib/noRootBarrelImport.js'; + +RuleTester.afterAll = afterAll; +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { ecmaVersion: 'latest', sourceType: 'module' }, +}); + +ruleTester.run('no-root-barrel-import', noRootBarrelImport, { + valid: [ + "import { CommandDialog } from '@cratis/components/CommandDialog';", + "import { DataPage } from '@cratis/components/DataPage';", + "import { useState } from 'react';", + "export { Toolbar } from '@cratis/components/Toolbar';", + // Not the same package — a longer name that merely starts the same. + "import x from '@cratis/components-extra';", + ], + invalid: [ + { + code: "import { Button } from '@cratis/components';", + errors: [{ messageId: 'useSubpath', data: { packageName: '@cratis/components' } }], + }, + { + code: "export { Button } from '@cratis/components';", + errors: [{ messageId: 'useSubpath' }], + }, + { + code: "export * from '@cratis/components';", + errors: [{ messageId: 'useSubpath' }], + }, + { + // Configurable package name. + code: "import x from '@acme/ui';", + options: [{ packageName: '@acme/ui' }], + errors: [{ messageId: 'useSubpath', data: { packageName: '@acme/ui' } }], + }, + ], +}); + +ruleTester.run('no-primereact-dialog', noPrimereactDialog, { + valid: [ + "import { CommandDialog } from '@cratis/components/CommandDialog';", + "import { Dialog } from '@cratis/components/Dialogs';", + "import { Button } from 'primereact/button';", + ], + invalid: [ + { + code: "import { Dialog } from 'primereact/dialog';", + errors: [{ messageId: 'useWrapper', data: { source: 'primereact/dialog' } }], + }, + { + code: "import Dialog from 'primereact/dialog';", + errors: [{ messageId: 'useWrapper' }], + }, + { + code: "export { Dialog } from 'primereact/dialog';", + errors: [{ messageId: 'useWrapper' }], + }, + ], +}); diff --git a/ESLint/vitest.config.js b/ESLint/vitest.config.js new file mode 100644 index 0000000..a0990f5 --- /dev/null +++ b/ESLint/vitest.config.js @@ -0,0 +1,6 @@ +export default { + test: { + include: ['test/**/*.test.js'], + environment: 'node', + }, +}; diff --git a/package.json b/package.json index f83da01..0236d16 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "private": true, "workspaces": [ - "Source" + "Source", + "ESLint" ], "engines": { "node": ">=23.0.0" From 9ec0d8b0f2108b571336d4f9934a89ecd5ca74c2 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 4 Jun 2026 11:14:24 +0200 Subject: [PATCH 2/5] Add plugin meta.version and rule docs URLs Expose name + version on the plugin meta (cache keys, --print-config) and docs URLs on both rules so IDEs render a clickable link. Co-Authored-By: Claude Opus 4.8 --- ESLint/lib/noPrimereactDialog.js | 1 + ESLint/lib/noRootBarrelImport.js | 1 + ESLint/recommended.js | 5 ++++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ESLint/lib/noPrimereactDialog.js b/ESLint/lib/noPrimereactDialog.js index b486502..bb20d5e 100644 --- a/ESLint/lib/noPrimereactDialog.js +++ b/ESLint/lib/noPrimereactDialog.js @@ -10,6 +10,7 @@ export const noPrimereactDialog = { docs: { description: 'Disallow importing Dialog from primereact/dialog; use the Cratis Components dialog wrappers.', recommended: true, + url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md', }, schema: [{ type: 'object', diff --git a/ESLint/lib/noRootBarrelImport.js b/ESLint/lib/noRootBarrelImport.js index 00f8f2f..03ae4a9 100644 --- a/ESLint/lib/noRootBarrelImport.js +++ b/ESLint/lib/noRootBarrelImport.js @@ -11,6 +11,7 @@ export const noRootBarrelImport = { docs: { description: 'Disallow importing from the @cratis/components root barrel; use a subpath export.', recommended: true, + url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md', }, schema: [{ type: 'object', diff --git a/ESLint/recommended.js b/ESLint/recommended.js index 08d029a..b173a5f 100644 --- a/ESLint/recommended.js +++ b/ESLint/recommended.js @@ -1,8 +1,11 @@ +import { createRequire } from 'node:module'; import { noPrimereactDialog } from './lib/noPrimereactDialog.js'; import { noRootBarrelImport } from './lib/noRootBarrelImport.js'; +const { version } = createRequire(import.meta.url)('./package.json'); + export const plugin = { - meta: { name: '@cratis/components.eslint' }, + meta: { name: '@cratis/components.eslint', version }, rules: { 'no-primereact-dialog': noPrimereactDialog, 'no-root-barrel-import': noRootBarrelImport, From bf4b3716849326b29a85f5dbaec3c8fbad708058 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 4 Jun 2026 11:24:42 +0200 Subject: [PATCH 3/5] Rename package to @cratis/eslint-plugin-components Follow the ESLint scoped-plugin naming convention (@scope/eslint-plugin-*). The plugin namespace stays @cratis/components, so rule IDs are unchanged. Co-Authored-By: Claude Opus 4.8 --- ESLint/README.md | 6 +++--- ESLint/package.json | 2 +- ESLint/recommended.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ESLint/README.md b/ESLint/README.md index 248a027..cd0ba89 100644 --- a/ESLint/README.md +++ b/ESLint/README.md @@ -1,4 +1,4 @@ -# @cratis/components.eslint +# @cratis/eslint-plugin-components ESLint rules for projects that consume Cratis Components. Compose these on top of the Cratis base config, [`@cratis/eslint-config`](https://www.npmjs.com/package/@cratis/eslint-config). @@ -13,7 +13,7 @@ Both cover `import` and re-`export … from` forms. ## Install ```sh -yarn add -D @cratis/components.eslint @cratis/eslint-config eslint +yarn add -D @cratis/eslint-plugin-components @cratis/eslint-config eslint ``` ## Use @@ -21,7 +21,7 @@ yarn add -D @cratis/components.eslint @cratis/eslint-config eslint ```js // eslint.config.mjs import cratis from '@cratis/eslint-config'; -import components from '@cratis/components.eslint'; +import components from '@cratis/eslint-plugin-components'; export default [ ...cratis.configs.consumer, diff --git a/ESLint/package.json b/ESLint/package.json index 5e662b6..f023da6 100644 --- a/ESLint/package.json +++ b/ESLint/package.json @@ -1,5 +1,5 @@ { - "name": "@cratis/components.eslint", + "name": "@cratis/eslint-plugin-components", "version": "0.0.0", "description": "Cratis Components ESLint rules: import from subpaths, not the root barrel, and use the Cratis dialog wrappers instead of primereact/dialog. Compose on top of @cratis/eslint-config.", "author": "Cratis", diff --git a/ESLint/recommended.js b/ESLint/recommended.js index b173a5f..e24d93b 100644 --- a/ESLint/recommended.js +++ b/ESLint/recommended.js @@ -5,7 +5,7 @@ import { noRootBarrelImport } from './lib/noRootBarrelImport.js'; const { version } = createRequire(import.meta.url)('./package.json'); export const plugin = { - meta: { name: '@cratis/components.eslint', version }, + meta: { name: '@cratis/eslint-plugin-components', version }, rules: { 'no-primereact-dialog': noPrimereactDialog, 'no-root-barrel-import': noRootBarrelImport, @@ -16,7 +16,7 @@ export const plugin = { // (`@cratis/eslint-config`) in a consuming project: // // import cratis from '@cratis/eslint-config'; -// import components from '@cratis/components.eslint'; +// import components from '@cratis/eslint-plugin-components'; // export default [...cratis.configs.consumer, ...components.configs.recommended]; const recommended = [ { From 21d7ca278462fc21a2fdc087004ec725af7fdb88 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 4 Jun 2026 15:44:27 +0200 Subject: [PATCH 4/5] Use the canonical flat-config plugin object shape Make the default export the plugin object itself (meta + rules + self-referencing configs), per the ESLint flat-config plugin convention. Name the config block. Fold recommended.js into index.js. Co-Authored-By: Claude Opus 4.8 --- ESLint/index.js | 46 ++++++++++++++++++++++++++++++++----------- ESLint/package.json | 1 - ESLint/recommended.js | 32 ------------------------------ 3 files changed, 35 insertions(+), 44 deletions(-) delete mode 100644 ESLint/recommended.js diff --git a/ESLint/index.js b/ESLint/index.js index d6d1172..5498ac0 100644 --- a/ESLint/index.js +++ b/ESLint/index.js @@ -1,17 +1,41 @@ -import recommended, { plugin } from './recommended.js'; +import { createRequire } from 'node:module'; import { noPrimereactDialog } from './lib/noPrimereactDialog.js'; import { noRootBarrelImport } from './lib/noRootBarrelImport.js'; -// Cratis Components ESLint rules. Composes on top of @cratis/eslint-config. -// -// configs.recommended no primereact dialogs + no root-barrel imports -// rules the rule implementations, for custom wiring -export const configs = { recommended }; -export const rules = { - 'no-primereact-dialog': noPrimereactDialog, - 'no-root-barrel-import': noRootBarrelImport, +const { version } = createRequire(import.meta.url)('./package.json'); + +// A single flat-config plugin object — meta + rules + self-referencing configs — per the +// ESLint flat-config plugin convention. The default export IS the plugin, so consumers +// get `components.meta`, `components.rules`, and `components.configs` directly. Composes +// on top of @cratis/eslint-config. +const plugin = { + meta: { name: '@cratis/eslint-plugin-components', version }, + rules: { + 'no-primereact-dialog': noPrimereactDialog, + 'no-root-barrel-import': noRootBarrelImport, + }, + configs: {}, }; -export { plugin, noPrimereactDialog, noRootBarrelImport }; +// configs reference the plugin itself, so they are assigned after it exists. +// +// import cratis from '@cratis/eslint-config'; +// import components from '@cratis/eslint-plugin-components'; +// export default [...cratis.configs.consumer, ...components.configs.recommended]; +Object.assign(plugin.configs, { + recommended: [ + { + name: '@cratis/components/recommended', + files: ['**/*.ts', '**/*.tsx'], + plugins: { '@cratis/components': plugin }, + rules: { + '@cratis/components/no-primereact-dialog': 'error', + '@cratis/components/no-root-barrel-import': 'error', + }, + }, + ], +}); -export default { configs, rules, plugin }; +export default plugin; +export const { configs, rules, meta } = plugin; +export { noPrimereactDialog, noRootBarrelImport }; diff --git a/ESLint/package.json b/ESLint/package.json index f023da6..212a77a 100644 --- a/ESLint/package.json +++ b/ESLint/package.json @@ -21,7 +21,6 @@ ], "files": [ "index.js", - "recommended.js", "lib", "README.md" ], diff --git a/ESLint/recommended.js b/ESLint/recommended.js deleted file mode 100644 index e24d93b..0000000 --- a/ESLint/recommended.js +++ /dev/null @@ -1,32 +0,0 @@ -import { createRequire } from 'node:module'; -import { noPrimereactDialog } from './lib/noPrimereactDialog.js'; -import { noRootBarrelImport } from './lib/noRootBarrelImport.js'; - -const { version } = createRequire(import.meta.url)('./package.json'); - -export const plugin = { - meta: { name: '@cratis/eslint-plugin-components', version }, - rules: { - 'no-primereact-dialog': noPrimereactDialog, - 'no-root-barrel-import': noRootBarrelImport, - }, -}; - -// Cratis Components import-surface rules. Compose AFTER the Cratis base -// (`@cratis/eslint-config`) in a consuming project: -// -// import cratis from '@cratis/eslint-config'; -// import components from '@cratis/eslint-plugin-components'; -// export default [...cratis.configs.consumer, ...components.configs.recommended]; -const recommended = [ - { - files: ['**/*.ts', '**/*.tsx'], - plugins: { '@cratis/components': plugin }, - rules: { - '@cratis/components/no-primereact-dialog': 'error', - '@cratis/components/no-root-barrel-import': 'error', - }, - }, -]; - -export default recommended; From a310280ea21d435bb67cf7e31f33da5403c672d3 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 4 Jun 2026 15:57:15 +0200 Subject: [PATCH 5/5] Make publish-version resilient to per-package failures Previously the first failed 'npm publish' called process.exit(1), aborting the release and stranding every workspace after it. A brand-new package whose npm trusted publisher isn't configured yet would therefore also block the existing packages from releasing. Collect publish failures, keep publishing the rest, and exit non-zero at the end with a summary. Build/test tasks stay fail-fast. Co-Authored-By: Claude Opus 4.8 --- run-task-on-workspaces.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/run-task-on-workspaces.js b/run-task-on-workspaces.js index a108d71..069c0a5 100755 --- a/run-task-on-workspaces.js +++ b/run-task-on-workspaces.js @@ -77,6 +77,8 @@ function updateDependencyVersionsFromLocalWorkspaces(file, packageJson, version) } } +const publishFailures = []; + for (const workspaceName in workspaces) { const workspaceRelativeLocation = workspaces[workspaceName]; const workspaceAbsoluteLocation = path.join(process.cwd(), workspaceRelativeLocation); @@ -108,9 +110,11 @@ for (const workspaceName in workspaces) { console.log(result.stdout.toString()); console.log(result.stderr.toString()); if (result.status !== 0) { - console.log(`Error publishing workspace '${workspaceName}'`); - process.exit(1); - return; + // Don't abort the release: a single workspace failing (e.g. a brand-new + // package whose npm trusted publisher isn't configured yet) must not + // strand the other packages. Collect and fail at the end instead. + console.log(`Error publishing workspace '${workspaceName}' - continuing with remaining workspaces`); + publishFailures.push(workspaceName); } } } else { @@ -133,3 +137,8 @@ for (const workspaceName in workspaces) { } } } + +if (publishFailures.length > 0) { + console.log(`\n${publishFailures.length} workspace(s) failed to publish: ${publishFailures.join(', ')}`); + process.exit(1); +}