Преглед изворни кода

feat(cli): enhance form-materials cli ability (#799)

* feat: upgrade cli

* feat: enhance cli

* fix: eslint

* fix: enhance cli

* feat: enhance cli

* fix: enhance cli

* fix: enhance cli

* feat: enhance cli

* fix: cli lint

* fix: lint
Yiwei Mao пре 4 месеци
родитељ
комит
d6ce61a33c

+ 11 - 71
apps/cli/.eslintrc.cjs

@@ -3,77 +3,17 @@
  * SPDX-License-Identifier: MIT
  */
 
-module.exports = {
-  parser: "@typescript-eslint/parser",
-  parserOptions: {
-    requireConfigFile: false,
-    babelOptions: {
-      babelrc: false,
-      configFile: false,
-      cwd: __dirname,
-    },
-  },
-  ignorePatterns: [
-    '**/*.d.ts',
-    '**/__mocks__',
-    '**/node_modules',
-    '**/build',
-    '**/dist',
-    '**/es',
-    '**/lib',
-    '**/.codebase',
-    '**/.changeset',
-    '**/config',
-    '**/common/scripts',
-    '**/output',
-    'error-log-str.js',
-    '*.bundle.js',
-    '*.min.js',
-    '*.js.map',
-    '**/output',
-    '**/*.log',
-    '**/tsconfig.tsbuildinfo',
-    '**/vitest.config.ts',
-    'package.json',
-    '*.json',
-  ],
+const { defineConfig } = require('@flowgram.ai/eslint-config');
+
+module.exports = defineConfig({
+  preset: 'web',
+  packageRoot: __dirname,
   rules: {
     'no-console': 'off',
-    'react/no-deprecated': 'off',
-    'import/prefer-default-export': 'off',
-    'lines-between-class-members': 'warn',
-    'react/jsx-no-useless-fragment': 'off',
-    'no-unused-vars': 'off',
-    'no-redeclare': 'off',
-    'no-empty-fuNction': 'off',
-    'prefer-destructurin': 'off',
-    'no-underscore-dangle': 'off',
-    'no-empty-function': 'off',
-    'no-multi-assign': 'off',
-    'arrow-body-style': 'warn',
-    'no-useless-constructor': 'off',
-    'no-param-reassign': 'off',
-    'max-classes-per-file': 'off',
-    'grouped-accessor-pairs': 'off',
-    'no-plusplus': 'off',
-    'no-restricted-syntax': 'off',
-    'react/destructuring-assignment': 'off',
-    'import/extensions': 'off',
-    'consistent-return': 'off',
-    'jsx-a11y/no-static-element-interactions': 'off',
-    'no-use-before-define': 'off',
-    'no-bitwise': 'off',
-    'no-case-declarations': 'off',
-    'react/no-array-index-key': 'off',
-    'react/require-default-props': 'off',
-    'no-dupe-class-members': 'off',
-    'react/jsx-props-no-spreading': 'off',
-    'no-console': 'off',
-    'no-shadow': 'off',
-    'class-methods-use-this': 'off',
-    'default-param-last': 'off',
-    'no-unused-vars': 'off',
-    'import/prefer-default-export': 'off',
-    'import/extensions': 'off',
   },
-}
+  settings: {
+    react: {
+      version: '18',
+    },
+  },
+});

+ 6 - 1
apps/cli/package.json

@@ -13,7 +13,10 @@
   ],
   "scripts": {
     "build": "tsup src/index.ts --format esm,cjs --dts --out-dir dist",
-    "start": "node bin/index.js"
+    "watch": "tsup src/index.ts --format esm,cjs --out-dir dist --watch",
+    "start": "node bin/index.js",
+    "lint": "eslint ./src --cache",
+    "lint:fix": "eslint ./src --fix"
   },
   "dependencies": {
     "fs-extra": "^9.1.0",
@@ -24,6 +27,8 @@
     "ignore": "~7.0.5"
   },
   "devDependencies": {
+    "@flowgram.ai/eslint-config": "workspace:*",
+    "@types/download": "8.0.5",
     "@types/fs-extra": "11.0.4",
     "@types/node": "^18",
     "@types/inquirer": "9.0.7",

+ 6 - 12
apps/cli/src/create-app/index.ts

@@ -4,12 +4,13 @@
  */
 
 import path from 'path';
+import https from 'https';
 import { execSync } from 'child_process';
+
+import * as tar from 'tar';
 import inquirer from 'inquirer';
 import fs from 'fs-extra';
 import chalk from 'chalk';
-import * as tar from 'tar';
-import https from 'https';
 
 const updateFlowGramVersions = (dependencies: any[], latestVersion: string) => {
   for (const packageName in dependencies) {
@@ -50,9 +51,7 @@ function downloadFile(url: string, dest: string): Promise<void> {
 
 export const createApp = async (projectName?: string) => {
   console.log(chalk.green('Welcome to @flowgram.ai/create-app CLI!'));
-  const latest = execSync(
-    'npm view @flowgram.ai/demo-fixed-layout version --tag=latest latest'
-  )
+  const latest = execSync('npm view @flowgram.ai/demo-fixed-layout version --tag=latest latest')
     .toString()
     .trim();
 
@@ -115,10 +114,7 @@ export const createApp = async (projectName?: string) => {
           C: targetDir,
         });
 
-        fs.renameSync(
-          path.join(targetDir, 'package'),
-          path.join(targetDir, folderName)
-        );
+        fs.renameSync(path.join(targetDir, 'package'), path.join(targetDir, folderName));
 
         fs.unlinkSync(tempTarballPath);
         return true;
@@ -134,9 +130,7 @@ export const createApp = async (projectName?: string) => {
     const pkgJsonPath = path.join(targetDir, folderName, 'package.json');
     const data = fs.readFileSync(pkgJsonPath, 'utf-8');
 
-    const packageLatestVersion = execSync(
-      'npm view @flowgram.ai/core version --tag=latest latest'
-    )
+    const packageLatestVersion = execSync('npm view @flowgram.ai/core version --tag=latest latest')
       .toString()
       .trim();
 

+ 75 - 0
apps/cli/src/find-materials/index.ts

@@ -0,0 +1,75 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import chalk from 'chalk';
+
+import { traverseRecursiveTsFiles } from '../utils/ts-file';
+import { Project } from '../utils/project';
+import { loadNpm } from '../utils/npm';
+import { Material } from '../materials/material';
+
+export async function findUsedMaterials() {
+  // materialName can be undefined
+  console.log(chalk.bold('🚀 Welcome to @flowgram.ai form-materials CLI!'));
+
+  const project = await Project.getSingleton();
+  project.printInfo();
+
+  const formMaterialPkg = await loadNpm('@flowgram.ai/form-materials');
+  const materials: Material[] = Material.listAll(formMaterialPkg);
+
+  const allUsedMaterials = new Set<Material>();
+
+  const exportName2Material = new Map<string, Material>();
+
+  for (const material of materials) {
+    if (!material.indexFile) {
+      console.warn(`Material ${material.name} not found`);
+      return;
+    }
+
+    console.log(`👀 The exports of ${material.name} is ${material.allExportNames.join(',')}`);
+
+    material.allExportNames.forEach((exportName) => {
+      exportName2Material.set(exportName, material);
+    });
+  }
+
+  for (const tsFile of traverseRecursiveTsFiles(project.srcPath)) {
+    const fileMaterials = new Set<Material>();
+
+    let fileImportPrinted = false;
+    for (const importDeclaration of tsFile.imports) {
+      if (
+        !importDeclaration.source.startsWith('@flowgram.ai/form-materials') ||
+        !importDeclaration.namedImports?.length
+      ) {
+        continue;
+      }
+
+      if (!fileImportPrinted) {
+        fileImportPrinted = true;
+        console.log(chalk.bold(`\n👀 Searching ${tsFile.path}`));
+      }
+
+      console.log(`🔍 ${importDeclaration.statement}`);
+
+      if (importDeclaration.namedImports) {
+        importDeclaration.namedImports.forEach((namedImport) => {
+          const material = exportName2Material.get(namedImport.imported);
+
+          if (material) {
+            fileMaterials.add(material);
+            allUsedMaterials.add(material);
+            console.log(`import ${chalk.bold(material.fullName)} by ${namedImport.imported}`);
+          }
+        });
+      }
+    }
+  }
+
+  console.log(chalk.bold('\n📦 All used materials:'));
+  console.log([...allUsedMaterials].map((_material) => _material.fullName).join(','));
+}

+ 33 - 18
apps/cli/src/index.ts

@@ -3,44 +3,59 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { Command } from "commander";
+import path from 'path';
 
-import { syncMaterial } from "./materials";
-import { createApp } from "./create-app";
-import { updateFlowgramVersion } from "./update-version";
+import { Command } from 'commander';
+
+import { updateFlowgramVersion } from './update-version';
+import { syncMaterial } from './materials';
+import { findUsedMaterials } from './find-materials';
+import { createApp } from './create-app';
 
 const program = new Command();
 
-program.name("flowgram-cli").version("1.0.0").description("Flowgram CLI");
+program.name('flowgram-cli').version('1.0.0').description('Flowgram CLI');
 
 program
-  .command("create-app")
-  .description("Create a new flowgram project")
-  .argument("[string]", "Project name")
+  .command('create-app')
+  .description('Create a new flowgram project')
+  .argument('[string]', 'Project name')
   .action(async (projectName) => {
     await createApp(projectName);
   });
 
 program
-  .command("materials")
-  .description("Sync materials to the project")
-  .argument("[string]", "Material name")
-  .option(
-    "--refresh-project-imports",
-    "Refresh project imports to copied materials",
-    false,
+  .command('materials')
+  .description('Sync materials to the project')
+  .argument(
+    '[string]',
+    'Material name or names\nExample 1: components/variable-selector \nExample2: components/variable-selector,effect/provideJsonSchemaOutputs'
   )
+  .option('--refresh-project-imports', 'Refresh project imports to copied materials', false)
+  .option('--target-material-root-dir <string>', 'Target directory to copy materials')
+  .option('--select-multiple', 'Select multiple materials', false)
   .action(async (materialName, options) => {
     await syncMaterial({
       materialName,
       refreshProjectImports: options.refreshProjectImports,
+      targetMaterialRootDir: options.targetMaterialRootDir
+        ? path.join(process.cwd(), options.targetMaterialRootDir)
+        : undefined,
+      selectMultiple: options.selectMultiple,
     });
   });
 
 program
-  .command("update-version")
-  .description("Update flowgram version in the project")
-  .argument("[string]", "Flowgram version")
+  .command('find-used-materials')
+  .description('Find used materials in the project')
+  .action(async () => {
+    await findUsedMaterials();
+  });
+
+program
+  .command('update-version')
+  .description('Update flowgram version in the project')
+  .argument('[string]', 'Flowgram version')
   .action(async (version) => {
     await updateFlowgramVersion(version);
   });

+ 60 - 0
apps/cli/src/materials/copy.ts

@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import path from 'path';
+import fs from 'fs';
+
+import { traverseRecursiveTsFiles } from '../utils/ts-file';
+import { SyncMaterialContext } from './types';
+
+interface CopyMaterialReturn {
+  packagesToInstall: string[];
+}
+
+export const copyMaterials = (ctx: SyncMaterialContext): CopyMaterialReturn => {
+  const { selectedMaterials, project, formMaterialPkg, targetFormMaterialRoot } = ctx;
+  const formMaterialDependencies = formMaterialPkg.dependencies;
+  const packagesToInstall: Set<string> = new Set();
+
+  for (const material of selectedMaterials) {
+    const sourceDir: string = material.sourceDir;
+    const targetDir: string = path.join(targetFormMaterialRoot, material.type, material.name);
+
+    fs.cpSync(sourceDir, targetDir, { recursive: true });
+
+    for (const file of traverseRecursiveTsFiles(targetDir)) {
+      for (const importDeclaration of file.imports) {
+        const { source } = importDeclaration;
+
+        if (source.startsWith('@/')) {
+          // is inner import
+          console.log(`Replace Import from ${source} to @flowgram.ai/form-materials`);
+          file.replaceImport(
+            [importDeclaration],
+            [{ ...importDeclaration, source: '@flowgram.ai/form-materials' }]
+          );
+          packagesToInstall.add(`@flowgram.ai/form-materials@${project.flowgramVersion}`);
+        } else if (!source.startsWith('.') && !source.startsWith('react')) {
+          // check if is in form material dependencies
+          const [dep, version] =
+            Object.entries(formMaterialDependencies).find(([_key]) => source.startsWith(_key)) ||
+            [];
+          if (!dep) {
+            continue;
+          }
+          if (dep.startsWith('@flowgram.ai/')) {
+            packagesToInstall.add(`${dep}@${project.flowgramVersion}`);
+          } else {
+            packagesToInstall.add(`${dep}@${version}`);
+          }
+        }
+      }
+    }
+  }
+
+  return {
+    packagesToInstall: [...packagesToInstall],
+  };
+};

+ 45 - 74
apps/cli/src/materials/index.ts

@@ -3,105 +3,76 @@
  * SPDX-License-Identifier: MIT
  */
 
-import inquirer from "inquirer";
-import chalk from "chalk";
+import path from 'path';
 
-import { copyMaterial, listAllMaterials, Material } from "./materials";
-import { loadNpm } from "../utils/npm";
-import path from "path";
-import { Project } from "../utils/project";
-import { executeRefreshProjectImport } from "./refresh-project-import";
+import chalk from 'chalk';
 
-export async function syncMaterial(opts: {
-  materialName?: string;
-  refreshProjectImports?: boolean;
-}) {
-  const { materialName, refreshProjectImports } = opts;
+import { Project } from '../utils/project';
+import { loadNpm } from '../utils/npm';
+import { MaterialCliOptions, SyncMaterialContext } from './types';
+import { getSelectedMaterials } from './select';
+import { executeRefreshProjectImport } from './refresh-project-import';
+import { Material } from './material';
+import { copyMaterials } from './copy';
+
+export async function syncMaterial(cliOpts: MaterialCliOptions) {
+  const { refreshProjectImports, targetMaterialRootDir } = cliOpts;
 
   // materialName can be undefined
-  console.log(chalk.bold("🚀 Welcome to @flowgram.ai form-materials!"));
+  console.log(chalk.bold('🚀 Welcome to @flowgram.ai form-materials CLI!'));
 
   const project = await Project.getSingleton();
   project.printInfo();
 
+  // where to place all material in target project
+  const targetFormMaterialRoot =
+    targetMaterialRootDir || path.join(project.projectPath, 'src', 'form-materials');
+  console.log(chalk.black(`  - Target material root: ${targetFormMaterialRoot}`));
+
   if (!project.flowgramVersion) {
     throw new Error(
       chalk.red(
-        "❌ Please install @flowgram.ai/fixed-layout-editor or @flowgram.ai/free-layout-editor",
-      ),
+        '❌ Please install @flowgram.ai/fixed-layout-editor or @flowgram.ai/free-layout-editor'
+      )
     );
   }
 
-  const formMaterialPath = await loadNpm("@flowgram.ai/form-materials");
-  const formMaterialSrc = path.join(formMaterialPath, "src");
-
-  const materials: Material[] = listAllMaterials(formMaterialSrc);
+  const formMaterialPkg = await loadNpm('@flowgram.ai/form-materials');
 
-  let material: Material | undefined; // material can be undefined
-
-  // 1. Check if materialName is provided and exists in materials
-  if (materialName) {
-    const selectedMaterial = materials.find(
-      (m) => `${m.type}/${m.name}` === materialName,
-    );
-    if (selectedMaterial) {
-      material = selectedMaterial;
-      console.log(chalk.green(`Using material: ${materialName}`));
-    } else {
-      console.log(
-        chalk.yellow(
-          `Material "${materialName}" not found. Please select from the list:`,
-        ),
-      );
-    }
-  }
+  let selectedMaterials: Material[] = await getSelectedMaterials(cliOpts, formMaterialPkg);
 
-  // 2. If material not found or materialName not provided, prompt user to select
-  if (!material) {
-    // User select one component
-    const result = await inquirer.prompt<{
-      material: Material; // Specify type for prompt result
-    }>([
-      {
-        type: "list",
-        name: "material",
-        message: "Select one material to add:",
-        choices: [
-          ...materials.map((_material) => ({
-            name: `${_material.type}/${_material.name}`,
-            value: _material,
-          })),
-        ],
-      },
-    ]);
-    material = result.material;
-  }
   // Ensure material is defined before proceeding
-  if (!material) {
-    console.error(chalk.red("No material selected. Exiting."));
+  if (!selectedMaterials.length) {
+    console.error(chalk.red('No material selected. Exiting.'));
     process.exit(1);
   }
 
-  // 3. Refresh project imports
+  const context: SyncMaterialContext = {
+    selectedMaterials: selectedMaterials,
+    project,
+    formMaterialPkg,
+    cliOpts,
+    targetFormMaterialRoot,
+  };
+
+  // Copy the materials to the project
+  console.log(chalk.bold('🚀 The following materials will be added to your project'));
+  console.log(selectedMaterials.map((material) => `📦 ${material.fullName}`).join('\n'));
+  console.log('\n');
+
+  let { packagesToInstall } = copyMaterials(context);
+
+  // Refresh project imports
   if (refreshProjectImports) {
-    console.log(chalk.bold("🚀 Refresh imports in your project"));
-    executeRefreshProjectImport(project, material);
+    console.log(chalk.bold('🚀 Refresh imports in your project'));
+    executeRefreshProjectImport(context);
   }
 
-  // 4. Copy the materials to the project
-  console.log(
-    chalk.bold("🚀 The following materials will be added to your project"),
-  );
-  console.log(material);
-  let { packagesToInstall } = copyMaterial(material, project, formMaterialPath);
-
-  // 4. Install the dependencies
+  // Install the dependencies
   await project.addDependencies(packagesToInstall);
-  console.log(
-    chalk.bold("✅ These npm dependencies is added to your package.json"),
-  );
+  console.log(chalk.bold('\n✅ These npm dependencies is added to your package.json'));
   packagesToInstall.forEach((_package) => {
     console.log(`- ${_package}`);
   });
-  console.log(chalk.bold("\n➡️ Please run npm install to install dependencies\n"));
+  console.log(chalk.bold(chalk.bold('\n➡️ Please run npm install to install dependencies\n')));
 }

+ 61 - 0
apps/cli/src/materials/material.ts

@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import path from 'path';
+import { readdirSync } from 'fs';
+
+import { getIndexTsFile } from '../utils/ts-file';
+import { LoadedNpmPkg } from '../utils/npm';
+
+export class Material {
+  protected static _all_materials_cache: Material[] = [];
+
+  static ALL_TYPES = [
+    'components',
+    'effects',
+    'plugins',
+    'shared',
+    'validate',
+    'form-plugins',
+    'hooks',
+  ];
+
+  constructor(public type: string, public name: string, public formMaterialPkg: LoadedNpmPkg) {}
+
+  get fullName() {
+    return `${this.type}/${this.name}`;
+  }
+
+  get sourceDir() {
+    return path.join(this.formMaterialPkg.srcPath, this.type, this.name);
+  }
+
+  get indexFile() {
+    return getIndexTsFile(this.sourceDir);
+  }
+
+  get allExportNames() {
+    return this.indexFile?.allExportNames || [];
+  }
+
+  static listAll(formMaterialPkg: LoadedNpmPkg): Material[] {
+    if (!this._all_materials_cache.length) {
+      this._all_materials_cache = Material.ALL_TYPES.map((type) => {
+        const materialsPath: string = path.join(formMaterialPkg.srcPath, type);
+        return readdirSync(materialsPath)
+          .map((_path: string) => {
+            if (_path === 'index.ts') {
+              return null;
+            }
+
+            return new Material(type, _path, formMaterialPkg);
+          })
+          .filter((material): material is Material => material !== null);
+      }).flat();
+    }
+
+    return this._all_materials_cache;
+  }
+}

+ 0 - 126
apps/cli/src/materials/materials.ts

@@ -1,126 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-import path from "path";
-import fs from "fs";
-
-import { Project } from "../utils/project"; // Import ProjectInfo
-import { traverseRecursiveTsFiles } from "../utils/ts-file";
-
-// Added type definitions
-export interface Material {
-  name: string;
-  type: string;
-  path: string;
-  [key: string]: any; // For other properties from config.json
-}
-
-const _types: string[] = [
-  "components",
-  "effects",
-  "plugins",
-  "shared",
-  "validate",
-  "form-plugins",
-  "hooks",
-];
-
-export function listAllMaterials(formMaterialSrc: string): Material[] {
-  const _materials: Material[] = [];
-
-  for (const _type of _types) {
-    // 在 Node.js 中,import.meta.dirname 不可用,可使用 import.meta.url 结合 url 模块来获取目录路径
-
-    const materialsPath: string = path.join(formMaterialSrc, _type);
-    _materials.push(
-      ...fs
-        .readdirSync(materialsPath)
-        .map((_path: string) => {
-          if (_path === "index.ts") {
-            return null;
-          }
-
-          return {
-            name: _path, // Assuming the folder name is the material name
-            type: _type,
-            path: path.join(materialsPath, _path),
-          } as Material;
-        })
-        .filter((material): material is Material => material !== null),
-    );
-  }
-
-  return _materials;
-}
-
-export const getFormMaterialDependencies = (
-  formMaterialPath: string,
-): Record<string, string> => {
-  const packageJsonPath: string = path.join(formMaterialPath, "package.json");
-  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
-
-  return packageJson.dependencies;
-};
-
-export const copyMaterial = (
-  material: Material,
-  project: Project,
-  formMaterialPath: string,
-): {
-  packagesToInstall: string[];
-} => {
-  const formMaterialDependencies =
-    getFormMaterialDependencies(formMaterialPath);
-
-  const sourceDir: string = material.path;
-  const materialRoot: string = path.join(
-    project.projectPath,
-    "src",
-    "form-materials",
-    `${material.type}`,
-  );
-  const targetDir = path.join(materialRoot, material.name);
-  const packagesToInstall: Set<string> = new Set();
-
-  fs.cpSync(sourceDir, targetDir, { recursive: true });
-
-  for (const file of traverseRecursiveTsFiles(targetDir)) {
-    for (const importDeclaration of file.imports) {
-      const { source } = importDeclaration;
-
-      if (source.startsWith("@/")) {
-        // is inner import
-        console.log(
-          `Replace Import from ${source} to @flowgram.ai/form-materials`,
-        );
-        file.replaceImport(
-          [importDeclaration],
-          [{ ...importDeclaration, source: "@flowgram.ai/form-materials" }],
-        );
-        packagesToInstall.add(
-          `@flowgram.ai/form-materials@${project.flowgramVersion}`,
-        );
-      } else if (!source.startsWith(".") && !source.startsWith("react")) {
-        // check if is in form material dependencies
-        const [dep, version] =
-          Object.entries(formMaterialDependencies).find(([_key]) =>
-            source.startsWith(_key),
-          ) || [];
-        if (!dep) {
-          continue;
-        }
-        if (dep.startsWith("@flowgram.ai/")) {
-          packagesToInstall.add(`${dep}@${project.flowgramVersion}`);
-        } else {
-          packagesToInstall.add(`${dep}@${version}`);
-        }
-      }
-    }
-  }
-
-  return {
-    packagesToInstall: [...packagesToInstall],
-  };
-};

+ 55 - 36
apps/cli/src/materials/refresh-project-import.ts

@@ -3,54 +3,73 @@
  * SPDX-License-Identifier: MIT
  */
 
-import chalk from "chalk";
-import { ImportDeclaration } from "../utils/import";
-import { Project } from "../utils/project";
-import { getIndexTsFile, traverseRecursiveTsFiles } from "../utils/ts-file";
-import { Material } from "./materials";
-
-export function executeRefreshProjectImport(
-  project: Project,
-  material: Material,
-) {
-  const materialFile = getIndexTsFile(material.path);
-
-  if (!materialFile) {
-    console.warn(`Material ${material.name} not found`);
-    return;
-  }
+import path from 'path';
+
+import chalk from 'chalk';
+
+import { traverseRecursiveTsFiles } from '../utils/ts-file';
+import { ImportDeclaration, NamedImport } from '../utils/import';
+import { SyncMaterialContext } from './types';
+import { Material } from './material';
 
-  const targetDir = `@/form-materials/${material.type}/${material.name}`;
+export function executeRefreshProjectImport(context: SyncMaterialContext) {
+  const { selectedMaterials, project, targetFormMaterialRoot } = context;
 
-  const exportNames = materialFile.allExportNames;
+  const exportName2Material = new Map<string, Material>();
 
-  console.log(`👀 The exports of ${material.name} is ${exportNames.join(",")}`);
+  const targetModule = `@/${path.relative(project.srcPath, targetFormMaterialRoot)}`;
+
+  for (const material of selectedMaterials) {
+    if (!material.indexFile) {
+      console.warn(`Material ${material.name} not found`);
+      return;
+    }
+
+    console.log(`👀 The exports of ${material.name} is ${material.allExportNames.join(',')}`);
+
+    material.allExportNames.forEach((exportName) => {
+      exportName2Material.set(exportName, material);
+    });
+  }
 
   for (const tsFile of traverseRecursiveTsFiles(project.srcPath)) {
     for (const importDeclaration of tsFile.imports) {
-      if (importDeclaration.source === "@flowgram.ai/form-materials") {
-        const currentMaterialImports = importDeclaration.namedImports?.filter(
-          (item) => exportNames.includes(item.imported),
-        );
-        if (!currentMaterialImports?.length) {
+      if (importDeclaration.source.startsWith('@flowgram.ai/form-materials')) {
+        // Import Module and its related named Imported
+        const restImports: NamedImport[] = [];
+        const importMap: Record<string, NamedImport[]> = {};
+
+        if (!importDeclaration.namedImports) {
           continue;
         }
-        const nextImports: ImportDeclaration[] = [
-          {
-            ...importDeclaration,
-            namedImports: currentMaterialImports,
-            source: targetDir,
-          },
-        ];
 
-        const keepImportNames = importDeclaration.namedImports?.filter(
-          (item) => !exportNames.includes(item.imported),
+        for (const nameImport of importDeclaration.namedImports) {
+          const material = exportName2Material.get(nameImport.imported);
+          if (material) {
+            const importModule = `${targetModule}/${material.fullName}`;
+            importMap[importModule] = importMap[importModule] || [];
+            importMap[importModule].push(nameImport);
+          } else {
+            restImports.push(nameImport);
+          }
+        }
+
+        if (Object.keys(importMap).length === 0) {
+          continue;
+        }
+
+        const nextImports: ImportDeclaration[] = Object.entries(importMap).map(
+          ([importModule, namedImports]) => ({
+            ...importDeclaration,
+            namedImports,
+            source: importModule,
+          })
         );
 
-        if (keepImportNames?.length) {
+        if (restImports?.length) {
           nextImports.unshift({
             ...importDeclaration,
-            namedImports: keepImportNames,
+            namedImports: restImports,
           });
         }
 
@@ -60,7 +79,7 @@ export function executeRefreshProjectImport(
         console.log(
           `From:\n${importDeclaration.statement}\nTo:\n${nextImports
             .map((item) => item.statement)
-            .join("\n")}`,
+            .join('\n')}`
         );
       }
     }

+ 69 - 0
apps/cli/src/materials/select.ts

@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import inquirer from 'inquirer';
+import chalk from 'chalk';
+
+import { LoadedNpmPkg } from '../utils/npm';
+import { MaterialCliOptions } from './types';
+import { Material } from './material';
+
+export const getSelectedMaterials = async (
+  cliOpts: MaterialCliOptions,
+  formMaterialPkg: LoadedNpmPkg
+) => {
+  const { materialName, selectMultiple } = cliOpts;
+
+  const materials: Material[] = Material.listAll(formMaterialPkg);
+
+  let selectedMaterials: Material[] = [];
+
+  // 1. Check if materialName is provided and exists in materials
+  if (materialName) {
+    selectedMaterials = materialName
+      .split(',')
+      .map((_name) => materials.find((_m) => _m.fullName === _name.trim())!)
+      .filter(Boolean);
+  }
+
+  // 2. If material not found or materialName not provided, prompt user to select
+  if (!selectedMaterials.length) {
+    console.log(chalk.yellow(`Material "${materialName}" not found. Please select from the list:`));
+
+    const choices = materials.map((_material) => ({
+      name: _material.fullName,
+      value: _material,
+    }));
+    if (selectMultiple) {
+      // User select one component
+      const result = await inquirer.prompt<{
+        material: Material[]; // Specify type for prompt result
+      }>([
+        {
+          type: 'checkbox',
+          name: 'material',
+          message: 'Select multiple materials to add:',
+          choices: choices,
+        },
+      ]);
+      selectedMaterials = result.material;
+    } else {
+      // User select one component
+      const result = await inquirer.prompt<{
+        material: Material; // Specify type for prompt result
+      }>([
+        {
+          type: 'list',
+          name: 'material',
+          message: 'Select one material to add:',
+          choices: choices,
+        },
+      ]);
+      selectedMaterials = [result.material];
+    }
+  }
+
+  return selectedMaterials;
+};

+ 23 - 0
apps/cli/src/materials/types.ts

@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { Project } from '../utils/project';
+import { LoadedNpmPkg } from '../utils/npm';
+import { Material } from './material';
+
+export interface MaterialCliOptions {
+  materialName?: string;
+  refreshProjectImports?: boolean;
+  targetMaterialRootDir?: string;
+  selectMultiple?: boolean;
+}
+
+export interface SyncMaterialContext {
+  selectedMaterials: Material[];
+  project: Project;
+  formMaterialPkg: LoadedNpmPkg;
+  cliOpts: MaterialCliOptions;
+  targetFormMaterialRoot: string;
+}

+ 12 - 11
apps/cli/src/update-version/index.ts

@@ -3,30 +3,31 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { getLatestVersion } from "../utils/npm";
-import { traverseRecursiveFiles } from "../utils/file";
-import chalk from "chalk";
+import chalk from 'chalk';
+
+import { getLatestVersion } from '../utils/npm';
+import { traverseRecursiveFiles } from '../utils/file';
 
 export async function updateFlowgramVersion(inputVersion?: string) {
-  console.log(chalk.bold("🚀 Welcome to @flowgram.ai update-version helper"));
+  console.log(chalk.bold('🚀 Welcome to @flowgram.ai update-version helper'));
 
   // Get latest version
-  const latestVersion = await getLatestVersion("@flowgram.ai/editor");
+  const latestVersion = await getLatestVersion('@flowgram.ai/editor');
   const currentPath = process.cwd();
-  console.log("- Latest flowgram version: ", latestVersion);
-  console.log("- Current Path: ", currentPath);
+  console.log('- Latest flowgram version: ', latestVersion);
+  console.log('- Current Path: ', currentPath);
 
   // User Input flowgram version, default is latestVersion
   const flowgramVersion: string = inputVersion || latestVersion;
 
   for (const file of traverseRecursiveFiles(currentPath)) {
-    if (file.path.endsWith("package.json")) {
-      console.log("👀 Find package.json: ", file.path);
+    if (file.path.endsWith('package.json')) {
+      console.log('👀 Find package.json: ', file.path);
       let updated = false;
       const json = JSON.parse(file.content);
       if (json.dependencies) {
         for (const key in json.dependencies) {
-          if (key.startsWith("@flowgram.ai/")) {
+          if (key.startsWith('@flowgram.ai/')) {
             updated = true;
             json.dependencies[key] = flowgramVersion;
             console.log(`- Update ${key} to ${flowgramVersion}`);
@@ -35,7 +36,7 @@ export async function updateFlowgramVersion(inputVersion?: string) {
       }
       if (json.devDependencies) {
         for (const key in json.devDependencies) {
-          if (key.startsWith("@flowgram.ai/")) {
+          if (key.startsWith('@flowgram.ai/')) {
             updated = true;
             json.devDependencies[key] = flowgramVersion;
             console.log(`- Update ${key} to ${flowgramVersion}`);

+ 8 - 11
apps/cli/src/utils/export.ts

@@ -9,10 +9,7 @@ export function extractNamedExports(content: string) {
 
   // Collect all type definition names
   const typeDefinitions = new Set();
-  const typePatterns = [
-    /\b(?:type|interface)\s+(\w+)/g,
-    /\bexport\s+(?:type|interface)\s+(\w+)/g,
-  ];
+  const typePatterns = [/\b(?:type|interface)\s+(\w+)/g, /\bexport\s+(?:type|interface)\s+(\w+)/g];
 
   let match;
   for (const pattern of typePatterns) {
@@ -41,7 +38,7 @@ export function extractNamedExports(content: string) {
   exportPatterns[0].lastIndex = 0;
   while ((match = exportPatterns[0].exec(content)) !== null) {
     const [, kind, name] = match;
-    if (kind === "type" || kind === "interface" || typeDefinitions.has(name)) {
+    if (kind === 'type' || kind === 'interface' || typeDefinitions.has(name)) {
       typeExports.push(name);
     } else {
       valueExports.push(name);
@@ -52,13 +49,13 @@ export function extractNamedExports(content: string) {
   exportPatterns[1].lastIndex = 0;
   while ((match = exportPatterns[1].exec(content)) !== null) {
     const exportsList = match[1]
-      .split(",")
+      .split(',')
       .map((item) => item.trim())
-      .filter((item) => item && !item.includes(" as "));
+      .filter((item) => item && !item.includes(' as '));
 
     for (const name of exportsList) {
-      if (name.startsWith("type ")) {
-        typeExports.push(name.replace("type ", "").trim());
+      if (name.startsWith('type ')) {
+        typeExports.push(name.replace('type ', '').trim());
       } else if (typeDefinitions.has(name)) {
         typeExports.push(name);
       } else {
@@ -93,9 +90,9 @@ export function extractNamedExports(content: string) {
   exportPatterns[4].lastIndex = 0;
   while ((match = exportPatterns[4].exec(content)) !== null) {
     const exportsList = match[1]
-      .split(",")
+      .split(',')
       .map((item) => item.trim())
-      .filter((item) => item && !item.includes(" as "));
+      .filter((item) => item && !item.includes(' as '));
 
     for (const name of exportsList) {
       typeExports.push(name);

+ 14 - 13
apps/cli/src/utils/file.ts

@@ -3,9 +3,10 @@
  * SPDX-License-Identifier: MIT
  */
 
-import path from "path";
-import fs from "fs";
-import ignore, { Ignore } from "ignore";
+import path from 'path';
+import fs from 'fs';
+
+import ignore, { Ignore } from 'ignore';
 
 export class File {
   content: string;
@@ -18,7 +19,7 @@ export class File {
 
   suffix: string;
 
-  constructor(filePath: string, public root: string = "/") {
+  constructor(filePath: string, public root: string = '/') {
     this.path = filePath;
     this.relativePath = path.relative(this.root, this.path);
     this.suffix = path.extname(this.path);
@@ -30,7 +31,7 @@ export class File {
 
     // If no utf-8, skip
     try {
-      this.content = fs.readFileSync(this.path, "utf-8");
+      this.content = fs.readFileSync(this.path, 'utf-8');
       this.isUtf8 = true;
     } catch (e) {
       this.isUtf8 = false;
@@ -40,29 +41,29 @@ export class File {
 
   replace(updater: (content: string) => string) {
     if (!this.isUtf8) {
-      console.warn("Not UTF-8 file skipped: ", this.path);
+      console.warn('Not UTF-8 file skipped: ', this.path);
       return;
     }
     this.content = updater(this.content);
-    fs.writeFileSync(this.path, this.content, "utf-8");
+    fs.writeFileSync(this.path, this.content, 'utf-8');
   }
 
   write(nextContent: string) {
     this.content = nextContent;
-    fs.writeFileSync(this.path, this.content, "utf-8");
+    fs.writeFileSync(this.path, this.content, 'utf-8');
   }
 }
 
 export function* traverseRecursiveFilePaths(
   folder: string,
-  ig: Ignore = ignore().add(".git"),
-  root: string = folder,
+  ig: Ignore = ignore().add('.git'),
+  root: string = folder
 ): Generator<string> {
   const files = fs.readdirSync(folder);
 
   // add .gitignore to ignore if exists
-  if (fs.existsSync(path.join(folder, ".gitignore"))) {
-    ig.add(fs.readFileSync(path.join(folder, ".gitignore"), "utf-8"));
+  if (fs.existsSync(path.join(folder, '.gitignore'))) {
+    ig.add(fs.readFileSync(path.join(folder, '.gitignore'), 'utf-8'));
   }
 
   for (const file of files) {
@@ -82,6 +83,6 @@ export function* traverseRecursiveFilePaths(
 
 export function* traverseRecursiveFiles(folder: string): Generator<File> {
   for (const filePath of traverseRecursiveFilePaths(folder)) {
-    yield new File(filePath);
+    yield new File(filePath, folder);
   }
 }

+ 7 - 5
apps/cli/src/utils/import.ts

@@ -3,6 +3,12 @@
  * SPDX-License-Identifier: MIT
  */
 
+export interface NamedImport {
+  local?: string;
+  imported: string;
+  typeOnly?: boolean;
+}
+
 /**
  * Cases
  * import { A, B } from 'module';
@@ -16,11 +22,7 @@ export interface ImportDeclaration {
   statement: string;
 
   // import { A, B } from 'module';
-  namedImports?: {
-    local?: string;
-    imported: string;
-    typeOnly?: boolean;
-  }[];
+  namedImports?: NamedImport[];
 
   // import A from 'module';
   defaultImport?: string;

+ 37 - 18
apps/cli/src/utils/npm.ts

@@ -3,17 +3,37 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { execSync } from 'child_process';
-import { existsSync } from 'fs';
-import fs from 'fs-extra';
 import path from 'path';
 import https from 'https';
+import { existsSync, readFileSync } from 'fs';
+import { execSync } from 'child_process';
+
 import * as tar from 'tar';
+import fs from 'fs-extra';
 
-export async function getLatestVersion(packageName: string): Promise<string> {
-  return execSync(`npm view ${packageName} version --tag=latest`)
-    .toString()
-    .trim();
+export class LoadedNpmPkg {
+  constructor(public name: string, public version: string, public path: string) {}
+
+  get srcPath() {
+    return path.join(this.path, 'src');
+  }
+
+  get distPath() {
+    return path.join(this.path, 'dist');
+  }
+
+  protected _packageJson: Record<string, any>;
+
+  get packageJson() {
+    if (!this._packageJson) {
+      this._packageJson = JSON.parse(readFileSync(path.join(this.path, 'package.json'), 'utf8'));
+    }
+    return this._packageJson;
+  }
+
+  get dependencies(): Record<string, string> {
+    return this.packageJson.dependencies;
+  }
 }
 
 // 使用 https 下载文件
@@ -45,30 +65,29 @@ function downloadFile(url: string, dest: string): Promise<void> {
   });
 }
 
-export async function loadNpm(packageName: string): Promise<string> {
+export async function getLatestVersion(packageName: string): Promise<string> {
+  return execSync(`npm view ${packageName} version --tag=latest`).toString().trim();
+}
+
+export async function loadNpm(packageName: string): Promise<LoadedNpmPkg> {
   const packageLatestVersion = await getLatestVersion(packageName);
 
-  const packagePath = path.join(
-    __dirname,
-    `./.download/${packageName}-${packageLatestVersion}`,
-  );
+  const packagePath = path.join(__dirname, `./.download/${packageName}-${packageLatestVersion}`);
 
   if (existsSync(packagePath)) {
-    return packagePath;
+    return new LoadedNpmPkg(packageName, packageLatestVersion, packagePath);
   }
 
   try {
     // 获取 tarball 地址
-    const tarballUrl = execSync(
-      `npm view ${packageName}@${packageLatestVersion} dist.tarball`,
-    )
+    const tarballUrl = execSync(`npm view ${packageName}@${packageLatestVersion} dist.tarball`)
       .toString()
       .trim();
 
     // 临时 tarball 路径
     const tempTarballPath = path.join(
       __dirname,
-      `./.download/${packageName}-${packageLatestVersion}.tgz`,
+      `./.download/${packageName}-${packageLatestVersion}.tgz`
     );
 
     // 确保目录存在
@@ -89,7 +108,7 @@ export async function loadNpm(packageName: string): Promise<string> {
     // 删除临时文件
     fs.unlinkSync(tempTarballPath);
 
-    return packagePath;
+    return new LoadedNpmPkg(packageName, packageLatestVersion, packagePath);
   } catch (error) {
     console.error(`Error downloading or extracting package: ${error}`);
     throw error;

+ 20 - 27
apps/cli/src/utils/project.ts

@@ -3,10 +3,12 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { existsSync, readFileSync, writeFileSync } from "fs";
-import path from "path";
-import { getLatestVersion } from "./npm";
-import chalk from "chalk";
+import path from 'path';
+import { existsSync, readFileSync, writeFileSync } from 'fs';
+
+import chalk from 'chalk';
+
+import { getLatestVersion } from './npm';
 
 interface PackageJson {
   dependencies: { [key: string]: string };
@@ -32,26 +34,23 @@ export class Project {
     // get nearest package.json
     let projectPath: string = process.cwd();
 
-    while (
-      projectPath !== "/" &&
-      !existsSync(path.join(projectPath, "package.json"))
-    ) {
-      projectPath = path.join(projectPath, "..");
+    while (projectPath !== '/' && !existsSync(path.join(projectPath, 'package.json'))) {
+      projectPath = path.join(projectPath, '..');
     }
-    if (projectPath === "/") {
-      throw new Error("Please run this command in a valid project");
+    if (projectPath === '/') {
+      throw new Error('Please run this command in a valid project');
     }
 
     this.projectPath = projectPath;
 
-    this.srcPath = path.join(projectPath, "src");
-    this.packageJsonPath = path.join(projectPath, "package.json");
-    this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, "utf8"));
+    this.srcPath = path.join(projectPath, 'src');
+    this.packageJsonPath = path.join(projectPath, 'package.json');
+    this.packageJson = JSON.parse(readFileSync(this.packageJsonPath, 'utf8'));
 
     this.flowgramVersion =
-      this.packageJson.dependencies["@flowgram.ai/fixed-layout-editor"] ||
-      this.packageJson.dependencies["@flowgram.ai/free-layout-editor"] ||
-      this.packageJson.dependencies["@flowgram.ai/editor"];
+      this.packageJson.dependencies['@flowgram.ai/fixed-layout-editor'] ||
+      this.packageJson.dependencies['@flowgram.ai/free-layout-editor'] ||
+      this.packageJson.dependencies['@flowgram.ai/editor'];
   }
 
   async addDependency(dependency: string) {
@@ -59,7 +58,7 @@ export class Project {
     let version: string;
 
     // 处理作用域包(如 @types/react@1.0.0)
-    const lastAtIndex = dependency.lastIndexOf("@");
+    const lastAtIndex = dependency.lastIndexOf('@');
 
     if (lastAtIndex <= 0) {
       // 没有@符号 或者@在开头(如 @types/react)
@@ -77,10 +76,7 @@ export class Project {
     }
 
     this.packageJson.dependencies[name] = version;
-    writeFileSync(
-      this.packageJsonPath,
-      JSON.stringify(this.packageJson, null, 2),
-    );
+    writeFileSync(this.packageJsonPath, JSON.stringify(this.packageJson, null, 2));
   }
 
   async addDependencies(dependencies: string[]) {
@@ -90,14 +86,11 @@ export class Project {
   }
 
   writeToPackageJsonFile() {
-    writeFileSync(
-      this.packageJsonPath,
-      JSON.stringify(this.packageJson, null, 2),
-    );
+    writeFileSync(this.packageJsonPath, JSON.stringify(this.packageJson, null, 2));
   }
 
   printInfo() {
-    console.log(chalk.bold("Project Info:"));
+    console.log(chalk.bold('Project Info:'));
     console.log(chalk.black(`  - Flowgram Version: ${this.flowgramVersion}`));
     console.log(chalk.black(`  - Project Path: ${this.projectPath}`));
   }

+ 23 - 38
apps/cli/src/utils/ts-file.ts

@@ -3,15 +3,12 @@
  * SPDX-License-Identifier: MIT
  */
 
-import path from "path";
-import fs from "fs";
-import { File, traverseRecursiveFilePaths } from "./file";
-import { extractNamedExports } from "./export";
-import {
-  assembleImport,
-  ImportDeclaration,
-  traverseFileImports,
-} from "./import";
+import path, { join } from 'path';
+import fs from 'fs';
+
+import { assembleImport, ImportDeclaration, traverseFileImports } from './import';
+import { File, traverseRecursiveFilePaths } from './file';
+import { extractNamedExports } from './export';
 
 class TsFile extends File {
   exports: {
@@ -28,13 +25,11 @@ class TsFile extends File {
     return [...this.exports.values, ...this.exports.types];
   }
 
-  constructor(filePath: string) {
-    super(filePath);
+  constructor(filePath: string, root?: string) {
+    super(filePath, root);
 
-    this.exports = extractNamedExports(fs.readFileSync(filePath, "utf-8"));
-    this.imports = Array.from(
-      traverseFileImports(fs.readFileSync(filePath, "utf-8")),
-    );
+    this.exports = extractNamedExports(fs.readFileSync(filePath, 'utf-8'));
+    this.imports = Array.from(traverseFileImports(fs.readFileSync(filePath, 'utf-8')));
   }
 
   addImport(importDeclarations: ImportDeclaration[]) {
@@ -46,9 +41,7 @@ class TsFile extends File {
       const lastImportStatement = this.imports[this.imports.length - 1];
       return content.replace(
         lastImportStatement.statement,
-        `${lastImportStatement?.statement}\n${importDeclarations.map(
-          (item) => item.statement,
-        )}\n`,
+        `${lastImportStatement?.statement}\n${importDeclarations.map((item) => item.statement)}\n`
       );
     });
     this.imports.push(...importDeclarations);
@@ -56,20 +49,12 @@ class TsFile extends File {
 
   removeImport(importDeclarations: ImportDeclaration[]) {
     this.replace((content) =>
-      importDeclarations.reduce(
-        (prev, cur) => prev.replace(cur.statement, ""),
-        content,
-      ),
-    );
-    this.imports = this.imports.filter(
-      (item) => !importDeclarations.includes(item),
+      importDeclarations.reduce((prev, cur) => prev.replace(cur.statement, ''), content)
     );
+    this.imports = this.imports.filter((item) => !importDeclarations.includes(item));
   }
 
-  replaceImport(
-    oldImports: ImportDeclaration[],
-    newImports: ImportDeclaration[],
-  ) {
+  replaceImport(oldImports: ImportDeclaration[], newImports: ImportDeclaration[]) {
     newImports.forEach((importDeclaration) => {
       importDeclaration.statement = assembleImport(importDeclaration);
     });
@@ -84,9 +69,9 @@ class TsFile extends File {
             }
           });
         } else {
-          content = content.replace(oldImport.statement, "");
+          content = content.replace(oldImport.statement, '');
           this.imports = this.imports.filter(
-            (_import) => _import.statement !== oldImport.statement,
+            (_import) => _import.statement !== oldImport.statement
           );
         }
       });
@@ -96,9 +81,9 @@ class TsFile extends File {
         const lastImportStatement = newImports[oldImports.length - 1].statement;
         content = content.replace(
           lastImportStatement,
-          `${lastImportStatement}\n${restNewImports.map(
-            (item) => item.statement,
-          )}\n`,
+          `${lastImportStatement}
+${restNewImports.map((item) => item.statement).join('\n')}
+`
         );
       }
       this.imports.push(...restNewImports);
@@ -110,8 +95,8 @@ class TsFile extends File {
 
 export function* traverseRecursiveTsFiles(folder: string): Generator<TsFile> {
   for (const filePath of traverseRecursiveFilePaths(folder)) {
-    if (filePath.endsWith(".ts") || filePath.endsWith(".tsx")) {
-      yield new TsFile(filePath);
+    if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
+      yield new TsFile(filePath, folder);
     }
   }
 }
@@ -120,8 +105,8 @@ export function getIndexTsFile(folder: string): TsFile | undefined {
   // ts or tsx
   const files = fs.readdirSync(folder);
   for (const file of files) {
-    if (file === "index.ts" || file === "index.tsx") {
-      return new TsFile(path.join(folder, file));
+    if (file === 'index.ts' || file === 'index.tsx') {
+      return new TsFile(path.join(folder, file), folder);
     }
   }
   return undefined;

+ 70 - 0
common/config/rush/pnpm-lock.yaml

@@ -29,6 +29,12 @@ importers:
         specifier: 7.4.3
         version: 7.4.3
     devDependencies:
+      '@flowgram.ai/eslint-config':
+        specifier: workspace:*
+        version: link:../../config/eslint-config
+      '@types/download':
+        specifier: 8.0.5
+        version: 8.0.5
       '@types/fs-extra':
         specifier: 11.0.4
         version: 11.0.4
@@ -10214,6 +10220,20 @@ packages:
       '@types/ms': 0.7.34
     dev: false
 
+  /@types/decompress@4.2.7:
+    resolution: {integrity: sha512-9z+8yjKr5Wn73Pt17/ldnmQToaFHZxK0N1GHysuk/JIPT8RIdQeoInM01wWPgypRcvb6VH1drjuFpQ4zmY437g==}
+    dependencies:
+      '@types/node': 18.19.68
+    dev: true
+
+  /@types/download@8.0.5:
+    resolution: {integrity: sha512-Ad68goc/BsL3atP3OP/lWKAKhiC6FduN1mC5yg9lZuGYmUY7vyoWBcXgt8GE9OzVWRq5IBXwm4o/QiE+gipZAg==}
+    dependencies:
+      '@types/decompress': 4.2.7
+      '@types/got': 9.6.12
+      '@types/node': 18.19.68
+    dev: true
+
   /@types/eslint-scope@3.7.7:
     resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
     dependencies:
@@ -10248,6 +10268,14 @@ packages:
       '@types/node': 18.19.68
     dev: true
 
+  /@types/got@9.6.12:
+    resolution: {integrity: sha512-X4pj/HGHbXVLqTpKjA2ahI4rV/nNBc9mGO2I/0CgAra+F2dKgMXnENv2SRpemScBzBAI4vMelIVYViQxlSE6xA==}
+    dependencies:
+      '@types/node': 18.19.68
+      '@types/tough-cookie': 4.0.5
+      form-data: 2.5.5
+    dev: true
+
   /@types/hash-sum@1.0.2:
     resolution: {integrity: sha512-UP28RddqY8xcU0SCEp9YKutQICXpaAq9N8U2klqF5hegGha7KzTOL8EdhIIV3bOSGBzjEpN9bU/d+nNZBdJYVw==}
     dev: false
@@ -10399,6 +10427,10 @@ packages:
       '@types/node': 18.19.68
     dev: true
 
+  /@types/tough-cookie@4.0.5:
+    resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
+    dev: true
+
   /@types/unist@2.0.11:
     resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
     dev: false
@@ -11431,6 +11463,10 @@ packages:
     resolution: {integrity: sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ==}
     dev: false
 
+  /asynckit@0.4.0:
+    resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+    dev: true
+
   /at-least-node@1.0.0:
     resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
     engines: {node: '>= 4.0.0'}
@@ -11860,6 +11896,13 @@ packages:
       color-string: 1.9.1
     optional: true
 
+  /combined-stream@1.0.8:
+    resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      delayed-stream: 1.0.0
+    dev: true
+
   /comlink@4.4.2:
     resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==}
     dev: false
@@ -12190,6 +12233,11 @@ packages:
     resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
     dev: false
 
+  /delayed-stream@1.0.0:
+    resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+    engines: {node: '>=0.4.0'}
+    dev: true
+
   /depd@1.1.2:
     resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
     engines: {node: '>= 0.6'}
@@ -12526,6 +12574,16 @@ packages:
       has-tostringtag: 1.0.2
       hasown: 2.0.2
 
+  /es-set-tostringtag@2.1.0:
+    resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+    engines: {node: '>= 0.4'}
+    dependencies:
+      es-errors: 1.3.0
+      get-intrinsic: 1.2.6
+      has-tostringtag: 1.0.2
+      hasown: 2.0.2
+    dev: true
+
   /es-shim-unscopables@1.0.2:
     resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==}
     dependencies:
@@ -13400,6 +13458,18 @@ packages:
       cross-spawn: 7.0.6
       signal-exit: 4.1.0
 
+  /form-data@2.5.5:
+    resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==}
+    engines: {node: '>= 0.12'}
+    dependencies:
+      asynckit: 0.4.0
+      combined-stream: 1.0.8
+      es-set-tostringtag: 2.1.0
+      hasown: 2.0.2
+      mime-types: 2.1.35
+      safe-buffer: 5.2.1
+    dev: true
+
   /forwarded@0.2.0:
     resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
     engines: {node: '>= 0.6'}