Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
452 changes: 438 additions & 14 deletions package-lock.json

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,26 @@
"publishConfig": {
"access": "public"
},
"main": "dist/cli.js",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package exports declare .d.ts files but TypeScript config may not generate them. If tsconfig.json missing "declaration": true", build produces no type definitions → consumers get type errors.

Recommendation:

  1. Check tsconfig.json has:
{
    "compilerOptions": {
      "declaration": true,
      "declarationMap": true  // optional, helpful for debugging
    } 
  } 
  1. Verify build output includes .d.ts files: npm run build
    ls dist/index.d.ts dist/components/protoBanner.d.ts

  2. If missing declaration files, add to tsconfig and rebuild.

Why matters: Consumers importing @patternfly/patternfly-cli in TypeScript projects will get "Cannot find module" errors without type definitions, even though runtime works.

"type": "module",
"bin": {
"patternfly-cli": "./dist/cli.js",
"pf": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./components": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./prototype.css": "./dist/prototype.css"
},
"scripts": {
"build": "tsc",
"build": "tsc && cp src/prototype.css dist/prototype.css",
"start": "node dist/cli.js",
"test": "jest",
"semantic-release": "semantic-release",
Expand Down Expand Up @@ -55,10 +67,17 @@
"commander": "^12.1.0",
"execa": "^9.3.0",
"fs-extra": "^11.2.0",
"glob": "^11.0.0",
"inquirer": "^9.3.5"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
"@patternfly/react-core": "^5.0.0 || ^6.0.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@patternfly/react-core": "^6.4.3",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
Expand All @@ -71,8 +90,12 @@
"@types/inquirer": "^9.0.9",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.1",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"eslint": "^10.0.2",
"jest": "^29.7.0",
"react": "^18.3.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these are in peerDependencies, they shouldn't also be needed in devDependencies. Same for @patternfly/react-core

"react-dom": "^18.3.0",
"semantic-release": "^24.2.0",
"ts-jest": "^29.4.5",
"typescript": "^5.9.3",
Expand Down
20 changes: 20 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { runLoad } from './load.js';
import { runDeployToGitHubPages } from './gh-pages.js';
import { readPackageVersion } from './read-package-version.js';
import { promptAndSetLocalGitUser } from './git-user-config.js';
import { runPrototype } from './prototype.js';

const packageJsonPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
const packageVersion = readPackageVersion(packageJsonPath);
Expand Down Expand Up @@ -205,4 +206,23 @@ program
}
});

/** Command to enable prototype mode by adding grayscale CSS import */
program
.command('prototype')
.description('Add prototype grayscale CSS import to the main React index file')
.argument('[path]', 'Path to the project (defaults to current directory)')
.action(async (projectPath) => {
const cwd = projectPath ? path.resolve(projectPath) : process.cwd();
try {
await runPrototype(cwd);
} catch (error) {
if (error instanceof Error) {
console.error(`\n❌ ${error.message}\n`);
} else {
console.error(error);
}
process.exit(1);
}
});

program.parse(process.argv);
14 changes: 14 additions & 0 deletions src/components/protoBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as React from "react";
import { Banner, Bullseye } from "@patternfly/react-core";

const ProtoBanner: React.FC = () => {
return (
<Banner default isSticky>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

PatternFly react-core Banner component variant prop

💡 Result:

In the current version of PatternFly (v6 as of 2026), the @patternfly/react-core Banner component does not have a "variant" prop. Instead, it uses "color" and "status" props for styling. color: 'red' | 'orangered' | 'orange' | 'yellow' | 'green' | 'teal' | 'blue' | 'purple' - Color options, overwritten by status. status: 'success' | 'warning' | 'danger' | 'info' | 'custom' - Status styles that overwrite color. Other props: children (React.ReactNode), className (string), isSticky (boolean, default false), screenReaderText (string). In older versions like PatternFly 4, there was a "variant" prop with values 'default' | 'info' | 'danger' | 'success' | 'warning'. The official documentation at patternfly.org/components/banner lists the React props table confirming no "variant" prop in the latest version. GitHub repo patternfly/patternfly-react main branch demos also align with this.

Citations:


🏁 Script executed:

find . -name "package.json" -type f | head -5 | xargs grep -l "@patternfly/react-core" 2>/dev/null

Repository: patternfly/patternfly-cli

Length of output: 84


🏁 Script executed:

cat package.json | grep -A 2 -B 2 "@patternfly/react-core" 2>/dev/null || echo "Not found in root package.json"

Repository: patternfly/patternfly-cli

Length of output: 364


Remove invalid default prop from Banner component.

In PatternFly v6, the Banner component does not use a default prop or variant prop. Use status or color props instead (e.g., status="info" or color="blue"). The isSticky prop is valid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/protoBanner.tsx` at line 6, The Banner instantiation is using
an invalid prop; remove the `default` prop from the <Banner> component in
protoBanner.tsx and replace it with a supported prop such as `status="info"` or
`color="blue"` while keeping the existing `isSticky` prop; locate the Banner JSX
(the line with Banner and isSticky) and update its props accordingly so it uses
`status` or `color` instead of `default`.

<Bullseye>
<strong>This application is a design prototype</strong>
</Bullseye>
</Banner>
);
};

export default ProtoBanner;
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Export React components
export { default as ProtoBanner } from './components/protoBanner.js';
3 changes: 3 additions & 0 deletions src/prototype.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
html {
filter: grayscale(100%);
}
112 changes: 112 additions & 0 deletions src/prototype.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import path from 'path';
import fs from 'fs-extra';
import { glob } from 'glob';

const CSS_IMPORT_STATEMENT = "import '@patternfly/patternfly-cli/prototype.css';";

/**
* Find the main index.tsx or index.jsx file in a React application.
* Searches common locations like src/index.tsx, src/index.jsx, index.tsx, index.jsx.
*/
async function findMainIndexFile(cwd: string): Promise<string | null> {
// Common patterns for React app entry points
const patterns = [
'src/index.tsx',
'src/index.jsx',
'index.tsx',
'index.jsx',
];

// Try exact matches first
for (const pattern of patterns) {
const fullPath = path.join(cwd, pattern);
if (await fs.pathExists(fullPath)) {
return fullPath;
}
}

// If no exact match, search with glob for any index.tsx or index.jsx
const globPattern = '**/index.{tsx,jsx}';
const matches = await glob(globPattern, {
cwd,
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**'],
absolute: true,
});

if (matches.length > 0) {
// Prefer files in src directory
const srcMatch = matches.find(m => m.includes('/src/'));
return srcMatch ?? matches[0] ?? null;
Comment on lines +39 to +40
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify hardcoded POSIX separator checks that are platform-fragile
rg -n "includes\\('/src/'\\)" src/prototype.ts

Repository: patternfly/patternfly-cli

Length of output: 198


🏁 Script executed:

sed -n '20,50p' src/prototype.ts

Repository: patternfly/patternfly-cli

Length of output: 876


🏁 Script executed:

sed -n '95,120p' src/prototype.ts

Repository: patternfly/patternfly-cli

Length of output: 690


🏁 Script executed:

sed -n '1,30p' src/prototype.ts | head -20

Repository: patternfly/patternfly-cli

Length of output: 654


🏁 Script executed:

cat > /tmp/verify_glob.js << 'EOF'
// Quick verification of how the 'glob' package returns paths
console.log("path.sep on this system:", require('path').sep);
console.log("Example absolute path on this system would use:", require('path').sep);
console.log("\nOn Windows: C:\\Users\\project\\src\\index.tsx would NOT match /src/");
console.log("On Unix:    /home/user/project/src/index.tsx WOULD match /src/");
EOF
node /tmp/verify_glob.js

Repository: patternfly/patternfly-cli

Length of output: 275


🏁 Script executed:

# Verify that matches come from glob with absolute: true
rg -A 8 "glob\(globPattern" src/prototype.ts | head -20

Repository: patternfly/patternfly-cli

Length of output: 620


Use path-segment checks instead of hardcoded '/src/'.

The glob function with absolute: true returns OS-specific paths: backslash separators on Windows (C:\...\src\...) and forward slashes on Unix (/...../src/...). The hardcoded /src/ check only matches forward slashes, so it fails on Windows and incorrectly falls back to matches[0] instead of preferring the src directory.

Proposed fix
+function isUnderSrc(cwd: string, filePath: string): boolean {
+  const rel = path.relative(cwd, filePath);
+  return rel.split(path.sep).includes('src');
+}
...
-    const srcMatch = matches.find(m => m.includes('/src/'));
+    const srcMatch = matches.find(m => isUnderSrc(cwd, m));
     return srcMatch ?? matches[0] ?? null;
...
-    const srcMatch = matches.find(m => m.includes('/src/'));
+    const srcMatch = matches.find(m => isUnderSrc(cwd, m));
     return srcMatch ?? matches[0] ?? null;

Also applies to: 107-108

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/prototype.ts` around lines 39 - 40, The code uses a hardcoded '/src/'
substring to prefer paths under a src directory (see the matches.find that sets
srcMatch and the similar logic at lines 107-108), which fails on Windows; update
both places to normalize the path and check path segments instead of a literal
'/src/'. Use Node's path utilities (e.g., path.normalize and path.sep) or split
the normalized path by path.sep and test if segments include 'src' so the
matches.find reliably detects a src directory on all OSes.

}

return null;
}

/**
* Check if the CSS import already exists in the file content.
*/
function hasImport(content: string): boolean {
return content.includes(CSS_IMPORT_STATEMENT);
}

/**
* Add the CSS import to the top of the file, after any existing imports.
*/
function addImport(content: string): string {
if (hasImport(content)) {
return content;
}

const lines = content.split('\n');
let lastImportIndex = -1;

// Find the last import statement
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line && line.trim().startsWith('import ')) {
lastImportIndex = i;
}
}

// Insert after the last import, or at the beginning if no imports found
const insertIndex = lastImportIndex >= 0 ? lastImportIndex + 1 : 0;
lines.splice(insertIndex, 0, CSS_IMPORT_STATEMENT);

return lines.join('\n');
}

/**
* Run the prototype command: find the main index file and add the CSS import.
*/
export async function runPrototype(cwd: string): Promise<void> {
console.log('🔍 Searching for main index file...\n');

const indexFile = await findMainIndexFile(cwd);

if (!indexFile) {
console.error('❌ Could not find main index.tsx or index.jsx file.');
console.error(' Searched in common locations like src/index.tsx, src/index.jsx\n');
throw new Error('Main index file not found');
}

console.log(`✅ Found index file: ${path.relative(cwd, indexFile)}\n`);

// Read the file content
const content = await fs.readFile(indexFile, 'utf-8');

// Check if import already exists
if (hasImport(content)) {
console.log('ℹ️ Prototype CSS import already exists in the file.\n');
return;
}

// Add the import
const updatedContent = addImport(content);

// Write back to the file
await fs.writeFile(indexFile, updatedContent, 'utf-8');

console.log('✅ Added prototype CSS import to the file.');
console.log(` Import statement: ${CSS_IMPORT_STATEMENT}\n`);
console.log('✨ Prototype mode enabled! All UI will now render in grayscale. ✨\n');
}
145 changes: 145 additions & 0 deletions tests/prototype.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
jest.mock('fs-extra', () => {
const real = jest.requireActual<typeof import('fs-extra')>('fs-extra');
return {
__esModule: true,
default: {
pathExists: jest.fn(),
readFile: jest.fn(),
writeFile: jest.fn(),
existsSync: real.existsSync,
readFileSync: real.readFileSync,
},
};
});

jest.mock('glob', () => ({
__esModule: true,
glob: jest.fn(),
}));

import path from 'path';
import fs from 'fs-extra';
import { glob } from 'glob';
import { runPrototype } from '../src/prototype.js';

const mockPathExists = fs.pathExists as jest.MockedFunction<typeof fs.pathExists> & jest.Mock;
const mockReadFile = fs.readFile as jest.MockedFunction<typeof fs.readFile> & jest.Mock;
const mockWriteFile = fs.writeFile as jest.MockedFunction<typeof fs.writeFile> & jest.Mock;
const mockGlob = glob as jest.MockedFunction<typeof glob> & jest.Mock;

describe('runPrototype', () => {
const testCwd = '/test/project';
const CSS_IMPORT = "import '@patternfly/patternfly-cli/prototype.css';";

beforeEach(() => {
jest.clearAllMocks();
// Suppress console.log and console.error during tests
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'error').mockImplementation();
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should find and modify src/index.tsx', async () => {
const indexPath = path.join(testCwd, 'src/index.tsx');
const originalContent = `import React from 'react';\nimport ReactDOM from 'react-dom';\n\nReactDOM.render(<App />, document.getElementById('root'));`;
const expectedContent = `import React from 'react';\nimport ReactDOM from 'react-dom';\n${CSS_IMPORT}\n\nReactDOM.render(<App />, document.getElementById('root'));`;

mockPathExists.mockResolvedValue(true);
mockReadFile.mockResolvedValue(originalContent);
mockWriteFile.mockResolvedValue(undefined);

await runPrototype(testCwd);

expect(mockPathExists).toHaveBeenCalledWith(indexPath);
expect(mockReadFile).toHaveBeenCalledWith(indexPath, 'utf-8');
expect(mockWriteFile).toHaveBeenCalledWith(indexPath, expectedContent, 'utf-8');
});

it('should find and modify src/index.jsx', async () => {
const tsxPath = path.join(testCwd, 'src/index.tsx');
const jsxPath = path.join(testCwd, 'src/index.jsx');
const originalContent = `import React from 'react';\n\nReactDOM.render(<App />, document.getElementById('root'));`;

mockPathExists
.mockResolvedValueOnce(false) // src/index.tsx doesn't exist
.mockResolvedValueOnce(true); // src/index.jsx exists
mockReadFile.mockResolvedValue(originalContent);
mockWriteFile.mockResolvedValue(undefined);

await runPrototype(testCwd);

expect(mockPathExists).toHaveBeenCalledWith(tsxPath);
expect(mockPathExists).toHaveBeenCalledWith(jsxPath);
expect(mockReadFile).toHaveBeenCalledWith(jsxPath, 'utf-8');
});

it('should not modify file if import already exists', async () => {
const indexPath = path.join(testCwd, 'src/index.tsx');
const contentWithImport = `import React from 'react';\n${CSS_IMPORT}\nimport ReactDOM from 'react-dom';\n\nReactDOM.render(<App />, document.getElementById('root'));`;

mockPathExists.mockResolvedValue(true);
mockReadFile.mockResolvedValue(contentWithImport);

await runPrototype(testCwd);

expect(mockReadFile).toHaveBeenCalledWith(indexPath, 'utf-8');
expect(mockWriteFile).not.toHaveBeenCalled();
});

it('should add import at the beginning if no imports exist', async () => {
const indexPath = path.join(testCwd, 'src/index.tsx');
const originalContent = `const app = document.getElementById('root');\napp.innerHTML = 'Hello';`;
const expectedContent = `${CSS_IMPORT}\nconst app = document.getElementById('root');\napp.innerHTML = 'Hello';`;

mockPathExists.mockResolvedValue(true);
mockReadFile.mockResolvedValue(originalContent);
mockWriteFile.mockResolvedValue(undefined);

await runPrototype(testCwd);

expect(mockWriteFile).toHaveBeenCalledWith(indexPath, expectedContent, 'utf-8');
});

it('should throw error if no index file is found', async () => {
mockPathExists.mockResolvedValue(false);
mockGlob.mockResolvedValue([]);

await expect(runPrototype(testCwd)).rejects.toThrow('Main index file not found');

expect(mockWriteFile).not.toHaveBeenCalled();
});

it('should use glob to find index file if common locations do not exist', async () => {
const foundIndexPath = path.join(testCwd, 'app/index.tsx');
const originalContent = `import React from 'react';\n\nfunction App() { return <div>Hello</div>; }`;

mockPathExists.mockResolvedValue(false);
mockGlob.mockResolvedValue([foundIndexPath]);
mockReadFile.mockResolvedValue(originalContent);
mockWriteFile.mockResolvedValue(undefined);

await runPrototype(testCwd);

expect(mockGlob).toHaveBeenCalled();
expect(mockReadFile).toHaveBeenCalledWith(foundIndexPath, 'utf-8');
expect(mockWriteFile).toHaveBeenCalled();
});

it('should prefer src directory when multiple index files are found', async () => {
const srcIndexPath = path.join(testCwd, 'src/index.tsx');
const otherIndexPath = path.join(testCwd, 'other/index.tsx');
const originalContent = `import React from 'react';`;

mockPathExists.mockResolvedValue(false);
mockGlob.mockResolvedValue([otherIndexPath, srcIndexPath]);
mockReadFile.mockResolvedValue(originalContent);
mockWriteFile.mockResolvedValue(undefined);

await runPrototype(testCwd);

expect(mockReadFile).toHaveBeenCalledWith(srcIndexPath, 'utf-8');
});
});
Loading