Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions code/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@
"@emotion/styled": "^11.14.0",
"@fal-works/esbuild-plugin-global-externals": "^2.1.2",
"@happy-dom/global-registrator": "^18.0.1",
"@hipster/sb-utils": "0.0.8-canary.15.285ba09.0",
"@ngard/tiny-isequal": "^1.1.0",
"@polka/compression": "^1.0.0-next.28",
"@radix-ui/react-scroll-area": "1.2.0-rc.7",
Expand Down Expand Up @@ -262,6 +263,7 @@
"bundle-require": "^5.1.0",
"camelcase": "^8.0.0",
"chai": "^5.1.1",
"clack-tree-select": "^1.0.4",
"commander": "^14.0.1",
"comment-parser": "^1.4.1",
"copy-to-clipboard": "^3.3.1",
Expand Down
12 changes: 8 additions & 4 deletions code/core/src/core-server/utils/get-new-story-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export async function getNewStoryFile(
const basenameWithoutExtension = base.replace(extension, '');
const dir = dirname(componentFilePath);

// Ensure dir is relative to project root
const projectRoot = getProjectRoot();
const relativeDir = dir.startsWith(projectRoot) ? relative(projectRoot, dir) : dir;

const { storyFileName, isTypescript, storyFileExtension } = getStoryMetadata(componentFilePath);
const storyFileNameWithExtension = `${storyFileName}.${storyFileExtension}`;
const alternativeStoryFileNameWithExtension = `${basenameWithoutExtension}.${componentExportName}.stories.${storyFileExtension}`;
Expand Down Expand Up @@ -95,7 +99,7 @@ export async function getNewStoryFile(
if (previewConfigPath) {
const hasImportsMap = await checkForImportsMap(options.configDir);
if (!hasImportsMap) {
const storyFilePath = join(getProjectRoot(), dir);
const storyFilePath = join(projectRoot, relativeDir);
const relPath = relative(storyFilePath, previewConfigPath);
const pathWithoutExt = relPath.replace(/\.(ts|js|mts|cts|tsx|jsx)$/, '');
previewImportPath = pathWithoutExt.startsWith('.') ? pathWithoutExt : `./${pathWithoutExt}`;
Expand Down Expand Up @@ -133,9 +137,9 @@ export async function getNewStoryFile(
storyFileContent = replaceArgsPlaceholders(storyFileContent);

const storyFilePath =
doesStoryFileExist(join(getProjectRoot(), dir), storyFileName) && componentExportCount > 1
? join(getProjectRoot(), dir, alternativeStoryFileNameWithExtension)
: join(getProjectRoot(), dir, storyFileNameWithExtension);
doesStoryFileExist(join(projectRoot, relativeDir), storyFileName) && componentExportCount > 1
? join(projectRoot, relativeDir, alternativeStoryFileNameWithExtension)
: join(projectRoot, relativeDir, storyFileNameWithExtension);

const formattedStoryFileContent = await formatFileContent(storyFilePath, storyFileContent);

Expand Down
6 changes: 5 additions & 1 deletion code/core/src/node-logger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import * as newLogger from './logger/logger';

export { prompt } from './prompts';
export { logTracker } from './logger/log-tracker';
export type { SpinnerInstance, TaskLogInstance } from './prompts/prompt-provider-base';
export type {
SpinnerInstance,
TaskLogInstance,
FileSystemTreeSelectPromptOptions,
} from './prompts/prompt-provider-base';
export { protectUrls, createHyperlink } from './wrap-utils';
export { CLI_COLORS } from './logger/colors';
export { ConsoleLogger, StyledConsoleLogger } from './logger/console';
Expand Down
9 changes: 9 additions & 0 deletions code/core/src/node-logger/prompts/prompt-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getPromptProvider } from './prompt-config';
import type {
BasePromptOptions,
ConfirmPromptOptions,
FileSystemTreeSelectPromptOptions,
MultiSelectPromptOptions,
Option,
PromptOptions,
Expand All @@ -23,6 +24,7 @@ export type {
ConfirmPromptOptions,
SelectPromptOptions,
MultiSelectPromptOptions,
FileSystemTreeSelectPromptOptions,
PromptOptions,
SpinnerInstance,
TaskLogInstance,
Expand Down Expand Up @@ -108,6 +110,13 @@ export const multiselect = async <T>(
);
};

export const fileSystemTreeSelect = async (
options: FileSystemTreeSelectPromptOptions,
promptOptions?: PromptOptions
): Promise<string | string[]> => {
return getPromptProvider().fileSystemTreeSelect(options, promptOptions);
};

export const spinner = (options: SpinnerOptions): SpinnerInstance => {
if (isInteractiveTerminal()) {
const spinnerInstance = getPromptProvider().spinner(options);
Expand Down
22 changes: 22 additions & 0 deletions code/core/src/node-logger/prompts/prompt-provider-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ export interface MultiSelectPromptOptions<T> extends BasePromptOptions {
required?: boolean;
}

export interface FileSystemTreeSelectPromptOptions extends BasePromptOptions {
/** The root directory to start browsing from */
root?: string;
/** Whether to include files in the selection (default: true) */
includeFiles?: boolean;
/** Whether to include hidden files and directories (default: false) */
includeHidden?: boolean;
/** Maximum depth to traverse (default: Infinity) */
maxDepth?: number;
/** Optional filter function to exclude certain paths */
filter?: (path: string) => boolean;
/** Glob pattern(s) to filter files (based on picomatch) */
glob?: string | string[];
/** Whether to allow multiple selections (default: false) */
multiple?: boolean;
}
Comment on lines +42 to +57

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

Default value mismatch between documentation and implementation.

The JSDoc comment on line 55 states multiple defaults to false, but the implementation in prompt-provider-clack.ts (line 158) defaults it to true:

// prompt-provider-base.ts
/** Whether to allow multiple selections (default: false) */
multiple?: boolean;

// prompt-provider-clack.ts line 158
multiple: options.multiple ?? true,

Consider aligning the documentation with the actual default, or vice versa.

🔎 Proposed fix

Either update the documentation:

-  /** Whether to allow multiple selections (default: false) */
+  /** Whether to allow multiple selections (default: true) */
   multiple?: boolean;

Or update the implementation in prompt-provider-clack.ts:

-      multiple: options.multiple ?? true,
+      multiple: options.multiple ?? false,
🤖 Prompt for AI Agents
In code/core/src/node-logger/prompts/prompt-provider-base.ts around lines 42-57:
the JSDoc for `multiple` says the default is false but the implementation in
prompt-provider-clack.ts sets it to true; choose one behavior and make them
consistent. Either update this JSDoc to state default true, or change the
implementation at prompt-provider-clack.ts line ~158 to use `options.multiple ??
false` (and run tests/linters to ensure no other callers rely on the true
default).


export interface PromptOptions {
onCancel?: () => void;
}
Expand Down Expand Up @@ -87,6 +104,11 @@ export abstract class PromptProvider {
promptOptions?: PromptOptions
): Promise<T[]>;

abstract fileSystemTreeSelect(
options: FileSystemTreeSelectPromptOptions,
promptOptions?: PromptOptions
): Promise<string | string[]>;

abstract spinner(options: SpinnerOptions): SpinnerInstance;

abstract taskLog(options: TaskLogOptions): TaskLogInstance;
Expand Down
74 changes: 74 additions & 0 deletions code/core/src/node-logger/prompts/prompt-provider-clack.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import * as clack from '@clack/prompts';
import { buildFileSystemTree, treeSelect } from 'clack-tree-select';
import type { TreeItem } from 'clack-tree-select';
import picomatch from 'picomatch';

import { logTracker } from '../logger/log-tracker';
import { wrapTextForClackHint } from '../wrap-utils';
import type {
ConfirmPromptOptions,
FileSystemTreeSelectPromptOptions,
MultiSelectPromptOptions,
PromptOptions,
SelectPromptOptions,
Expand All @@ -15,6 +19,43 @@ import type {
} from './prompt-provider-base';
import { PromptProvider } from './prompt-provider-base';

// Filter a file system tree so that only the directories that contain files matching the globs are shown
function filterTreeByGlob(
tree: TreeItem<string>[],
glob: string | string[],
root: string
): TreeItem<string>[] {
const globMatchers = Array.isArray(glob)
? glob.map((g) => picomatch(g, { cwd: root }))
: [picomatch(glob, { cwd: root })];

function filterItem(item: TreeItem<string>): TreeItem<string> | null {
// For files (items without children), check if they match any of the glob patterns
if (!item.children || item.children.length === 0) {
// Convert absolute path to relative path for matching
const relativePath = item.value.startsWith(root)
? item.value.slice(root.length).replace(/^\/+/, '')
: item.value;
return globMatchers.some((matcher) => matcher(relativePath)) ? item : null;
}

// For directories, recursively filter children
const filteredChildren = filterTreeByGlob(item.children as TreeItem<string>[], glob, root);

// Keep the directory only if it has any children after filtering
if (filteredChildren.length > 0) {
return {
...item,
children: filteredChildren,
};
}

return null;
}

return tree.map(filterItem).filter((item): item is TreeItem<string> => item !== null);
}

export const getCurrentTaskLog = (): ReturnType<typeof clack.taskLog> | null => {
if (globalThis.STORYBOOK_CURRENT_TASK_LOG) {
return globalThis.STORYBOOK_CURRENT_TASK_LOG[globalThis.STORYBOOK_CURRENT_TASK_LOG.length - 1];
Expand Down Expand Up @@ -88,6 +129,39 @@ export class ClackPromptProvider extends PromptProvider {
return result as T[];
}

async fileSystemTreeSelect(
options: FileSystemTreeSelectPromptOptions,
promptOptions?: PromptOptions
): Promise<string | string[]> {
const filter =
options.filter ??
((path: string) => {
const excludedDirs = ['node_modules', '.git', 'dist'];
return excludedDirs.every((dir) => !path.includes(dir));
});

let fileTree = buildFileSystemTree(options.root || process.cwd(), {
includeFiles: options.includeFiles ?? true,
includeHidden: options.includeHidden ?? false,
maxDepth: options.maxDepth,
filter,
});

if (options.glob) {
// Filter the tree to only include directories that contain files matching the glob
fileTree = filterTreeByGlob(fileTree, options.glob, options.root || process.cwd());
}

const result = await treeSelect({
message: wrapTextForClackHint(options.message, undefined, undefined, 2),
tree: fileTree,
multiple: options.multiple ?? true,
});
this.handleCancel(result, promptOptions);
logTracker.addLog('prompt', options.message, { choice: result });
return result as string | string[];
}

spinner(options: SpinnerOptions): SpinnerInstance {
const task = clack.spinner();
const spinnerId = `${options.id}-spinner`;
Expand Down
12 changes: 12 additions & 0 deletions code/lib/cli-storybook/src/automigrate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ export const automigrate = async ({
fixResults: Record<string, FixStatus>;
preCheckFailure?: PreCheckFailure;
} | null> => {
const files = await prompt.fileSystemTreeSelect({
message: 'Select a file to migrate',
root: process.cwd(),
includeFiles: true,
includeHidden: false,
glob: ['**/*.tsx', '**/*.json'],
maxDepth: 3,
filter: (path) => {
return !path.includes('node_modules') && !path.includes('.git');
},
});
console.log({ files });
Comment on lines +129 to +140

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

This code appears to be debug/incomplete and blocks the --list flag.

Several issues with this code segment:

  1. Violates logging guidelines: Uses console.log directly instead of the server-side logger. As per coding guidelines, use logger from storybook/internal/node-logger.

  2. Blocks --list mode: The prompt runs before the if (list) check (line 141), forcing users to interact with the file selection prompt even when they just want to list available migrations.

  3. Unused result: The files variable is logged but never used in the migration logic, suggesting this is incomplete or debug code that shouldn't be committed.

🔎 Suggested fix: Move or remove the debug code

If this is intended functionality, move it after the list check and integrate the result:

 }: AutofixOptions): Promise<{
   fixResults: Record<string, FixStatus>;
   preCheckFailure?: PreCheckFailure;
 } | null> => {
-  const files = await prompt.fileSystemTreeSelect({
-    message: 'Select a file to migrate',
-    root: process.cwd(),
-    includeFiles: true,
-    includeHidden: false,
-    glob: ['**/*.tsx', '**/*.json'],
-    maxDepth: 3,
-    filter: (path) => {
-      return !path.includes('node_modules') && !path.includes('.git');
-    },
-  });
-  console.log({ files });
   if (list) {
     logAvailableMigrations();
     return null;
   }
+
+  // If file selection is needed, add it here after the list check
+  // and use `logger.debug` instead of console.log
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const files = await prompt.fileSystemTreeSelect({
message: 'Select a file to migrate',
root: process.cwd(),
includeFiles: true,
includeHidden: false,
glob: ['**/*.tsx', '**/*.json'],
maxDepth: 3,
filter: (path) => {
return !path.includes('node_modules') && !path.includes('.git');
},
});
console.log({ files });
if (list) {
logAvailableMigrations();
return null;
}
// If file selection is needed, add it here after the list check
// and use `logger.debug` instead of console.log
🤖 Prompt for AI Agents
In code/lib/cli-storybook/src/automigrate/index.ts around lines 129-140, the
interactive prompt and console.log are debug/incomplete: move or remove the
prompt so it does not run before the `if (list)` check (prevent blocking
--list), replace console.log with the server-side logger (import from
storybook/internal/node-logger) and either integrate the `files` result into the
migration flow or drop it if unused; ensure the prompt only runs when not
listing (e.g., after handling `if (list)`) and that any logging uses
logger.info/error rather than console.log.

if (list) {
logAvailableMigrations();
return null;
Expand Down
42 changes: 42 additions & 0 deletions code/lib/cli-storybook/src/bin/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { version } from '../../package.json';
import { add } from '../add';
import { doAutomigrate } from '../automigrate';
import { doctor } from '../doctor';
import { generateStories } from '../generate-stories';
import { link } from '../link';
import { migrate } from '../migrate';
import { sandbox } from '../sandbox';
Expand Down Expand Up @@ -302,6 +303,47 @@ command('doctor')
}).catch(handleCommandFailure(options.logfile));
});

command('generate-stories [glob]')
.description('Generate stories for components matching a glob pattern (picomatch syntax)')
.option('-i, --interactive', 'Interactively select which components to generate stories for')
.option('-f, --force', 'Force generation of stories even if they already exist')
.option(
'-s, --sample [num]',
'Only select a subset of N components to generate stories for (default: 10)'
)
.option('-c, --config-dir <dir-name>', 'Directory where to load Storybook configurations from')
.action(async (globPattern = 'src/**/*.{jsx,tsx}', options) => {
withTelemetry(
// @ts-expect-error - TODO: Add and enable telemetry if we decide to keep this command
'generate-stories',
{ cliOptions: { ...options, disableTelemetry: true } },
async () => {
logger.intro(`Generating stories for components matching: ${globPattern}`);

let sampleComponents: number | undefined = undefined;
if (options.sample) {
const n = parseInt(options.sample, 10);
sampleComponents = isNaN(n) ? 10 : n;
}

const result = await generateStories({
glob: globPattern,
interactive: options.interactive,
configDir: options.configDir,
force: !!options.force,
sampleComponents,
});

if (result.success) {
logger.outro('Story generation completed successfully!');
} else {
logger.outro('Story generation completed with errors');
process.exit(1);
}
}
).catch(handleCommandFailure(options.logfile));
});

program.on('command:*', ([invalidCmd]) => {
let errorMessage = ` Invalid command: ${picocolors.bold(invalidCmd)}.\n See --help for a list of available commands.`;
const availableCommands = program.commands.map((cmd) => cmd.name());
Expand Down
Loading
Loading