├── .gitattributes ├── src ├── test │ ├── workspaces │ │ ├── suite │ │ │ ├── .gitignore │ │ │ ├── .vscode │ │ │ │ └── settings.json │ │ │ ├── packages │ │ │ │ ├── a │ │ │ │ │ └── pubspec.yaml │ │ │ │ └── b │ │ │ │ │ └── pubspec.yaml │ │ │ ├── pubspec.yaml │ │ │ └── melos.yaml │ │ └── dev │ │ │ ├── .vscode │ │ │ └── settings.json │ │ │ ├── .gitignore │ │ │ ├── packages │ │ │ ├── a │ │ │ │ └── pubspec.yaml │ │ │ ├── c │ │ │ │ └── pubspec.yaml │ │ │ └── b │ │ │ │ └── pubspec.yaml │ │ │ ├── pubspec.yaml │ │ │ └── melos.yaml │ ├── suite │ │ ├── index.ts │ │ ├── package-graph.test.ts │ │ ├── melos-yaml-code-lenses.test.ts │ │ ├── script-task-provder.test.ts │ │ ├── execute.test.ts │ │ ├── melos-commands.test.ts │ │ ├── workspace-config.test.ts │ │ └── melos-yaml-schema.test.ts │ ├── utils │ │ ├── vscode-workspace-utils.ts │ │ ├── fs-utils.ts │ │ ├── misc-utils.ts │ │ └── melos-yaml-utils.ts │ ├── testRunner.ts │ └── runTest.ts ├── utils │ ├── yaml-utils.ts │ ├── fs-utils.ts │ └── vscode-utils.ts ├── env.ts ├── package-filters.ts ├── extension.ts ├── package_graph_view.ts ├── code-lenses.ts ├── script-task-provider.ts ├── melos-workspace.ts ├── logging.ts ├── execute.ts ├── commands.ts └── workspace-config.ts ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── docs └── images │ └── melos-logo.png ├── .vscode ├── extensions.json ├── settings.json ├── tasks.json └── launch.json ├── .vscodeignore ├── tsconfig.json ├── .eslintrc.json ├── README.md ├── LICENSE ├── .github └── workflows │ └── ci.yaml ├── package.json ├── CHANGELOG.md └── melos.yaml.schema.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /src/test/workspaces/suite/.gitignore: -------------------------------------------------------------------------------- 1 | .dart_tool 2 | pubspec.lock -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode-test/ 2 | node_modules 3 | out 4 | *.vsix 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="v" 2 | message="chore(release): %s :tada:" 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode-test/ 2 | .dart_tool/ 3 | out/ 4 | node_modules/ 5 | CHANGELOG.md 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "endOfLine": "lf" 5 | } 6 | -------------------------------------------------------------------------------- /src/test/workspaces/dev/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dart.runPubGetOnPubspecChanges": false 3 | } 4 | -------------------------------------------------------------------------------- /docs/images/melos-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blaugold/melos-code/HEAD/docs/images/melos-logo.png -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as testRunner from '../testRunner' 2 | 3 | module.exports = testRunner 4 | -------------------------------------------------------------------------------- /src/test/workspaces/suite/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dart.runPubGetOnPubspecChanges": false 3 | } 4 | -------------------------------------------------------------------------------- /src/test/workspaces/dev/.gitignore: -------------------------------------------------------------------------------- 1 | .dart_tool 2 | .idea 3 | *.iml 4 | pubspec.lock 5 | .packages 6 | pubspec_overrides.yaml -------------------------------------------------------------------------------- /src/test/workspaces/suite/packages/a/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: a 2 | version: 0.0.0 3 | environment: 4 | sdk: '>=2.15.0 <3.0.0' 5 | -------------------------------------------------------------------------------- /src/test/workspaces/suite/packages/b/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: b 2 | version: 0.0.0 3 | environment: 4 | sdk: '>=2.15.0 <3.0.0' 5 | -------------------------------------------------------------------------------- /src/test/workspaces/dev/packages/a/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: a 2 | version: 0.0.0 3 | 4 | environment: 5 | sdk: '>=2.15.0 <3.0.0' 6 | -------------------------------------------------------------------------------- /src/test/workspaces/dev/packages/c/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: c 2 | version: 0.0.0 3 | 4 | environment: 5 | sdk: '>=2.15.0 <3.0.0' 6 | -------------------------------------------------------------------------------- /src/test/workspaces/dev/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: dev_workspace 2 | environment: 3 | sdk: '>=2.18.0 <3.0.0' 4 | 5 | dev_dependencies: 6 | melos: ^3.0.0 7 | -------------------------------------------------------------------------------- /src/test/workspaces/suite/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: suite_workspace 2 | environment: 3 | sdk: '>=2.18.0 <3.0.0' 4 | 5 | dev_dependencies: 6 | melos: ^3.0.0 7 | -------------------------------------------------------------------------------- /src/test/workspaces/dev/packages/b/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: b 2 | version: 0.0.0 3 | 4 | environment: 5 | sdk: '>=2.15.0 <3.0.0' 6 | 7 | dev_dependencies: 8 | a: 9 | path: ../a 10 | -------------------------------------------------------------------------------- /src/test/utils/vscode-workspace-utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | export function workspaceFolder(): vscode.WorkspaceFolder { 4 | return vscode.workspace.workspaceFolders![0] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint"] 5 | } 6 | -------------------------------------------------------------------------------- /src/test/utils/fs-utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | export async function writeFileAsString(uri: vscode.Uri, content: string) { 4 | await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')) 5 | } 6 | -------------------------------------------------------------------------------- /src/test/workspaces/suite/melos.yaml: -------------------------------------------------------------------------------- 1 | name: melos 2 | packages: 3 | - packages/** 4 | scripts: 5 | a: b 6 | b: melos exec -- echo b 7 | echo: echo Hello world 8 | echo_exec: melos exec -- echo Hello world 9 | ide: 10 | intellij: false 11 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | melos-workspaces/** 4 | out/** 5 | !out/extension.js 6 | **/node_modules/** 7 | src/** 8 | .eslintrc.json 9 | .gitignore 10 | .prettierignore 11 | .prettierrc.json 12 | **/tsconfig.json 13 | **/.eslintrc.json 14 | **/*.map 15 | **/*.ts 16 | *.vsix 17 | -------------------------------------------------------------------------------- /src/utils/yaml-utils.ts: -------------------------------------------------------------------------------- 1 | import { Range, TextDocument } from 'vscode' 2 | import { Node } from 'yaml/types' 3 | 4 | export function vscodeRangeFromNode(doc: TextDocument, node: Node): Range { 5 | return new Range( 6 | doc.positionAt(node.range![0]), 7 | doc.positionAt(node.range![1]) 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/test/workspaces/dev/melos.yaml: -------------------------------------------------------------------------------- 1 | name: test-project 2 | packages: 3 | - packages/** 4 | 5 | scripts: 6 | hello: echo 'Hello World' 7 | hello_exec: melos exec -- echo 'Hello World' 8 | hello_exec_filter: 9 | run: melos exec -- echo 'Hello World' 10 | packageFilters: 11 | scope: 12 | - a 13 | - b 14 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Environment variable to configure the log level of the console logger. 3 | * 4 | * If this variable is unset the console logger is disable. 5 | */ 6 | export const consoleLogLevel = process.env.MELOS_CODE_CONSOLE_LOG_LEVEL 7 | 8 | export const melosExecutableName = 9 | process.platform === 'win32' ? 'melos.bat' : 'melos' 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "out", 6 | "lib": ["ES2020"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "strict": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noUnusedParameters": true 13 | }, 14 | "exclude": ["node_modules", ".vscode-test"] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["prettier"], 3 | "root": true, 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 6, 7 | "sourceType": "module" 8 | }, 9 | "plugins": ["@typescript-eslint"], 10 | "rules": { 11 | "@typescript-eslint/naming-convention": "warn", 12 | "@typescript-eslint/semi": ["warn", "never"], 13 | "curly": "warn", 14 | "eqeqeq": "warn", 15 | "no-throw-literal": "warn", 16 | "semi": "off" 17 | }, 18 | "ignorePatterns": ["out", "dist", "**/*.d.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /src/package-filters.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Package filters for filtering the packages of a workspace. 3 | */ 4 | export interface MelosPackageFilters { 5 | readonly scope?: readonly string[] 6 | readonly ignore?: readonly string[] 7 | readonly dirExists?: readonly string[] 8 | readonly fileExists?: readonly string[] 9 | readonly dependsOn?: readonly string[] 10 | readonly noDependsOn?: readonly string[] 11 | readonly diff?: string 12 | readonly private?: boolean 13 | readonly published?: boolean 14 | readonly nullSafety?: boolean 15 | readonly flutter?: boolean 16 | } 17 | -------------------------------------------------------------------------------- /src/test/suite/package-graph.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import * as vscode from 'vscode' 3 | import { workspaceFolder } from '../utils/vscode-workspace-utils' 4 | 5 | suite('Melos package graph', () => { 6 | test('melos.showPackageGraph command shows package graph in webview', async () => { 7 | const panel = (await vscode.commands.executeCommand( 8 | 'melos.showPackageGraph' 9 | )) as vscode.WebviewPanel 10 | 11 | assert.strictEqual(panel.title, `Package graph - ${workspaceFolder().name}`) 12 | assert.notStrictEqual(panel.webview.html, '') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/test/utils/misc-utils.ts: -------------------------------------------------------------------------------- 1 | export async function retryUntilResult( 2 | condition: () => Promise | T | undefined, 3 | option: { maxWaitTime?: number; waitTime?: number } = {} 4 | ): Promise { 5 | const maxWaitTime = option.maxWaitTime ?? 5000 6 | const waitTime = option.waitTime ?? 100 7 | let elapsed = 0 8 | while (elapsed < maxWaitTime) { 9 | const result = await condition() 10 | if (result) { 11 | return result 12 | } 13 | await new Promise((resolve) => setTimeout(resolve, waitTime)) 14 | elapsed += waitTime 15 | } 16 | throw new Error(`No result after maxWaitTime: ${maxWaitTime}`) 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | }, 19 | { 20 | "type": "npm", 21 | "script": "lint", 22 | "problemMatcher": ["$eslint-stylish"], 23 | "label": "npm: lint" 24 | }, 25 | { 26 | "type": "npm", 27 | "script": "pretest", 28 | "label": "npm: pretest" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { registerMelosYamlCodeLenseProvider } from './code-lenses' 3 | import { registerMelosCommands } from './commands' 4 | import { info, initLogging } from './logging' 5 | import { initMelosWorkspaces } from './melos-workspace' 6 | import { registerMelosScriptTaskProvider } from './script-task-provider' 7 | 8 | export async function activate(context: vscode.ExtensionContext) { 9 | initLogging(context) 10 | 11 | info('Activating...') 12 | 13 | // Must be initialized before the rest of the extension, since it is used by 14 | // other components. 15 | await initMelosWorkspaces(context) 16 | 17 | registerMelosScriptTaskProvider(context) 18 | registerMelosCommands(context) 19 | registerMelosYamlCodeLenseProvider(context) 20 | 21 | info('Activated') 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This extension adds support for using [Melos] with Visual Studio Code. 2 | 3 | ## Installation 4 | 5 | Melos Code can be [installed from the Visual Studio Code Marketplace](https://marketplace.visualstudio.com/items?itemName=blaugold.melos-code) or by [searching within VS Code](https://code.visualstudio.com/docs/editor/extension-gallery#_search-for-an-extension). 6 | 7 | ## Features 8 | 9 | - Validation and autocompletion of `melos.yaml` 10 | - Run scripts in `melos.yaml` through CodeLenses 11 | - Provide scripts in `melos.yaml` as tasks 12 | - Apply defaults for Dart VS Code extension settings 13 | - Commands: 14 | - `Melos: Bootstrap`: Run `melos bootstrap` 15 | - `Melos: Clean`: Run `melos clean` 16 | - `Melos: Run script`: Select and run a script from `melos.yaml` 17 | - `Melos: Show package graph`: Show a diagram of the package graph 18 | 19 | [melos]: https://pub.dev/packages/melos 20 | -------------------------------------------------------------------------------- /src/test/testRunner.ts: -------------------------------------------------------------------------------- 1 | import * as glob from 'glob' 2 | import * as Mocha from 'mocha' 3 | import * as path from 'path' 4 | 5 | export function run(testsRoot: string): Promise { 6 | require('source-map-support').install() 7 | 8 | // Create the mocha test 9 | const mocha = new Mocha({ 10 | ui: 'tdd', 11 | color: true, 12 | timeout: 30000, 13 | }) 14 | 15 | return new Promise((c, e) => { 16 | glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { 17 | if (err) { 18 | return e(err) 19 | } 20 | 21 | // Add files to the test suite 22 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))) 23 | 24 | try { 25 | // Run the mocha test 26 | mocha.run((failures) => { 27 | if (failures > 0) { 28 | e(new Error(`${failures} tests failed.`)) 29 | } else { 30 | c() 31 | } 32 | }) 33 | } catch (err) { 34 | console.error(err) 35 | e(err) 36 | } 37 | }) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/fs-utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | /** 4 | * Returns whether the file with the given {@link uri} exists. 5 | * 6 | * The file must be a file or a symbolic link to a file and not a directory. 7 | */ 8 | export async function fileExists(uri: vscode.Uri) { 9 | try { 10 | const stat = await vscode.workspace.fs.stat(uri) 11 | return stat.type === vscode.FileType.File 12 | } catch (e) { 13 | if (e instanceof vscode.FileSystemError && e.code === 'FileNotFound') { 14 | return false 15 | } 16 | throw e 17 | } 18 | } 19 | 20 | /** 21 | * Reads a file through the VSCode API and returns its contents, or `null` if 22 | * it does not exist. 23 | */ 24 | export async function readOptionalFile( 25 | uri: vscode.Uri 26 | ): Promise { 27 | try { 28 | return await vscode.workspace.fs.readFile(uri) 29 | } catch (e) { 30 | if (e instanceof vscode.FileSystemError && e.code === 'FileNotFound') { 31 | return null 32 | } 33 | 34 | throw e 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Gabriel Terwesten 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /src/test/utils/melos-yaml-utils.ts: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash' 2 | import * as vscode from 'vscode' 3 | import * as YAML from 'yaml' 4 | import { writeFileAsString } from './fs-utils' 5 | import { workspaceFolder } from './vscode-workspace-utils' 6 | 7 | const melosYamlPath = 'melos.yaml' 8 | 9 | function melosYamlUri() { 10 | return vscode.Uri.joinPath(workspaceFolder().uri, melosYamlPath) 11 | } 12 | 13 | function melosYamlDefault() { 14 | return { 15 | name: 'melos', 16 | packages: ['packages/**'], 17 | } 18 | } 19 | 20 | export async function createMelosYaml(content: any = {}) { 21 | await writeFileAsString( 22 | melosYamlUri(), 23 | YAML.stringify(merge(melosYamlDefault(), content)) 24 | ) 25 | } 26 | 27 | export async function openMelosYamlInEditor(): Promise { 28 | const doc = await vscode.workspace.openTextDocument(melosYamlUri()) 29 | return await vscode.window.showTextDocument(doc) 30 | } 31 | 32 | export async function resolveMelosYamlCodeLenses(): Promise< 33 | vscode.CodeLens[] | undefined 34 | > { 35 | return await vscode.commands.executeCommand( 36 | 'vscode.executeCodeLensProvider', 37 | melosYamlUri() 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}", 14 | "${workspaceFolder}/src/test/workspaces/dev" 15 | ], 16 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 17 | "preLaunchTask": "${defaultBuildTask}", 18 | "env": { 19 | "MELOS_CODE_CONSOLE_LOG_LEVEL": "debug" 20 | } 21 | }, 22 | { 23 | "name": "Extension Tests (suite)", 24 | "type": "extensionHost", 25 | "request": "launch", 26 | "args": [ 27 | "--extensionDevelopmentPath=${workspaceFolder}", 28 | "--extensionTestsPath=${workspaceFolder}/out/test/suite", 29 | "${workspaceFolder}/src/test/workspaces/suite" 30 | ], 31 | "env": { 32 | "MELOS_CODE_CONSOLE_LOG_LEVEL": "debug" 33 | }, 34 | "outFiles": ["${workspaceFolder}/out/test/**/*.js"], 35 | "preLaunchTask": "npm: pretest" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | runs-on: [ubuntu-latest, macos-latest, windows-latest] 17 | runs-on: ${{ matrix.runs-on }} 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup dart 23 | uses: dart-lang/setup-dart@v1 24 | 25 | - name: Install Melos 26 | run: dart pub global activate melos 27 | 28 | - name: Setup NodeJS 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: '16' 32 | 33 | - name: Install npm dependencies 34 | run: npm ci 35 | 36 | - name: Check formatting 37 | run: npm run prettier:check 38 | 39 | - name: Check lint rules 40 | run: npm run lint 41 | 42 | - name: Start Xvfb 43 | if: runner.os == 'Linux' 44 | run: | 45 | Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 46 | echo "DISPLAY=:99.0" >>$GITHUB_ENV 47 | 48 | # This ensures that the local installation of Melos has been resolved before 49 | # running the tests. 50 | - name: Prepare test workspace 51 | working-directory: src/test/workspaces/suite 52 | run: melos 53 | 54 | - name: Run tests 55 | run: npm test 56 | -------------------------------------------------------------------------------- /src/package_graph_view.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | 3 | export function showPackageGraphView(options: { 4 | dotGraph: string 5 | folder: vscode.WorkspaceFolder 6 | }) { 7 | const panel = vscode.window.createWebviewPanel( 8 | 'melos.packageGraph', 9 | `Package graph - ${options.folder.name}`, 10 | vscode.ViewColumn.Beside, 11 | { 12 | enableScripts: true, 13 | } 14 | ) 15 | 16 | panel.webview.html = packageGraphWebviewContent(options.dotGraph) 17 | 18 | return panel 19 | } 20 | 21 | function packageGraphWebviewContent(graph: string) { 22 | return ` 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 37 | 38 | 39 |
40 | 41 | 55 | 56 | ` 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/vscode-utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { debug } from '../logging' 3 | 4 | /** 5 | * Type guard to check whether the given value is a {@link vscode.WorkspaceFolder}. 6 | */ 7 | export function isWorkspaceFolder(value: any): value is vscode.WorkspaceFolder { 8 | return ( 9 | typeof value === 'object' && 10 | 'uri' in value && 11 | 'name' in value && 12 | 'index' in value 13 | ) 14 | } 15 | 16 | /** 17 | * Returns whether the given {@link folder} is currently an open workspace folder. 18 | */ 19 | export function isOpenWorkspaceFolder(folder: vscode.WorkspaceFolder): boolean { 20 | const workspaceFolders = vscode.workspace.workspaceFolders 21 | if (!workspaceFolders) { 22 | return false 23 | } 24 | return workspaceFolders.includes(folder) 25 | } 26 | 27 | /** 28 | * Resolves a single {@link vscode.WorkspaceFolder}. 29 | * 30 | * If there are no open workspace folders, `undefined` is returned. 31 | * 32 | * If there is one open workspace folder, it is returned. 33 | * 34 | * If there are multiple open workspace folders, the users is asked to select one. 35 | */ 36 | export async function resolveWorkspaceFolder() { 37 | const workspaceFolders = vscode.workspace.workspaceFolders 38 | if (!workspaceFolders) { 39 | debug(`Could not resolve workspace folder: no workspace folders are open.`) 40 | return 41 | } 42 | 43 | if (workspaceFolders.length === 1) { 44 | return workspaceFolders[0] 45 | } 46 | 47 | const selectedWorkspace = await vscode.window.showWorkspaceFolderPick() 48 | if (!selectedWorkspace) { 49 | debug(`Could not resolve workspace folder: user did not select one.`) 50 | } 51 | 52 | return selectedWorkspace 53 | } 54 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from '@vscode/test-electron' 2 | import * as cp from 'child_process' 3 | import * as path from 'path' 4 | 5 | let exitCode = 0 6 | 7 | const vscodeVersion = process.env.VSCODE_VERSION ?? 'stable' 8 | const extensionDevelopmentPath = path.resolve(__dirname, '../../') 9 | const extensionDependencies: string[] = 10 | require('../../package.json').extensionDependencies 11 | 12 | async function runTests(suiteName: string) { 13 | try { 14 | const extensionTestsPath = path.resolve(__dirname, suiteName) 15 | const workspaceDir = path.resolve( 16 | __dirname, 17 | '../../src/test/workspaces', 18 | suiteName 19 | ) 20 | 21 | const res = await vscode.runTests({ 22 | version: vscodeVersion, 23 | extensionDevelopmentPath, 24 | extensionTestsPath, 25 | launchArgs: [workspaceDir], 26 | extensionTestsEnv: { 27 | // eslint-disable-next-line @typescript-eslint/naming-convention 28 | MELOS_CODE_CONSOLE_LOG_LEVEL: 'debug', 29 | ...process.env, 30 | }, 31 | }) 32 | exitCode = exitCode || res 33 | } catch (err) { 34 | console.error(err) 35 | exitCode = exitCode || 999 36 | } 37 | } 38 | 39 | async function main() { 40 | const vscodeExecutablePath = await vscode.downloadAndUnzipVSCode( 41 | vscodeVersion 42 | ) 43 | 44 | const [cli, ...args] = 45 | vscode.resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath) 46 | 47 | // Ensure that the redhat.vscode-yaml extension is installed. 48 | // This is a dependency of this extension and provides the language 49 | // definition for yaml, which is needed to activate this extension. 50 | for (const extension of extensionDependencies) { 51 | cp.spawnSync(cli, [...args, '--install-extension', extension], { 52 | encoding: 'utf-8', 53 | stdio: 'inherit', 54 | }) 55 | } 56 | 57 | try { 58 | await runTests('suite') 59 | } catch (err) { 60 | console.error(err) 61 | exitCode = 1 62 | } 63 | } 64 | 65 | main().then(() => process.exit(exitCode)) 66 | -------------------------------------------------------------------------------- /src/test/suite/melos-yaml-code-lenses.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import * as vscode from 'vscode' 3 | import { 4 | openMelosYamlInEditor, 5 | resolveMelosYamlCodeLenses, 6 | } from '../utils/melos-yaml-utils' 7 | import { retryUntilResult } from '../utils/misc-utils' 8 | import { workspaceFolder } from '../utils/vscode-workspace-utils' 9 | 10 | suite('melos.yaml CodeLenses', () => { 11 | test('should provide lenses to run scripts', async () => { 12 | await openMelosYamlInEditor() 13 | 14 | const codeLenses = await retryUntilResult(() => 15 | resolveMelosYamlCodeLenses().then((lenses) => { 16 | if (!lenses) { 17 | return 18 | } 19 | const runScriptCodeLenses = lenses?.filter( 20 | (codeLense) => codeLense.command?.command === 'melos.runScript' 21 | ) 22 | if (runScriptCodeLenses.length === 0) { 23 | return 24 | } 25 | return runScriptCodeLenses 26 | }) 27 | ) 28 | 29 | let codeLense = codeLenses[0] 30 | assert.deepStrictEqual(codeLense.range, new vscode.Range(4, 2, 4, 3)) 31 | assert.strictEqual(codeLense.command?.title, 'Run script') 32 | assert.strictEqual(codeLense.command?.command, 'melos.runScript') 33 | assert.deepStrictEqual(codeLense.command?.arguments, [ 34 | { 35 | workspaceFolder: workspaceFolder(), 36 | script: 'a', 37 | }, 38 | ]) 39 | 40 | codeLense = codeLenses[1] 41 | assert.deepStrictEqual(codeLense.range, new vscode.Range(5, 2, 5, 3)) 42 | assert.strictEqual(codeLense.command?.title, 'Run script') 43 | assert.strictEqual(codeLense.command?.command, 'melos.runScript') 44 | assert.deepStrictEqual(codeLense.command?.arguments, [ 45 | { 46 | workspaceFolder: workspaceFolder(), 47 | script: 'b', 48 | }, 49 | ]) 50 | 51 | codeLense = codeLenses[2] 52 | assert.deepStrictEqual(codeLense.range, new vscode.Range(5, 2, 5, 3)) 53 | assert.strictEqual(codeLense.command?.title, 'Run script in all packages') 54 | assert.strictEqual(codeLense.command?.command, 'melos.runScript') 55 | assert.deepStrictEqual(codeLense.command?.arguments, [ 56 | { 57 | workspaceFolder: workspaceFolder(), 58 | script: 'b', 59 | runInAllPackages: true, 60 | }, 61 | ]) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/test/suite/script-task-provder.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import * as vscode from 'vscode' 3 | import { melosExecutableName } from '../../env' 4 | import { retryUntilResult } from '../utils/misc-utils' 5 | import { workspaceFolder } from '../utils/vscode-workspace-utils' 6 | 7 | suite('Melos script task provider', () => { 8 | test('should provide tasks for scripts in melos.yaml', async () => { 9 | const tasks = await retryUntilResult(async () => { 10 | const tasks = await vscode.tasks.fetchTasks({ type: 'melos' }) 11 | return tasks.length === 0 ? undefined : tasks 12 | }) 13 | 14 | assert.strictEqual(tasks.length, 4) 15 | 16 | let task = tasks[0] 17 | assert.strictEqual(task.name, 'a') 18 | assert.strictEqual(task.source, 'melos') 19 | assert.strictEqual(task.detail, 'b') 20 | assert.deepStrictEqual(task.definition, { type: 'melos', script: 'a' }) 21 | assert.strictEqual(task.scope, workspaceFolder()) 22 | assert.strictEqual( 23 | (task.execution as vscode.ShellExecution).commandLine, 24 | `${melosExecutableName} run --no-select a` 25 | ) 26 | 27 | task = tasks[1] 28 | assert.strictEqual(task.name, 'b') 29 | assert.strictEqual(task.source, 'melos') 30 | assert.strictEqual(task.detail, 'melos exec -- echo b') 31 | assert.deepStrictEqual(task.definition, { type: 'melos', script: 'b' }) 32 | assert.strictEqual(task.scope, workspaceFolder()) 33 | assert.strictEqual( 34 | (task.execution as vscode.ShellExecution).commandLine, 35 | `${melosExecutableName} run --no-select b` 36 | ) 37 | 38 | task = tasks[2] 39 | assert.strictEqual(task.name, 'echo') 40 | assert.strictEqual(task.source, 'melos') 41 | assert.strictEqual(task.detail, 'echo Hello world') 42 | assert.deepStrictEqual(task.definition, { type: 'melos', script: 'echo' }) 43 | assert.strictEqual(task.scope, workspaceFolder()) 44 | assert.strictEqual( 45 | (task.execution as vscode.ShellExecution).commandLine, 46 | `${melosExecutableName} run --no-select echo` 47 | ) 48 | 49 | task = tasks[3] 50 | assert.strictEqual(task.name, 'echo_exec') 51 | assert.strictEqual(task.source, 'melos') 52 | assert.strictEqual(task.detail, 'melos exec -- echo Hello world') 53 | assert.deepStrictEqual(task.definition, { 54 | type: 'melos', 55 | script: 'echo_exec', 56 | }) 57 | assert.strictEqual(task.scope, workspaceFolder()) 58 | assert.strictEqual( 59 | (task.execution as vscode.ShellExecution).commandLine, 60 | `${melosExecutableName} run --no-select echo_exec` 61 | ) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/code-lenses.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { MelosRunScriptCommandArgs } from './commands' 3 | import { debug } from './logging' 4 | import { vscodeRangeFromNode } from './utils/yaml-utils' 5 | import { 6 | MelosWorkspaceConfig, 7 | parseMelosWorkspaceConfig, 8 | } from './workspace-config' 9 | 10 | export function registerMelosYamlCodeLenseProvider( 11 | context: vscode.ExtensionContext 12 | ) { 13 | context.subscriptions.push( 14 | vscode.languages.registerCodeLensProvider( 15 | { language: 'yaml', pattern: '**/melos.yaml' }, 16 | new MelosYamlCodeLenseProvider() 17 | ) 18 | ) 19 | } 20 | 21 | class MelosYamlCodeLenseProvider implements vscode.CodeLensProvider { 22 | async provideCodeLenses(document: vscode.TextDocument) { 23 | const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri) 24 | const melosConfig = parseMelosWorkspaceConfig(document.getText()) 25 | 26 | return [ 27 | ...this.buildRunScriptCodeLenses(melosConfig, workspaceFolder, document), 28 | ] 29 | } 30 | 31 | private buildRunScriptCodeLenses( 32 | melosConfig: MelosWorkspaceConfig, 33 | workspaceFolder: vscode.WorkspaceFolder | undefined, 34 | document: vscode.TextDocument 35 | ) { 36 | if (!workspaceFolder) { 37 | // We need a workspace folder to run scripts. 38 | return [] 39 | } 40 | 41 | debug( 42 | `Providing 'Run script' CodeLenses in '${workspaceFolder.name}' folder`, 43 | melosConfig.scripts.map((script) => script.name.value) 44 | ) 45 | 46 | const codeLenses: vscode.CodeLens[] = [] 47 | 48 | for (const script of melosConfig.scripts) { 49 | const name = script.name 50 | 51 | const runScriptCommandArgs: MelosRunScriptCommandArgs = { 52 | workspaceFolder, 53 | script: name.value, 54 | } 55 | 56 | codeLenses.push( 57 | new vscode.CodeLens(vscodeRangeFromNode(document, name.yamlNode), { 58 | title: `Run script`, 59 | command: 'melos.runScript', 60 | arguments: [runScriptCommandArgs], 61 | }) 62 | ) 63 | 64 | if (script.run?.melosExec) { 65 | const runInAllPackagesCommandArgs: MelosRunScriptCommandArgs = { 66 | ...runScriptCommandArgs, 67 | runInAllPackages: true, 68 | } 69 | 70 | codeLenses.push( 71 | new vscode.CodeLens(vscodeRangeFromNode(document, name.yamlNode), { 72 | title: `Run script in all packages`, 73 | command: 'melos.runScript', 74 | arguments: [runInAllPackagesCommandArgs], 75 | }) 76 | ) 77 | } 78 | } 79 | 80 | return codeLenses 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/test/suite/execute.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import * as vscode from 'vscode' 3 | import { 4 | buildPackageFilterOption, 5 | melosList, 6 | MelosListFormat, 7 | MelosPackageType, 8 | } from '../../execute' 9 | import { workspaceFolder } from '../utils/vscode-workspace-utils' 10 | 11 | suite('execute', () => { 12 | suite('melosList', () => { 13 | test('JSON result type', async () => { 14 | const result = await melosList({ 15 | format: MelosListFormat.json, 16 | folder: workspaceFolder(), 17 | }) 18 | 19 | assert.deepStrictEqual(result, [ 20 | { 21 | location: vscode.Uri.joinPath(workspaceFolder().uri, 'packages/a') 22 | .fsPath, 23 | name: 'a', 24 | private: false, 25 | type: MelosPackageType.dartPackage, 26 | version: '0.0.0', 27 | }, 28 | { 29 | location: vscode.Uri.joinPath(workspaceFolder().uri, 'packages/b') 30 | .fsPath, 31 | name: 'b', 32 | private: false, 33 | type: MelosPackageType.dartPackage, 34 | version: '0.0.0', 35 | }, 36 | ]) 37 | }) 38 | 39 | test('Graphviz result type', async () => { 40 | const result = await melosList({ 41 | format: MelosListFormat.gviz, 42 | folder: workspaceFolder(), 43 | }) 44 | 45 | assert.strictEqual( 46 | result, 47 | `digraph packages { 48 | size="10"; ratio=fill; 49 | a [shape="box"; color="#ff5307"]; 50 | b [shape="box"; color="#e03cc2"]; 51 | subgraph "cluster packages" { 52 | label="packages"; 53 | color="#6b4949"; 54 | a; 55 | b; 56 | } 57 | } 58 | ` 59 | ) 60 | }) 61 | }) 62 | 63 | test('buildPackageFilterOption', () => { 64 | assert.deepStrictEqual( 65 | buildPackageFilterOption({ 66 | scope: ['a'], 67 | ignore: ['b'], 68 | dependsOn: ['c'], 69 | noDependsOn: ['d'], 70 | fileExists: ['e'], 71 | dirExists: ['f'], 72 | diff: 'g', 73 | private: true, 74 | published: true, 75 | nullSafety: true, 76 | flutter: true, 77 | }), 78 | [ 79 | '--scope', 80 | 'a', 81 | '--ignore', 82 | 'b', 83 | '--depends-on', 84 | 'c', 85 | '--no-depends-on', 86 | 'd', 87 | '--file-exists', 88 | 'e', 89 | '--dir-exists', 90 | 'f', 91 | '--diff', 92 | 'g', 93 | '--private', 94 | '--published', 95 | '--null-safety', 96 | '--flutter', 97 | ] 98 | ) 99 | 100 | assert.deepStrictEqual( 101 | buildPackageFilterOption({ 102 | private: false, 103 | published: false, 104 | nullSafety: false, 105 | flutter: false, 106 | }), 107 | ['--no-private', '--no-published', '--no-null-safety', '--no-flutter'] 108 | ) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /src/test/suite/melos-commands.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import * as vscode from 'vscode' 3 | import { MelosRunScriptCommandArgs } from '../../commands' 4 | import { melosExecutableName } from '../../env' 5 | import { workspaceFolder } from '../utils/vscode-workspace-utils' 6 | 7 | suite('Melos commands as VS Code commands', () => { 8 | commandTest('bootstrap') 9 | commandTest('clean') 10 | 11 | suite('runScript with args', () => { 12 | test('simple script', async () => { 13 | const taskExecution = 14 | await vscode.commands.executeCommand( 15 | 'melos.runScript', 16 | { 17 | workspaceFolder: workspaceFolder(), 18 | script: 'echo', 19 | } as MelosRunScriptCommandArgs 20 | ) 21 | 22 | const task = taskExecution?.task 23 | assert.strictEqual(task?.definition.type, 'melos') 24 | assert.strictEqual(task?.name, 'echo') 25 | assert.strictEqual(task?.source, 'melos') 26 | assert.strictEqual(task.scope, workspaceFolder()) 27 | assert.strictEqual( 28 | (task.execution as vscode.ShellExecution).commandLine, 29 | `${melosExecutableName} run --no-select echo` 30 | ) 31 | }) 32 | 33 | test('run exec script in all packages', async () => { 34 | const taskExecution = 35 | await vscode.commands.executeCommand( 36 | 'melos.runScript', 37 | { 38 | workspaceFolder: workspaceFolder(), 39 | script: 'echo_exec', 40 | runInAllPackages: true, 41 | } as MelosRunScriptCommandArgs 42 | ) 43 | 44 | const task = taskExecution?.task 45 | assert.strictEqual(task?.definition.type, 'melos') 46 | assert.strictEqual(task?.name, 'echo_exec') 47 | assert.strictEqual(task?.source, 'melos') 48 | assert.strictEqual(task.scope, workspaceFolder()) 49 | assert.strictEqual( 50 | (task.execution as vscode.ShellExecution).commandLine, 51 | `${melosExecutableName} run --no-select echo_exec` 52 | ) 53 | }) 54 | }) 55 | }) 56 | 57 | async function commandTest(name: string) { 58 | test(`execute ${name}`, async () => { 59 | const didStartTask = new Promise((resolve) => { 60 | const disposable = vscode.tasks.onDidStartTask((event) => { 61 | const task = event.execution.task 62 | assert.strictEqual(task.definition.type, 'melos') 63 | assert.strictEqual(task.name, name) 64 | assert.strictEqual(task.source, 'melos') 65 | assert.strictEqual(task.scope, workspaceFolder()) 66 | assert.strictEqual( 67 | (task.execution as vscode.ShellExecution).commandLine, 68 | `${melosExecutableName} ${name}` 69 | ) 70 | 71 | disposable.dispose() 72 | resolve() 73 | }) 74 | }) 75 | 76 | const exitCode = await vscode.commands.executeCommand( 77 | `melos.${name}` 78 | ) 79 | assert.strictEqual(exitCode, 0) 80 | 81 | return didStartTask 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /src/script-task-provider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { melosExecutableName } from './env' 3 | import { info } from './logging' 4 | import { isWorkspaceFolder } from './utils/vscode-utils' 5 | import { loadMelosWorkspaceConfig } from './workspace-config' 6 | 7 | export function registerMelosScriptTaskProvider( 8 | context: vscode.ExtensionContext 9 | ) { 10 | context.subscriptions.push( 11 | vscode.tasks.registerTaskProvider( 12 | 'melos', 13 | new MelosScriptTaskProvider(context) 14 | ) 15 | ) 16 | } 17 | 18 | /** 19 | * A Melos script task definition. 20 | */ 21 | export interface MelosScriptTaskDefinition extends vscode.TaskDefinition { 22 | type: 'melos' 23 | 24 | /** 25 | * The name of the script to run as defined in melos.yaml. 26 | */ 27 | script: string 28 | } 29 | 30 | /** 31 | * A Melos script task definition for a script that uses `melos exec`. 32 | */ 33 | export interface MelosExecScriptTaskDefinition 34 | extends MelosScriptTaskDefinition { 35 | /** 36 | * The options to pass to `melos exec`. 37 | */ 38 | execOptions?: string[] 39 | /** 40 | * The command to execute through `melos exec`. 41 | */ 42 | command: string 43 | } 44 | 45 | function isMelosScriptTaskDefinition( 46 | definition: vscode.TaskDefinition 47 | ): definition is MelosScriptTaskDefinition { 48 | return definition.type === 'melos' && typeof definition.script === 'string' 49 | } 50 | 51 | class MelosScriptTaskProvider implements vscode.TaskProvider { 52 | constructor(private context: vscode.ExtensionContext) {} 53 | 54 | public async provideTasks(): Promise { 55 | const workspaceFolders = vscode.workspace.workspaceFolders 56 | if (!workspaceFolders) { 57 | return [] 58 | } 59 | 60 | return ( 61 | await Promise.all( 62 | workspaceFolders.map((folder) => this.loadWorkspaceFolderTasks(folder)) 63 | ) 64 | ).flatMap((tasks) => tasks) 65 | } 66 | 67 | private async loadWorkspaceFolderTasks( 68 | folder: vscode.WorkspaceFolder 69 | ): Promise { 70 | const melosConfig = await loadMelosWorkspaceConfig(this.context, folder) 71 | 72 | if (!melosConfig || !melosConfig.scripts) { 73 | return [] 74 | } 75 | 76 | info( 77 | `Loaded scripts for tasks in '${folder.name}' folder`, 78 | melosConfig.scripts.map((script) => script.name.value) 79 | ) 80 | 81 | return melosConfig.scripts.map((script) => { 82 | const task = buildMelosScriptTask( 83 | { 84 | type: 'melos', 85 | script: script.name.value, 86 | }, 87 | folder 88 | ) 89 | 90 | task.detail = script.run?.value 91 | 92 | return task 93 | }) 94 | } 95 | 96 | public resolveTask(_task: vscode.Task): vscode.Task | undefined { 97 | const definition = _task.definition 98 | if (!isMelosScriptTaskDefinition(definition)) { 99 | return undefined 100 | } 101 | 102 | const scope = _task.scope 103 | if (!isWorkspaceFolder(scope)) { 104 | // Only WorkspaceFolder scope is supported since Melos scripts are 105 | // defined in a melos.yaml in some workspace folder. 106 | return undefined 107 | } 108 | 109 | return buildMelosScriptTask(definition, scope) 110 | } 111 | } 112 | 113 | export function buildMelosScriptTask( 114 | definition: MelosScriptTaskDefinition, 115 | workspaceFolder: vscode.WorkspaceFolder 116 | ): vscode.Task { 117 | return new vscode.Task( 118 | definition, 119 | workspaceFolder, 120 | definition.script, 121 | definition.type, 122 | new vscode.ShellExecution( 123 | `${melosExecutableName} run --no-select ${definition.script}` 124 | ) 125 | ) 126 | } 127 | 128 | export function buildMelosExecScriptTask( 129 | definition: MelosExecScriptTaskDefinition, 130 | workspaceFolder: vscode.WorkspaceFolder 131 | ) { 132 | const commandLine = [ 133 | melosExecutableName, 134 | 'exec', 135 | ...(definition.execOptions ?? []), 136 | '--', 137 | definition.command, 138 | ] 139 | 140 | return new vscode.Task( 141 | definition, 142 | workspaceFolder, 143 | definition.script, 144 | definition.type, 145 | new vscode.ShellExecution(commandLine.join(' ')) 146 | ) 147 | } 148 | -------------------------------------------------------------------------------- /src/melos-workspace.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { debug, info } from './logging' 3 | import { fileExists } from './utils/fs-utils' 4 | import { isOpenWorkspaceFolder } from './utils/vscode-utils' 5 | import { melosYamlFile } from './workspace-config' 6 | 7 | /** 8 | * API for the currently opened Melos workspaces. 9 | */ 10 | export interface MelosWorkspaces { 11 | /** 12 | * List of workspace folders that are open that are Melos workspaces. 13 | */ 14 | get workspaceFolders(): vscode.WorkspaceFolder[] 15 | 16 | /** 17 | * An event that is emitted when a workspace folder is added or removed that 18 | * is a Melos workspace. 19 | */ 20 | onDidChangeWorkspaceFolders: vscode.Event 21 | } 22 | 23 | export const melosWorkspaces: MelosWorkspaces = { 24 | get workspaceFolders() { 25 | return workspaceFolders 26 | }, 27 | 28 | get onDidChangeWorkspaceFolders() { 29 | return onDidChangeWorkspaceFoldersEmitter.event 30 | }, 31 | } 32 | 33 | export async function initMelosWorkspaces(context: vscode.ExtensionContext) { 34 | workspaceFolders.push(...(await getMelosWorkspaceFolders())) 35 | 36 | context.subscriptions.push(onDidChangeWorkspaceFoldersEmitter) 37 | 38 | // Watch the workspace folder for changes. 39 | context.subscriptions.push( 40 | vscode.workspace.onDidChangeWorkspaceFolders(async (event) => { 41 | for (const folder of event.added) { 42 | if (await isMelosWorkspace(folder)) { 43 | if (isOpenWorkspaceFolder(folder)) { 44 | addMelosWorkspace(folder) 45 | } 46 | } 47 | } 48 | 49 | for (const folder of event.removed) { 50 | removeMelosWorkspace(folder) 51 | } 52 | }) 53 | ) 54 | 55 | // Watch melos.yml files for changes. 56 | const melosYamlWatcher = vscode.workspace.createFileSystemWatcher( 57 | `**/${melosYamlFile}`, 58 | false, 59 | true, 60 | false 61 | ) 62 | context.subscriptions.push(melosYamlWatcher) 63 | 64 | context.subscriptions.push( 65 | melosYamlWatcher.onDidCreate(async (uri) => { 66 | debug(`onDidCreate: ${uri}`) 67 | 68 | const folder = vscode.workspace.getWorkspaceFolder(uri) 69 | if (folder) { 70 | // Only add workspace folder if melos.yml is at the root of it. 71 | const melosYamlAtRoot = vscode.Uri.joinPath(folder.uri, melosYamlFile) 72 | if (uri.toString() === melosYamlAtRoot.toString()) { 73 | addMelosWorkspace(folder) 74 | } 75 | } 76 | }) 77 | ) 78 | 79 | context.subscriptions.push( 80 | melosYamlWatcher.onDidDelete(async (uri) => { 81 | debug(`onDidDelete: ${uri}`) 82 | 83 | const folder = vscode.workspace.getWorkspaceFolder(uri) 84 | if (folder) { 85 | removeMelosWorkspace(folder) 86 | } 87 | }) 88 | ) 89 | } 90 | 91 | const workspaceFolders: vscode.WorkspaceFolder[] = [] 92 | 93 | const onDidChangeWorkspaceFoldersEmitter = 94 | new vscode.EventEmitter() 95 | 96 | function addMelosWorkspace(folder: vscode.WorkspaceFolder) { 97 | if (workspaceFolders.includes(folder)) { 98 | return 99 | } 100 | 101 | workspaceFolders.push(folder) 102 | info(`Added Melos workspace: ${folder.name}`) 103 | 104 | onDidChangeWorkspaceFoldersEmitter.fire({ 105 | added: [folder], 106 | removed: [], 107 | }) 108 | } 109 | 110 | function removeMelosWorkspace(folder: vscode.WorkspaceFolder) { 111 | const index = workspaceFolders.indexOf(folder) 112 | if (index === -1) { 113 | return 114 | } 115 | 116 | workspaceFolders.splice(index, 1) 117 | info(`Removed Melos workspace: ${folder.name}`) 118 | 119 | onDidChangeWorkspaceFoldersEmitter.fire({ 120 | added: [], 121 | removed: [folder], 122 | }) 123 | } 124 | 125 | export async function isMelosWorkspace(folder: vscode.WorkspaceFolder) { 126 | return fileExists(vscode.Uri.joinPath(folder.uri, melosYamlFile)) 127 | } 128 | 129 | async function getMelosWorkspaceFolders() { 130 | const workspaceFolders = vscode.workspace.workspaceFolders 131 | if (!workspaceFolders) { 132 | return [] 133 | } 134 | 135 | const result: vscode.WorkspaceFolder[] = [] 136 | for (const folder of workspaceFolders) { 137 | if (await isMelosWorkspace(folder)) { 138 | addMelosWorkspace(folder) 139 | } 140 | } 141 | return result 142 | } 143 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "melos-code", 3 | "publisher": "blaugold", 4 | "displayName": "Melos", 5 | "description": "Melos support for Visual Studio Code", 6 | "categories": [ 7 | "Other" 8 | ], 9 | "keywords": [ 10 | "melos", 11 | "dart", 12 | "flutter", 13 | "monorepo" 14 | ], 15 | "icon": "docs/images/melos-logo.png", 16 | "galleryBanner": { 17 | "color": "#FFFFFF", 18 | "theme": "dark" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/blaugold/melos-code" 23 | }, 24 | "homepage": "https://github.com/blaugold/melos-code", 25 | "bugs": { 26 | "url": "https://github.com/blaugold/melos-code/issues" 27 | }, 28 | "license": "SEE LICENSE IN LICENSE", 29 | "preview": true, 30 | "version": "1.1.1", 31 | "engines": { 32 | "vscode": "^1.62.0" 33 | }, 34 | "activationEvents": [ 35 | "onLanguage:yaml", 36 | "workspaceContains:melos.yaml", 37 | "onCommand:melos.bootstrap", 38 | "onCommand:melos.clean", 39 | "onCommand:melos.runScript", 40 | "onCommand:melos.showPackageGraph" 41 | ], 42 | "contributes": { 43 | "yamlValidation": [ 44 | { 45 | "fileMatch": "melos.yaml", 46 | "url": "./melos.yaml.schema.json" 47 | } 48 | ], 49 | "taskDefinitions": [ 50 | { 51 | "type": "melos", 52 | "required": [ 53 | "script" 54 | ], 55 | "properties": { 56 | "script": { 57 | "type": "string", 58 | "description": "The name of the script to run." 59 | } 60 | }, 61 | "when": "shellExecutionSupported" 62 | } 63 | ], 64 | "commands": [ 65 | { 66 | "command": "melos.bootstrap", 67 | "title": "Bootstrap", 68 | "category": "Melos", 69 | "icon": "$(package)", 70 | "enablement": "shellExecutionSupported && workspaceFolderCount > 0" 71 | }, 72 | { 73 | "command": "melos.clean", 74 | "title": "Clean", 75 | "category": "Melos", 76 | "enablement": "shellExecutionSupported && workspaceFolderCount > 0" 77 | }, 78 | { 79 | "command": "melos.runScript", 80 | "title": "Run script", 81 | "category": "Melos", 82 | "enablement": "shellExecutionSupported && workspaceFolderCount > 0" 83 | }, 84 | { 85 | "command": "melos.showPackageGraph", 86 | "title": "Show package graph", 87 | "category": "Melos", 88 | "icon": "$(circuit-board)", 89 | "enablement": "shellExecutionSupported && workspaceFolderCount > 0" 90 | } 91 | ], 92 | "menus": { 93 | "editor/title": [ 94 | { 95 | "command": "melos.showPackageGraph", 96 | "when": "resourceFilename == melos.yaml", 97 | "group": "navigation" 98 | }, 99 | { 100 | "command": "melos.bootstrap", 101 | "when": "resourceFilename == melos.yaml", 102 | "group": "navigation" 103 | } 104 | ] 105 | } 106 | }, 107 | "extensionDependencies": [ 108 | "redhat.vscode-yaml", 109 | "dart-code.dart-code" 110 | ], 111 | "main": "./out/extension.js", 112 | "scripts": { 113 | "vscode:prepublish": "npm run esbuild-base -- --minify", 114 | "compile": "tsc -p ./", 115 | "watch": "tsc -watch -p ./", 116 | "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node", 117 | "esbuild": "npm run esbuild-base -- --sourcemap", 118 | "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch", 119 | "lint": "eslint src --ext ts", 120 | "pretest": "npm run compile", 121 | "test": "node ./out/test/runTest.js", 122 | "prettier:check": "prettier --check .", 123 | "prettier:write": "prettier --write .", 124 | "version": "conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md" 125 | }, 126 | "devDependencies": { 127 | "@types/glob": "^7.1.4", 128 | "@types/lodash": "^4.14.177", 129 | "@types/mocha": "^9.0.0", 130 | "@types/node": "14.x", 131 | "@types/vscode": "^1.62.0", 132 | "@typescript-eslint/eslint-plugin": "^5.1.0", 133 | "@typescript-eslint/parser": "^5.1.0", 134 | "@vscode/test-electron": "^2.3.8", 135 | "conventional-changelog-cli": "^2.1.1", 136 | "esbuild": "^0.14.1", 137 | "eslint": "^8.1.0", 138 | "eslint-config-prettier": "^8.3.0", 139 | "glob": "^7.1.7", 140 | "mocha": "^9.1.3", 141 | "prettier": "2.5.0", 142 | "source-map-support": "^0.5.21", 143 | "typescript": "^4.4.4" 144 | }, 145 | "dependencies": { 146 | "ajv": "^8.8.2", 147 | "lodash": "^4.17.21", 148 | "yaml": "^1.10.2" 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/logging.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { consoleLogLevel } from './env' 3 | 4 | /** 5 | * Initializes the logging setup. 6 | */ 7 | export function initLogging(context: vscode.ExtensionContext): void { 8 | // Setup OutputChannel logging. 9 | const outputChannel = vscode.window.createOutputChannel('Melos') 10 | context.subscriptions.push(outputChannel) 11 | LogWriter.register(new OutputChannelLogWriter(outputChannel, LogLevel.info)) 12 | 13 | // Setup console logging. 14 | if (consoleLogLevel) { 15 | LogWriter.register(new ConsoleLogWriter(parseLogLevel(consoleLogLevel))) 16 | } 17 | } 18 | 19 | /** 20 | * A log level. 21 | * 22 | * Log levels are ordered from most to least severe 23 | * and least to most verbose. 24 | * Lower log level include the log level above. 25 | */ 26 | export enum LogLevel { 27 | error = 'error', 28 | warn = 'warn', 29 | info = 'info', 30 | debug = 'debug', 31 | trace = 'trace', 32 | } 33 | 34 | function parseLogLevel(logLevelString: string): LogLevel { 35 | const logLevel = LogLevel[logLevelString as keyof typeof LogLevel] 36 | if (!logLevel) { 37 | throw new Error(`Invalid console log level: ${consoleLogLevel}`) 38 | } 39 | return logLevel 40 | } 41 | 42 | const orderedLogLevels = [ 43 | LogLevel.error, 44 | LogLevel.warn, 45 | LogLevel.info, 46 | LogLevel.debug, 47 | LogLevel.trace, 48 | ] 49 | 50 | function logLevelOrder(level: LogLevel): number { 51 | return orderedLogLevels.indexOf(level) 52 | } 53 | 54 | function logLevelIncludesOther(level: LogLevel, other: LogLevel) { 55 | return logLevelOrder(level) >= logLevelOrder(other) 56 | } 57 | 58 | export function log( 59 | level: LogLevel, 60 | message: string, 61 | error?: any, 62 | ...data: any[] 63 | ) { 64 | LogWriter.write({ 65 | date: new Date(), 66 | level, 67 | message, 68 | error, 69 | data, 70 | }) 71 | } 72 | 73 | export function error(message: string, error: any, ...data: any[]): void { 74 | log(LogLevel.error, message, error, ...data) 75 | } 76 | 77 | export function warn(message: string, ...data: any[]): void { 78 | log(LogLevel.warn, message, undefined, ...data) 79 | } 80 | 81 | export function info(message: string, ...data: any[]): void { 82 | log(LogLevel.info, message, undefined, ...data) 83 | } 84 | 85 | export function debug(message: string, ...data: any[]): void { 86 | log(LogLevel.debug, message, undefined, ...data) 87 | } 88 | 89 | export function trace(message: string, ...data: any[]): void { 90 | log(LogLevel.trace, message, undefined, ...data) 91 | } 92 | 93 | interface LogMessage { 94 | date: Date 95 | level: LogLevel 96 | message: string 97 | error?: any 98 | data: any[] 99 | } 100 | 101 | abstract class LogWriter { 102 | private static logWriters: LogWriter[] = [] 103 | 104 | static register(writer: LogWriter): void { 105 | LogWriter.logWriters.push(writer) 106 | } 107 | 108 | static write(message: LogMessage): void { 109 | LogWriter.logWriters.forEach((writer) => { 110 | if (writer.shouldLog(message)) { 111 | writer.write(message) 112 | } 113 | }) 114 | } 115 | 116 | constructor(public readonly level: LogLevel) {} 117 | 118 | shouldLog(message: LogMessage): boolean { 119 | return logLevelIncludesOther(this.level, message.level) 120 | } 121 | 122 | abstract write(message: LogMessage): void 123 | } 124 | 125 | const logLevelMaxLength = Object.keys(LogLevel) 126 | .map((level) => level.length) 127 | .reduce((a, b) => (a > b ? a : b)) 128 | 129 | function formatLogMessage(message: LogMessage): string { 130 | let result = '' 131 | 132 | result += `${message.date.toISOString()} ` 133 | result += `[${message.level.toUpperCase()}]`.padStart(logLevelMaxLength + 2) 134 | result += ` ${message.message}` 135 | 136 | const error = message.error 137 | if (error) { 138 | result += `\n` 139 | if (error.name) { 140 | result += `${error.name}: ` 141 | } 142 | if (error.message) { 143 | result += `${error.message}` 144 | } 145 | if (error.stack) { 146 | result += `\n${error.stack}` 147 | } 148 | } 149 | 150 | for (const data of message.data) { 151 | result += `\n${JSON.stringify(data, null, 2)}` 152 | } 153 | 154 | return result 155 | } 156 | 157 | class ConsoleLogWriter extends LogWriter { 158 | constructor(level: LogLevel) { 159 | super(level) 160 | } 161 | 162 | write(message: LogMessage): void { 163 | console.log(formatLogMessage(message)) 164 | } 165 | } 166 | 167 | class OutputChannelLogWriter extends LogWriter { 168 | constructor(public readonly channel: vscode.OutputChannel, level: LogLevel) { 169 | super(level) 170 | } 171 | 172 | write(message: LogMessage): void { 173 | this.channel.appendLine(formatLogMessage(message)) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/test/suite/workspace-config.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import { parseMelosWorkspaceConfig } from '../../workspace-config' 3 | 4 | suite('MelosWorkspaceConfig', () => { 5 | suite('parseMelosWorkspaceConfig', () => { 6 | test('parses partial config', async () => { 7 | const config = parseMelosWorkspaceConfig(` 8 | scripts: 9 | a: a 10 | `) 11 | 12 | assert.strictEqual(config.scripts.length, 1) 13 | assert.strictEqual(config.scripts[0].name.value, 'a') 14 | assert.strictEqual(config.scripts[0].run?.value, 'a') 15 | }) 16 | 17 | test('parses scripts', async () => { 18 | const config = parseMelosWorkspaceConfig( 19 | `scripts: 20 | a: a 21 | b: 22 | run: a 23 | ` 24 | ) 25 | 26 | assert.strictEqual(config.scripts.length, 2) 27 | 28 | const scriptA = config.scripts.find((s) => s.name.value === 'a')! 29 | const scriptB = config.scripts.find((s) => s.name.value === 'b')! 30 | 31 | assert.strictEqual(scriptA.name.value, 'a') 32 | assert.deepStrictEqual( 33 | scriptA.name.yamlNode.cstNode?.rangeAsLinePos?.start, 34 | { 35 | line: 2, 36 | col: 5, 37 | } 38 | ) 39 | assert.strictEqual(scriptA.run?.value, 'a') 40 | assert.deepStrictEqual( 41 | scriptA.run.yamlNode.cstNode?.rangeAsLinePos?.start, 42 | { 43 | line: 2, 44 | col: 8, 45 | } 46 | ) 47 | assert.strictEqual(scriptB.name.value, 'b') 48 | assert.deepStrictEqual( 49 | scriptB.name.yamlNode.cstNode?.rangeAsLinePos?.start, 50 | { 51 | line: 3, 52 | col: 5, 53 | } 54 | ) 55 | assert.strictEqual(scriptB.run?.value, 'a') 56 | assert.deepStrictEqual( 57 | scriptB.run.yamlNode.cstNode?.rangeAsLinePos?.start, 58 | { 59 | line: 4, 60 | col: 14, 61 | } 62 | ) 63 | }) 64 | 65 | test('parse melos exec command', () => { 66 | let config = parseMelosWorkspaceConfig( 67 | `scripts: 68 | a: melos exec -- a 69 | ` 70 | ) 71 | 72 | assert.deepStrictEqual(config.scripts[0].run?.melosExec, { 73 | options: [], 74 | command: 'a', 75 | }) 76 | 77 | config = parseMelosWorkspaceConfig( 78 | `scripts: 79 | a: melos exec --c 1 -- a 80 | ` 81 | ) 82 | 83 | assert.deepStrictEqual(config.scripts[0].run?.melosExec, { 84 | options: ['--c', '1'], 85 | command: 'a', 86 | }) 87 | 88 | config = parseMelosWorkspaceConfig( 89 | `scripts: 90 | a: 91 | exec: b 92 | ` 93 | ) 94 | 95 | assert.deepStrictEqual(config.scripts[0].run?.melosExec, { 96 | options: [], 97 | command: 'b', 98 | }) 99 | 100 | config = parseMelosWorkspaceConfig( 101 | `scripts: 102 | a: 103 | run: b 104 | exec: 105 | concurrency: 42 106 | failFast: true 107 | ` 108 | ) 109 | 110 | assert.deepStrictEqual(config.scripts[0].run?.melosExec, { 111 | options: ['--concurrency=42', '--fail-fast'], 112 | command: 'b', 113 | }) 114 | }) 115 | 116 | test('parse packageFilters section of scripts', () => { 117 | let config = parseMelosWorkspaceConfig( 118 | `scripts: 119 | a: 120 | run: b 121 | packageFilters: 122 | scope: a 123 | ignore: b 124 | fileExists: c 125 | dirExists: d 126 | dependsOn: e 127 | noDependsOn: f 128 | diff: g 129 | private: true 130 | published: true 131 | nullSafety: true 132 | flutter: true 133 | ` 134 | ) 135 | 136 | assert.deepStrictEqual(config.scripts[0].packageFilters, { 137 | scope: ['a'], 138 | ignore: ['b'], 139 | fileExists: ['c'], 140 | dirExists: ['d'], 141 | dependsOn: ['e'], 142 | noDependsOn: ['f'], 143 | diff: 'g', 144 | private: true, 145 | published: true, 146 | nullSafety: true, 147 | flutter: true, 148 | }) 149 | 150 | config = parseMelosWorkspaceConfig( 151 | `scripts: 152 | a: 153 | run: b 154 | packageFilters: 155 | scope: 156 | - a 157 | ignore: 158 | - b 159 | fileExists: 160 | - c 161 | dirExists: 162 | - d 163 | dependsOn: 164 | - e 165 | noDependsOn: 166 | - f 167 | ` 168 | ) 169 | 170 | assert.deepStrictEqual(config.scripts[0].packageFilters, { 171 | scope: ['a'], 172 | ignore: ['b'], 173 | fileExists: ['c'], 174 | dirExists: ['d'], 175 | dependsOn: ['e'], 176 | noDependsOn: ['f'], 177 | diff: undefined, 178 | private: undefined, 179 | published: undefined, 180 | nullSafety: undefined, 181 | flutter: undefined, 182 | }) 183 | }) 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /src/execute.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process' 2 | import { kebabCase } from 'lodash' 3 | import { promisify } from 'util' 4 | import * as vscode from 'vscode' 5 | import { melosExecutableName } from './env' 6 | import { info, trace } from './logging' 7 | import { MelosPackageFilters } from './package-filters' 8 | 9 | const execAsync = promisify(exec) 10 | 11 | /** 12 | * Executes a melos command as a VS Code task. 13 | * 14 | * Displays a progress notification, which allows cancellation of the command. 15 | */ 16 | export async function executeMelosCommand(options: { 17 | name: string 18 | folder: vscode.WorkspaceFolder 19 | }): Promise { 20 | const task = new vscode.Task( 21 | { 22 | type: 'melos', 23 | }, 24 | options.folder, 25 | options.name, 26 | 'melos', 27 | new vscode.ShellExecution(`${melosExecutableName} ${options.name}`) 28 | ) 29 | task.presentationOptions = { 30 | reveal: vscode.TaskRevealKind.Silent, 31 | clear: true, 32 | showReuseMessage: false, 33 | } 34 | 35 | const taskEnded = new Promise((resolve) => { 36 | const didEndTaskDisposable = vscode.tasks.onDidEndTaskProcess((event) => { 37 | if (event.execution.task === task) { 38 | didEndTaskDisposable.dispose() 39 | resolve(event.exitCode) 40 | } 41 | }) 42 | }) 43 | 44 | const start = Date.now() 45 | 46 | const taskExecution = await vscode.tasks.executeTask(task) 47 | 48 | const exitCode = await vscode.window.withProgress( 49 | { 50 | title: `Executing 'melos ${options.name}'`, 51 | location: vscode.ProgressLocation.Notification, 52 | cancellable: true, 53 | }, 54 | (_, cancellationToken) => { 55 | cancellationToken.onCancellationRequested(() => taskExecution.terminate()) 56 | return taskEnded 57 | } 58 | ) 59 | 60 | const end = Date.now() 61 | const duration = end - start 62 | 63 | info( 64 | `Executed 'melos ${options.name}' in ${duration}ms with exit code ${exitCode}` 65 | ) 66 | 67 | return exitCode 68 | } 69 | 70 | export async function executeMelosCommandForResult(options: { 71 | args: string[] 72 | folder: vscode.WorkspaceFolder 73 | }): Promise { 74 | const commandLine = `${melosExecutableName} ${options.args.join(' ')}` 75 | const result = execAsync(commandLine, { 76 | encoding: 'utf8', 77 | cwd: options.folder.uri.fsPath, 78 | }) 79 | const output = await result 80 | const exitCode = result.child.exitCode 81 | if (exitCode !== 0) { 82 | throw new Error( 83 | `Expected to get exit code 0 but got ${exitCode}, when executing:\n'${commandLine}'` 84 | ) 85 | } 86 | trace( 87 | `Executed '${commandLine}'\nStdout:\n${output.stdout}\nStderr:\n${output.stderr}` 88 | ) 89 | return output.stdout 90 | } 91 | 92 | export enum MelosListFormat { 93 | json = 'json', 94 | graph = 'graph', 95 | gviz = 'gviz', 96 | } 97 | 98 | export enum MelosPackageType { 99 | dartPackage, 100 | flutterPackage, 101 | flutterPlugin, 102 | flutterApp, 103 | } 104 | 105 | export interface MelosListResult { 106 | name: string 107 | version: string 108 | private: boolean 109 | location: string 110 | type: MelosPackageType 111 | } 112 | 113 | export function melosList(options: { 114 | format: MelosListFormat.json 115 | folder: vscode.WorkspaceFolder 116 | filters?: MelosPackageFilters 117 | }): Promise 118 | 119 | export function melosList(options: { 120 | format: MelosListFormat.gviz 121 | folder: vscode.WorkspaceFolder 122 | filters?: MelosPackageFilters 123 | }): Promise 124 | 125 | export async function melosList(options: { 126 | format: MelosListFormat 127 | folder: vscode.WorkspaceFolder 128 | filters?: MelosPackageFilters 129 | }): Promise { 130 | const args = ['list', `--${options.format}`] 131 | 132 | if (options.filters) { 133 | args.push(...buildPackageFilterOption(options.filters)) 134 | } 135 | 136 | const rawResult = await executeMelosCommandForResult({ 137 | args, 138 | folder: options.folder, 139 | }) 140 | 141 | switch (options.format) { 142 | case MelosListFormat.json: 143 | case MelosListFormat.graph: 144 | return JSON.parse(rawResult) 145 | case MelosListFormat.gviz: 146 | return rawResult 147 | } 148 | } 149 | 150 | export function buildPackageFilterOption( 151 | filters: MelosPackageFilters 152 | ): string[] { 153 | const result: string[] = [] 154 | 155 | for (const key of Object.keys(filters)) { 156 | const optionFlag = `--${kebabCase(key)}` 157 | const value = (filters as any)[key] 158 | 159 | if (value === undefined) { 160 | continue 161 | } 162 | 163 | if (Array.isArray(value)) { 164 | for (const valueItem of value) { 165 | result.push(optionFlag, String(valueItem)) 166 | } 167 | } else { 168 | if (typeof value === 'boolean') { 169 | result.push(value ? optionFlag : `--no-${kebabCase(key)}`) 170 | } else { 171 | result.push(optionFlag, String(value)) 172 | } 173 | } 174 | } 175 | 176 | return result 177 | } 178 | -------------------------------------------------------------------------------- /src/test/suite/melos-yaml-schema.test.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { ValidateFunction } from 'ajv' 2 | import * as assert from 'assert' 3 | import * as fs from 'fs' 4 | import * as path from 'path' 5 | import * as YAML from 'yaml' 6 | 7 | suite('melos.yaml Schema', () => { 8 | setup(() => { 9 | const melosYamlSchema = fs.readFileSync( 10 | path.join(__dirname, '../../../melos.yaml.schema.json'), 11 | 'utf8' 12 | ) 13 | melosYamlValidationFn = new Ajv().compile(JSON.parse(melosYamlSchema)) 14 | }) 15 | 16 | test('accept minimal config', () => { 17 | assertValidMelosYaml(` 18 | name: a 19 | packages: 20 | - a 21 | `) 22 | }) 23 | 24 | test('reject empty melos.yaml', () => { 25 | assertInvalidMelosYaml(` 26 | `) 27 | }) 28 | 29 | test('reject additional props at root', () => { 30 | assertInvalidMelosYaml( 31 | ` 32 | name: a 33 | packages: 34 | - a 35 | a: a 36 | `, 37 | { instancePath: '', keyword: 'additionalProperties' } 38 | ) 39 | }) 40 | 41 | test('accept repository option', () => { 42 | assertValidMelosYaml(` 43 | name: a 44 | repository: https://github.com/user/repo 45 | packages: 46 | - a 47 | `) 48 | }) 49 | 50 | test('accept intellij ide config', () => { 51 | assertValidMelosYaml(` 52 | name: a 53 | packages: 54 | - a 55 | ide: 56 | intellij: true 57 | `) 58 | }) 59 | 60 | test('reject additional props in ide config', () => { 61 | assertInvalidMelosYaml( 62 | ` 63 | name: a 64 | packages: 65 | - a 66 | ide: 67 | a: a 68 | `, 69 | { instancePath: '/ide', keyword: 'additionalProperties' } 70 | ) 71 | }) 72 | 73 | test('accept version command config', () => { 74 | assertValidMelosYaml(` 75 | name: a 76 | packages: 77 | - a 78 | command: 79 | version: 80 | message: a 81 | linkToCommits: true 82 | branch: a 83 | `) 84 | }) 85 | 86 | test('reject additional props in command config', () => { 87 | assertInvalidMelosYaml( 88 | ` 89 | name: a 90 | packages: 91 | - a 92 | command: 93 | a: a 94 | `, 95 | { instancePath: '/command', keyword: 'additionalProperties' } 96 | ) 97 | }) 98 | 99 | test('reject additional props in version command config', () => { 100 | assertInvalidMelosYaml( 101 | ` 102 | name: a 103 | packages: 104 | - a 105 | command: 106 | version: 107 | a: a 108 | `, 109 | { instancePath: '/command/version', keyword: 'additionalProperties' } 110 | ) 111 | }) 112 | 113 | test('accept simple script config', () => { 114 | assertValidMelosYaml(` 115 | name: a 116 | packages: 117 | - a 118 | scripts: 119 | a: a 120 | `) 121 | }) 122 | 123 | test('accept full script config', () => { 124 | assertValidMelosYaml(` 125 | name: a 126 | packages: 127 | - a 128 | scripts: 129 | a: 130 | name: a 131 | description: a 132 | run: a 133 | env: 134 | a: a 135 | packageFilters: 136 | scope: a 137 | ignore: a 138 | dirExists: a 139 | fileExists: a 140 | dependsOn: a 141 | noDependsOn: a 142 | diff: a 143 | private: true 144 | noPrivate: false 145 | published: true 146 | nullSafety: true 147 | flutter: true 148 | b: 149 | run: a 150 | packageFilters: 151 | scope: 152 | - a 153 | ignore: 154 | - a 155 | dirExists: 156 | - a 157 | fileExists: 158 | - a 159 | dependsOn: 160 | - a 161 | noDependsOn: 162 | - a 163 | `) 164 | }) 165 | 166 | test('reject additional props in script config', () => { 167 | assertInvalidMelosYaml( 168 | ` 169 | name: a 170 | packages: 171 | - a 172 | scripts: 173 | a: 174 | run: a 175 | a: a 176 | `, 177 | { instancePath: '/scripts/a', keyword: 'additionalProperties' } 178 | ) 179 | }) 180 | 181 | test('reject additional props in script packageFilters config', () => { 182 | assertInvalidMelosYaml( 183 | ` 184 | name: a 185 | packages: 186 | - a 187 | scripts: 188 | a: 189 | run: a 190 | packageFilters: 191 | a: a 192 | `, 193 | { 194 | instancePath: '/scripts/a/packageFilters', 195 | keyword: 'additionalProperties', 196 | } 197 | ) 198 | }) 199 | }) 200 | 201 | let melosYamlValidationFn: ValidateFunction 202 | 203 | function assertValidMelosYaml(content: string) { 204 | const yamlContent = YAML.parse(content) 205 | const valid = melosYamlValidationFn(yamlContent) 206 | if (!valid) { 207 | assert.fail( 208 | melosYamlValidationFn 209 | .errors!.map((e) => JSON.stringify(e, null, 2)) 210 | .join('\n') 211 | ) 212 | } 213 | } 214 | 215 | function assertInvalidMelosYaml( 216 | content: string, 217 | options?: { instancePath?: string; keyword?: string } 218 | ) { 219 | if (options?.keyword !== undefined && options.instancePath === undefined) { 220 | throw new Error('instancePath is required when keyword is specified') 221 | } 222 | 223 | const yamlContent = YAML.parse(content) 224 | const valid = melosYamlValidationFn(yamlContent) 225 | 226 | if (valid) { 227 | assert.fail(`Expected melos.yaml to be invalid\n${content}`) 228 | } 229 | 230 | if (options?.instancePath !== undefined) { 231 | const error = melosYamlValidationFn.errors!.find((error) => 232 | error.instancePath === options?.instancePath && 233 | options?.keyword !== undefined 234 | ? error.keyword === options?.keyword 235 | : true 236 | ) 237 | 238 | if (!error) { 239 | assert.fail( 240 | `Expected error at ${options.instancePath} 241 | ${content} 242 | ${JSON.stringify(melosYamlValidationFn.errors!, null, 2)}` 243 | ) 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.1.1](https://github.com/blaugold/melos-code/compare/v1.1.0...v1.1.1) (2024-01-02) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * spelling of `dev_dependencies` in melos.yaml schema ([6d11dcd](https://github.com/blaugold/melos-code/commit/6d11dcdd0f941f35107e9b2b10cd7a2a251d829f)) 7 | 8 | 9 | 10 | # [1.1.0](https://github.com/blaugold/melos-code/compare/v1.0.1...v1.1.0) (2024-01-02) 11 | 12 | 13 | ### Features 14 | 15 | * add `dependencyOverrides` to melos.yaml schema ([#48](https://github.com/blaugold/melos-code/issues/48)) ([0048471](https://github.com/blaugold/melos-code/commit/0048471e7d032201668ce70be8d41d99554e9b3e)) 16 | * add `enforceLockfile`, `environment`, `dependencies` and `devDependencies` to melos.yaml schema ([#53](https://github.com/blaugold/melos-code/issues/53)) ([ee441bd](https://github.com/blaugold/melos-code/commit/ee441bdade37129d2b4a38dee4093c72d668d67d)) 17 | * add `fetchTags` to `melos.yaml` schema ([#49](https://github.com/blaugold/melos-code/issues/49)) ([d0efb28](https://github.com/blaugold/melos-code/commit/d0efb289548a0c27121cfbdae56998649bb43c6f)) 18 | * add `runPubGetOffline` and `runPubGetOffline` to melos.yaml schema ([#51](https://github.com/blaugold/melos-code/issues/51)) ([c5fc37f](https://github.com/blaugold/melos-code/commit/c5fc37f9469c66d59c4edb2fa4ced68531c4b058)) 19 | 20 | 21 | 22 | ## [1.0.1](https://github.com/blaugold/melos-code/compare/v1.0.0...v1.0.1) (2023-04-24) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * add `clean` hooks to `melos.yaml` schema ([#47](https://github.com/blaugold/melos-code/issues/47)) ([7041836](https://github.com/blaugold/melos-code/commit/70418363ab3db6b96f73687516b1c65ebe74d10a)) 28 | 29 | 30 | 31 | # [1.0.0](https://github.com/blaugold/melos-code/compare/v0.6.0...v1.0.0) (2023-04-20) 32 | 33 | 34 | ### Features 35 | 36 | * support Melos 3 ([#46](https://github.com/blaugold/melos-code/issues/46)) 37 | * add self-hosted repositories to `melos.yaml` schema ([#40](https://github.com/blaugold/melos-code/issues/40)) ([b321207](https://github.com/blaugold/melos-code/commit/b32120729b69ca7e601dda32f0a5d46bb5dbc58c)) 38 | 39 | 40 | 41 | # [0.6.0](https://github.com/blaugold/melos-code/compare/v0.5.0...v0.6.0) (2022-10-26) 42 | 43 | 44 | ### Features 45 | 46 | * add `ide.intellij.enabled` and `command.version.releaseUrl` to `melos.yaml` schema ([#39](https://github.com/blaugold/melos-code/issues/39)) ([69c29db](https://github.com/blaugold/melos-code/commit/69c29dba658b2889e421c9de829376c3ad2b7a0b)) 47 | 48 | 49 | 50 | # [0.5.0](https://github.com/blaugold/melos-code/compare/v0.4.3...v0.5.0) (2022-09-15) 51 | 52 | 53 | ### Features 54 | 55 | * add `moduleNamePrefix` to `melos.yaml` schema ([87f88e8](https://github.com/blaugold/melos-code/commit/87f88e847c23062d3c202939f5ef37f0a886c04f)) 56 | * don't touch `dart.runPubGetOnPubspecChanges` when `usePubspecOverrides` is enabled ([#38](https://github.com/blaugold/melos-code/issues/38)) ([da73508](https://github.com/blaugold/melos-code/commit/da735081480790634a8997fe865670a94badf831)), closes [#36](https://github.com/blaugold/melos-code/issues/36) [#37](https://github.com/blaugold/melos-code/issues/37) 57 | 58 | 59 | 60 | ## [0.4.3](https://github.com/blaugold/melos-code/compare/v0.4.2...v0.4.3) (2022-06-15) 61 | 62 | 63 | ### Features 64 | 65 | * add support for `exec` in scripts ([#34](https://github.com/blaugold/melos-code/issues/34)) ([5cf9b1b](https://github.com/blaugold/melos-code/commit/5cf9b1b1a861a0abffa920dd3f4806495d7d92a0)) 66 | 67 | 68 | 69 | ## [0.4.2](https://github.com/blaugold/melos-code/compare/v0.4.1...v0.4.2) (2022-05-22) 70 | 71 | 72 | ### Features 73 | 74 | * add new options to `melos.yaml` schema ([2c79fca](https://github.com/blaugold/melos-code/commit/2c79fca3838f459b29594bb9c20e16809d682dde)) 75 | 76 | 77 | 78 | ## [0.4.1](https://github.com/blaugold/melos-code/compare/v0.4.0...v0.4.1) (2022-04-28) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * don't use `--all` flag when invoking `melos list` ([#33](https://github.com/blaugold/melos-code/issues/33)) ([27c92d4](https://github.com/blaugold/melos-code/commit/27c92d4a3b22dcfef2442c34ef3412d571500469)) 84 | 85 | 86 | ### Features 87 | 88 | * add new properties to `melos.yaml` schema ([#32](https://github.com/blaugold/melos-code/issues/32)) ([adf0b99](https://github.com/blaugold/melos-code/commit/adf0b993febaa672079406e0b56a8176aa5972f6)) 89 | 90 | 91 | 92 | # [0.4.0](https://github.com/blaugold/melos-code/compare/v0.3.0...v0.4.0) (2022-03-26) 93 | 94 | 95 | ### Bug Fixes 96 | 97 | * only run melos script in selected package ([#31](https://github.com/blaugold/melos-code/issues/31)) ([55c8636](https://github.com/blaugold/melos-code/commit/55c86362013c531032bd1c29c1373efcb679a887)) 98 | 99 | 100 | 101 | # [0.3.0](https://github.com/blaugold/melos-code/compare/v0.2.1...v0.3.0) (2021-12-30) 102 | 103 | 104 | ### Bug Fixes 105 | 106 | * allow scalar values for env args of scripts in `melos.yaml` ([#23](https://github.com/blaugold/melos-code/issues/23)) ([899a128](https://github.com/blaugold/melos-code/commit/899a12818aabc9f15b62014f683bb36423d07837)) 107 | * fill window with package graph ([96cba55](https://github.com/blaugold/melos-code/commit/96cba5585b5bea8b4222a017d9d272edbd24daa5)), closes [#19](https://github.com/blaugold/melos-code/issues/19) 108 | * use `--all` flag with `melos list` ([69cd405](https://github.com/blaugold/melos-code/commit/69cd405be1e88b2f17a034650fe6f6c9cac21089)), closes [#21](https://github.com/blaugold/melos-code/issues/21) 109 | 110 | 111 | ### Features 112 | 113 | * add editor title menu entries for `melos.yaml` ([cc43a75](https://github.com/blaugold/melos-code/commit/cc43a7562c8255dc6f150326e77972671b1228e0)), closes [#20](https://github.com/blaugold/melos-code/issues/20) 114 | 115 | 116 | 117 | ## [0.2.1](https://github.com/blaugold/melos-code/compare/v0.2.0...v0.2.1) (2021-12-13) 118 | 119 | 120 | ### Features 121 | 122 | * add `Melos: Run script` command ([#17](https://github.com/blaugold/melos-code/issues/17)) ([f3769d1](https://github.com/blaugold/melos-code/commit/f3769d16f42fc6021c3440a5d97851a069dfee4f)), closes [#16](https://github.com/blaugold/melos-code/issues/16) [#15](https://github.com/blaugold/melos-code/issues/15) 123 | * add support for showing package graph ([#14](https://github.com/blaugold/melos-code/issues/14)) ([1857924](https://github.com/blaugold/melos-code/commit/185792434a8decb7fbd1c9af821c026ee0399a33)), closes [#5](https://github.com/blaugold/melos-code/issues/5) 124 | 125 | 126 | 127 | # [0.2.0](https://github.com/blaugold/melos-code/compare/v0.1.0...v0.2.0) (2021-12-08) 128 | 129 | 130 | ### Bug Fixes 131 | 132 | * add `workspaceChangelog` to yaml scheme ([2f82b4a](https://github.com/blaugold/melos-code/commit/2f82b4aa23017b138466c81eea8e74b3fa4d339c)) 133 | 134 | 135 | ### Features 136 | 137 | * add commands for `bootstrap` and `clean` ([#13](https://github.com/blaugold/melos-code/issues/13)) ([743dbca](https://github.com/blaugold/melos-code/commit/743dbca7b9c3bc9d012482d0c2f5931697fe613d)), closes [#2](https://github.com/blaugold/melos-code/issues/2) 138 | * add logging ([#10](https://github.com/blaugold/melos-code/issues/10)) ([7eab6e5](https://github.com/blaugold/melos-code/commit/7eab6e51663b0af1cf694eee4a6e13cfba06c4b1)) 139 | 140 | 141 | 142 | ## 0.1.0 143 | 144 | - Initial release 145 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { executeMelosCommand, melosList, MelosListFormat } from './execute' 3 | import { debug } from './logging' 4 | import { showPackageGraphView } from './package_graph_view' 5 | import { 6 | buildMelosExecScriptTask, 7 | buildMelosScriptTask, 8 | } from './script-task-provider' 9 | import { resolveWorkspaceFolder } from './utils/vscode-utils' 10 | import { loadMelosWorkspaceConfig, MelosScriptConfig } from './workspace-config' 11 | 12 | export function registerMelosCommands(context: vscode.ExtensionContext) { 13 | registerMelosToolCommand(context, { name: 'bootstrap' }) 14 | registerMelosToolCommand(context, { name: 'clean' }) 15 | 16 | context.subscriptions.push( 17 | vscode.commands.registerCommand( 18 | 'melos.runScript', 19 | runScriptCommandHandler(context) 20 | ) 21 | ) 22 | 23 | context.subscriptions.push( 24 | vscode.commands.registerCommand( 25 | 'melos.showPackageGraph', 26 | showPackageGraphCommandHandler() 27 | ) 28 | ) 29 | } 30 | 31 | function registerMelosToolCommand( 32 | context: vscode.ExtensionContext, 33 | options: { name: string } 34 | ) { 35 | context.subscriptions.push( 36 | vscode.commands.registerCommand(`melos.${options.name}`, async () => { 37 | debug(`command:${options.name}`) 38 | 39 | const workspaceFolder = await resolveWorkspaceFolder() 40 | if (!workspaceFolder) { 41 | return 42 | } 43 | 44 | return executeMelosCommand({ 45 | name: options.name, 46 | folder: workspaceFolder, 47 | }) 48 | }) 49 | ) 50 | } 51 | 52 | /** 53 | * The arguments for the `melos.runScript` command. 54 | */ 55 | export interface MelosRunScriptCommandArgs { 56 | /** 57 | * The workspace folder which contains the melos.yaml file which defines the 58 | * {@link script}. 59 | */ 60 | workspaceFolder: vscode.WorkspaceFolder 61 | /** 62 | * The name of the script to run. 63 | */ 64 | script: string 65 | /** 66 | * Whether to run the script in all packages. 67 | * 68 | * This only applies to scripts, that use `melos exec` to run across multiple 69 | * packages. The default is `false`. 70 | * 71 | * If this option is `false`, and the script could be executed in multiple 72 | * packages, the user will be asked to select one. 73 | */ 74 | runInAllPackages?: boolean 75 | } 76 | 77 | function runScriptCommandHandler(context: vscode.ExtensionContext) { 78 | return async (args?: MelosRunScriptCommandArgs) => { 79 | debug(`command:runScript`, { 80 | ...args, 81 | workspaceFolder: args?.workspaceFolder.name, 82 | }) 83 | 84 | const workspaceFolder = 85 | args?.workspaceFolder ?? (await resolveWorkspaceFolder()) 86 | 87 | if (!workspaceFolder) { 88 | // User did not select a workspace folder. 89 | return 90 | } 91 | 92 | const melosConfig = await loadMelosWorkspaceConfig(context, workspaceFolder) 93 | if (!melosConfig) { 94 | vscode.window.showErrorMessage('No melos.yaml file found.') 95 | return 96 | } 97 | 98 | // Resolve the full script configuration. 99 | let scriptConfig: MelosScriptConfig | undefined 100 | if (args) { 101 | scriptConfig = melosConfig.scripts.find( 102 | (script) => script.name.value === args.script 103 | ) 104 | 105 | if (!scriptConfig) { 106 | throw new Error(`Script ${args.script} not found.`) 107 | } 108 | } else { 109 | // Ask the user to select a script. 110 | const scriptPickItems = melosConfig.scripts.map((script) => ({ 111 | label: script.name.value, 112 | detail: script.run?.value, 113 | script, 114 | })) 115 | 116 | const scriptPickItem = await vscode.window.showQuickPick( 117 | scriptPickItems, 118 | { 119 | title: 'Select a script to run', 120 | } 121 | ) 122 | if (!scriptPickItem) { 123 | // User did not select a script. 124 | return 125 | } 126 | 127 | scriptConfig = scriptPickItem.script 128 | } 129 | 130 | function runMelosScriptTask() { 131 | return vscode.tasks.executeTask( 132 | buildMelosScriptTask( 133 | { 134 | type: 'melos', 135 | script: scriptConfig!.name.value, 136 | }, 137 | workspaceFolder! 138 | ) 139 | ) 140 | } 141 | 142 | if (args?.runInAllPackages || !scriptConfig.run?.melosExec) { 143 | return runMelosScriptTask() 144 | } 145 | 146 | // Resolve the package to run the script in. 147 | const packages = await melosList({ 148 | format: MelosListFormat.json, 149 | folder: workspaceFolder, 150 | filters: scriptConfig.packageFilters, 151 | }) 152 | 153 | if (packages.length === 0) { 154 | vscode.window.showWarningMessage( 155 | `No packages found for script ${scriptConfig.name.value}.` 156 | ) 157 | return 158 | } 159 | 160 | let packageName: string | undefined 161 | if (packages.length === 1) { 162 | packageName = packages[0].name 163 | } else { 164 | // Ask the user to select a package. 165 | const selectedPackage = await vscode.window.showQuickPick( 166 | [ 167 | { label: 'All packages', pkg: undefined }, 168 | ...packages.map((pkg) => ({ 169 | label: pkg.name, 170 | detail: vscode.workspace.asRelativePath(pkg.location), 171 | pkg, 172 | })), 173 | ], 174 | { 175 | title: 'Select package to run script in', 176 | } 177 | ) 178 | 179 | if (!selectedPackage) { 180 | // User did not select a package. 181 | return 182 | } 183 | 184 | if (!selectedPackage.pkg) { 185 | // User wants to run the script in all packages. 186 | return runMelosScriptTask() 187 | } 188 | 189 | packageName = selectedPackage.pkg.name 190 | } 191 | 192 | const melosExecCommand = scriptConfig.run?.melosExec 193 | if (!melosExecCommand) { 194 | vscode.window.showErrorMessage( 195 | `Invalid melos exec command: ${scriptConfig.run?.value}` 196 | ) 197 | return 198 | } 199 | 200 | // Execute the script in a single package. 201 | const task = buildMelosExecScriptTask( 202 | { 203 | type: 'melos', 204 | script: `${scriptConfig!.name.value} [${packageName}]`, 205 | execOptions: ['--scope', packageName, ...melosExecCommand.options], 206 | command: melosExecCommand.command, 207 | }, 208 | workspaceFolder 209 | ) 210 | return vscode.tasks.executeTask(task) 211 | } 212 | } 213 | 214 | function showPackageGraphCommandHandler() { 215 | return async () => { 216 | // Get melos workspace folder. 217 | const workspaceFolder = await resolveWorkspaceFolder() 218 | if (!workspaceFolder) { 219 | return 220 | } 221 | 222 | // Get package graph data. 223 | const dotGraph = await melosList({ 224 | format: MelosListFormat.gviz, 225 | folder: workspaceFolder, 226 | }) 227 | 228 | // Show package graph. 229 | return showPackageGraphView({ dotGraph, folder: workspaceFolder }) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/workspace-config.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { Schema, ValidateFunction } from 'ajv' 2 | import * as vscode from 'vscode' 3 | import { Document, parseDocument } from 'yaml' 4 | import { Node, Scalar, YAMLMap } from 'yaml/types' 5 | import { info, warn } from './logging' 6 | import { MelosPackageFilters } from './package-filters' 7 | import { readOptionalFile } from './utils/fs-utils' 8 | 9 | export const melosYamlFile = 'melos.yaml' 10 | 11 | // https://regex101.com/r/idiNSJ/1 12 | const melosExecRegex = /^\s*melos\s*exec/ 13 | 14 | /** 15 | * A Melos workspace configuration. 16 | * 17 | * Only data currently used by the extension is included. 18 | */ 19 | export interface MelosWorkspaceConfig { 20 | /** 21 | * The YAML document from which the configuration was parsed. 22 | */ 23 | readonly yamlDoc: Document 24 | 25 | /** 26 | * Configuration for Melos commands. 27 | */ 28 | readonly command?: MelosCommandsConfig 29 | 30 | /** 31 | * The configured Melos scripts. 32 | */ 33 | readonly scripts: readonly MelosScriptConfig[] 34 | } 35 | 36 | /** 37 | * Configuration for Melos commands. 38 | */ 39 | export interface MelosCommandsConfig { 40 | /** 41 | * Configuration for the `melos bootstrap` command. 42 | */ 43 | readonly bootstrap: MelosBootstrapConfig 44 | } 45 | 46 | /** 47 | * Configuration for the `melos bootstrap` command. 48 | */ 49 | export interface MelosBootstrapConfig { 50 | /** 51 | * Whether Melos should use `pubspec_overrides.yaml` files to override dependencies. 52 | */ 53 | readonly usePubspecOverrides?: boolean 54 | } 55 | 56 | /** 57 | * Configuration for a Melos script. 58 | */ 59 | export interface MelosScriptConfig { 60 | /** 61 | * The name of the script. 62 | */ 63 | readonly name: MelosScriptName 64 | /** 65 | * The command to run for the script. 66 | */ 67 | readonly run?: MelosScriptCommand 68 | /** 69 | * The package filters to apply for the script. 70 | */ 71 | readonly packageFilters?: MelosPackageFilters 72 | } 73 | 74 | /** 75 | * The name of a Melos script. 76 | */ 77 | export interface MelosScriptName { 78 | /** 79 | * The name of the script. 80 | */ 81 | readonly value: string 82 | /** 83 | * The YAML node that contains the name. 84 | */ 85 | readonly yamlNode: Node 86 | } 87 | 88 | /** 89 | * A command to run for a Melos script. 90 | */ 91 | export interface MelosScriptCommand { 92 | /** 93 | * The command to run. 94 | */ 95 | readonly value: string 96 | /** 97 | * The YAML node that contains the command. 98 | */ 99 | readonly yamlNode: Node 100 | /** 101 | * If the command is a `melos exec` command, the parsed representation of it. 102 | */ 103 | readonly melosExec?: MelosExecCommand 104 | } 105 | 106 | /** 107 | * Parsed representation of a `melos exec` command line. 108 | */ 109 | export interface MelosExecCommand { 110 | /** 111 | * The options for the `exec` command. 112 | */ 113 | readonly options: string[] 114 | /** 115 | * The actual command to run. 116 | */ 117 | readonly command: string 118 | } 119 | 120 | /** 121 | * Parses the given string as a melos.yaml file. 122 | * 123 | * As much as possible of the file is parsed, even if it is invalid. 124 | */ 125 | export function parseMelosWorkspaceConfig(text: string): MelosWorkspaceConfig { 126 | const doc = parseDocument(text, { keepCstNodes: true }) 127 | return melosWorkspaceConfigFromYamlDoc(doc) 128 | } 129 | 130 | /** 131 | * Returns whether the given workspace configuration was created from a valid 132 | * file. 133 | */ 134 | export async function isMelosWorkspaceConfigValid( 135 | context: vscode.ExtensionContext, 136 | config: MelosWorkspaceConfig 137 | ): Promise { 138 | const validate = await getValidateMelosYamlFunction(context) 139 | return !!validate(config.yamlDoc.toJSON()) 140 | } 141 | 142 | /** 143 | * Loads and parses the melos.yaml file for the given workspace {@link folder}. 144 | * 145 | * Returns null if the files does not exist. 146 | * 147 | * If the file is invalid a warning message is displayed to the user. 148 | * The partially parsed config is still returned. 149 | * Validation is base on the JSON schema defined in the 150 | * {@link melosYamlSchemaFile} file. 151 | * 152 | * @param context The extension context. 153 | */ 154 | export async function loadMelosWorkspaceConfig( 155 | context: vscode.ExtensionContext, 156 | folder: vscode.WorkspaceFolder 157 | ): Promise { 158 | const configFile = await readOptionalFile( 159 | vscode.Uri.joinPath(folder.uri, melosYamlFile) 160 | ) 161 | 162 | if (configFile === null) { 163 | return null 164 | } 165 | 166 | const config = parseMelosWorkspaceConfig(configFile.toString()) 167 | 168 | if (await isMelosWorkspaceConfigValid(context, config)) { 169 | info(`Loaded valid ${melosYamlFile} from '${folder.name}' folder`) 170 | } else { 171 | warn(`Loaded invalid ${melosYamlFile} from '${folder.name}' folder`) 172 | showInvalidMelosYamlMessage(folder) 173 | } 174 | 175 | return config 176 | } 177 | 178 | function showInvalidMelosYamlMessage(folder: vscode.WorkspaceFolder) { 179 | vscode.window.showWarningMessage( 180 | `The ${melosYamlFile} file in the ${folder.name} folder is invalid.` 181 | ) 182 | } 183 | 184 | function melosWorkspaceConfigFromYamlDoc(doc: Document): MelosWorkspaceConfig { 185 | return { 186 | yamlDoc: doc, 187 | command: doc.toJSON()['command'], 188 | scripts: melosScriptsConfigsFromYaml(doc.get('scripts')), 189 | } 190 | } 191 | 192 | function melosScriptsConfigsFromYaml(value: any): MelosScriptConfig[] { 193 | if (!(value instanceof YAMLMap)) { 194 | return [] 195 | } 196 | 197 | return value.items 198 | .filter((entry) => { 199 | const name = entry.key 200 | return name instanceof Scalar && typeof name.value === 'string' 201 | }) 202 | .map((entry) => { 203 | const name = entry.key as Scalar 204 | 205 | const scriptName = { 206 | value: name.value, 207 | yamlNode: name, 208 | } 209 | 210 | const definition = entry.value 211 | if (definition instanceof Scalar) { 212 | return { 213 | name: scriptName, 214 | run: melosScriptCommandFromYaml(definition), 215 | } 216 | } 217 | 218 | if (definition instanceof YAMLMap) { 219 | return { 220 | name: scriptName, 221 | run: melosScriptCommandFromYaml( 222 | definition.get('run', true), 223 | definition.get('exec', true) 224 | ), 225 | packageFilters: melosPackageFiltersFromYaml( 226 | definition.get('packageFilters', true) 227 | ), 228 | } 229 | } 230 | 231 | return { name: scriptName } 232 | }) 233 | } 234 | 235 | function melosScriptCommandFromYaml( 236 | run: any, 237 | exec?: any 238 | ): MelosScriptCommand | undefined { 239 | if (exec) { 240 | if (exec instanceof Scalar) { 241 | if (typeof exec.value === 'string') { 242 | return { 243 | value: exec.value, 244 | yamlNode: exec, 245 | melosExec: { 246 | command: exec.value, 247 | options: [], 248 | }, 249 | } 250 | } else { 251 | return 252 | } 253 | } else if (exec instanceof YAMLMap) { 254 | if (run instanceof Scalar && typeof run.value === 'string') { 255 | const options: string[] = [] 256 | 257 | const concurrency = exec.get('concurrency') 258 | if (typeof concurrency === 'number') { 259 | options.push(`--concurrency=${concurrency}`) 260 | } 261 | 262 | const failFast = exec.get('failFast') 263 | if (typeof failFast === 'boolean' && failFast) { 264 | options.push('--fail-fast') 265 | } 266 | 267 | return { 268 | value: run.value, 269 | yamlNode: run, 270 | melosExec: { 271 | command: run.value, 272 | options: options, 273 | }, 274 | } 275 | } 276 | } else { 277 | return 278 | } 279 | } 280 | 281 | if (run instanceof Scalar && typeof run.value === 'string') { 282 | return { 283 | value: run.value, 284 | yamlNode: run, 285 | melosExec: parseMelosExecCommand(run.value), 286 | } 287 | } 288 | 289 | return 290 | } 291 | 292 | function parseMelosExecCommand( 293 | commandLine: string 294 | ): MelosExecCommand | undefined { 295 | if (!melosExecRegex.test(commandLine)) { 296 | return 297 | } 298 | 299 | const [options, command] = commandLine 300 | .replace(melosExecRegex, '') 301 | .split(' -- ') 302 | .map((part) => part.trim()) 303 | if (options === undefined || command === undefined) { 304 | return 305 | } 306 | 307 | return { 308 | options: options === '' ? [] : options.split(/\s+/), 309 | command, 310 | } 311 | } 312 | 313 | function melosPackageFiltersFromYaml( 314 | value: any 315 | ): MelosPackageFilters | undefined { 316 | if (!(value instanceof YAMLMap)) { 317 | return 318 | } 319 | 320 | const json = value.toJSON() as any 321 | 322 | function getStringList(key: string): string[] | undefined { 323 | const value = json[key] 324 | if (value === undefined) { 325 | return 326 | } 327 | 328 | if (Array.isArray(value)) { 329 | return value.filter((value) => typeof value === 'string') 330 | } 331 | 332 | return typeof value === 'string' ? [value] : undefined 333 | } 334 | 335 | function getString(key: string): string | undefined { 336 | const value = json[key] 337 | if (value === undefined) { 338 | return 339 | } 340 | 341 | return typeof value === 'string' ? value : undefined 342 | } 343 | 344 | function getBoolean(key: string): boolean | undefined { 345 | const value = json[key] 346 | if (value === undefined) { 347 | return 348 | } 349 | 350 | return typeof value === 'boolean' ? value : undefined 351 | } 352 | 353 | const noPrivate = getBoolean('noPrivate') 354 | const privateFromNoPrivate = noPrivate === undefined ? undefined : !noPrivate 355 | 356 | return { 357 | scope: getStringList('scope'), 358 | ignore: getStringList('ignore'), 359 | dirExists: getStringList('dirExists'), 360 | fileExists: getStringList('fileExists'), 361 | dependsOn: getStringList('dependsOn'), 362 | noDependsOn: getStringList('noDependsOn'), 363 | diff: getString('diff'), 364 | private: getBoolean('private') ?? privateFromNoPrivate, 365 | published: getBoolean('published'), 366 | nullSafety: getBoolean('nullSafety'), 367 | flutter: getBoolean('flutter'), 368 | } 369 | } 370 | 371 | // === melos.yaml schema ====================================================== 372 | 373 | /** 374 | * Location of melos.yaml schema file relative to extension root. 375 | */ 376 | const melosYamlSchemaFile = 'melos.yaml.schema.json' 377 | 378 | let validateMelosYamlFunction: Promise | undefined 379 | 380 | async function getValidateMelosYamlFunction( 381 | context: vscode.ExtensionContext 382 | ): Promise { 383 | return (validateMelosYamlFunction ??= loadMelosYamlSchema( 384 | context.extensionUri 385 | ).then((schema) => new Ajv().compile(schema))) 386 | } 387 | 388 | async function loadMelosYamlSchema(extensionUri: vscode.Uri): Promise { 389 | const rawSchema = await vscode.workspace.fs.readFile( 390 | vscode.Uri.joinPath(extensionUri, melosYamlSchemaFile) 391 | ) 392 | return JSON.parse(rawSchema.toString()) 393 | } 394 | -------------------------------------------------------------------------------- /melos.yaml.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema", 3 | "title": "melos.yaml", 4 | "description": "The workspace configuration file for Melos", 5 | "type": "object", 6 | "required": ["name", "packages"], 7 | "additionalProperties": false, 8 | "properties": { 9 | "name": { 10 | "type": "string", 11 | "description": "The name of the Melos workspace - used by IDE documentation." 12 | }, 13 | "repository": { 14 | "anyOf": [ 15 | { 16 | "type": "string", 17 | "description": "The hosted git repository which contains the workspace." 18 | }, 19 | { 20 | "type": "object", 21 | "description": "The self-hosted git repository which contains the workspace.", 22 | "additionalProperties": false, 23 | "required": ["type", "origin", "owner", "name"], 24 | "properties": { 25 | "type": { 26 | "type": "string", 27 | "description": "The type of repository", 28 | "enum": ["github", "gitlab"] 29 | }, 30 | "origin": { 31 | "type": "string", 32 | "description": "The origin of the repository, e.g. https://git.example.dev/gitlab" 33 | }, 34 | "owner": { 35 | "type": "string", 36 | "description": "The owner of the repository" 37 | }, 38 | "name": { 39 | "type": "string", 40 | "description": "The name of the repository" 41 | } 42 | } 43 | } 44 | ] 45 | }, 46 | "packages": { 47 | "type": "array", 48 | "description": "Patterns for packages that are included in the melos workspace.", 49 | "items": { "type": "string" } 50 | }, 51 | "ignore": { 52 | "type": "array", 53 | "description": "Patterns for packages to exclude from the melos workspace.", 54 | "items": { "type": "string" } 55 | }, 56 | "sdkPath": { 57 | "type": "string", 58 | "description": "Path to the Dart/Flutter SDK that should be used." 59 | }, 60 | "ide": { 61 | "type": "object", 62 | "description": "IDE-specific configurations.\n\nThis allows connecting the different scripts to the IDE or tells melos to generate the necessary files for mono-repositories to work in the IDE.", 63 | "additionalProperties": false, 64 | "properties": { 65 | "intellij": { 66 | "description": "IntelliJ-specific configurations.", 67 | "anyOf": [ 68 | { 69 | "type": "boolean", 70 | "description": "Whether IntelliJ support is enabled." 71 | }, 72 | { 73 | "type": "object", 74 | "additionalProperties": false, 75 | "properties": { 76 | "enabled": { 77 | "type": "boolean", 78 | "description": "Whether IntelliJ support is enabled." 79 | }, 80 | "moduleNamePrefix": { 81 | "type": "string", 82 | "description": "Used when generating IntelliJ project modules files, this value specifies a string to prepend to a package's IntelliJ module name.\n\nThe default is 'melos_'." 83 | } 84 | } 85 | } 86 | ] 87 | } 88 | } 89 | }, 90 | "command": { 91 | "type": "object", 92 | "description": "Command-specific configurations.\n\nThis allows customizing the default behavior of melos commands.", 93 | "additionalProperties": false, 94 | "properties": { 95 | "bootstrap": { 96 | "type": "object", 97 | "description": "The bootstrap command configuration.", 98 | "additionalProperties": false, 99 | "properties": { 100 | "runPubGetInParallel": { 101 | "type": "boolean", 102 | "description": "Whether to run `pub get` in parallel during bootstrapping." 103 | }, 104 | "runPubGetOffline": { 105 | "type": "boolean", 106 | "description": "Whether to attempt to run `pub get` in offline mode during bootstrapping." 107 | }, 108 | "enforceLockfile": { 109 | "type": "boolean", 110 | "description": "Whether `pubspec.lock` is enforced when running `pub get` or not.\nUseful when you want to ensure the same versions of dependencies are used across different environments/machines.\n\nDefaults to false." 111 | }, 112 | "hooks": { 113 | "description": "Hooks to run at certain points of the command lifecycle.", 114 | "allOf": [{ "$ref": "#/$defs/commonHooks" }] 115 | }, 116 | "dependencyOverridePaths": { 117 | "type": "array", 118 | "description": "A list of paths to local packages relative to the workspace directory that should be added to each workspace package's dependency overrides. Each entry can be a specific path or a glob pattern.", 119 | "items": { 120 | "type": "string" 121 | } 122 | }, 123 | "dependencyOverrides": { 124 | "type": "object", 125 | "description": "Dependencies that should be added to each workspace package's dependency overrides." 126 | }, 127 | "environment": { 128 | "type": "object", 129 | "description": "Environment configuration to be synced between all packages." 130 | }, 131 | "dependencies": { 132 | "type": "object", 133 | "description": "Dependencies to be synced between all packages." 134 | }, 135 | "dev_dependencies": { 136 | "type": "object", 137 | "description": "Dev dependencies to be synced between all packages." 138 | } 139 | } 140 | }, 141 | "version": { 142 | "type": "object", 143 | "description": "The version command configuration.", 144 | "additionalProperties": false, 145 | "properties": { 146 | "message": { 147 | "type": "string", 148 | "description": "A custom header for the generated CHANGELOG.md." 149 | }, 150 | "includeScopes": { 151 | "type": "string", 152 | "description": "Whether to include conventional commit scopes in the generated CHANGELOG.md." 153 | }, 154 | "linkToCommits": { 155 | "type": "boolean", 156 | "description": "Whether to add links to commits in the generated CHANGELOG.md.\n\nDisabled by default." 157 | }, 158 | "workspaceChangelog": { 159 | "type": "boolean", 160 | "description": "Whether to additionally generate a summary CHANGELOG.md at the root of the workspace." 161 | }, 162 | "branch": { 163 | "type": "string", 164 | "description": "If specified, prevents `melos version` from being used inside branches other than the one specified." 165 | }, 166 | "updateGitTagRefs": { 167 | "type": "boolean", 168 | "description": "Whether to also update pubspec with git referenced packages." 169 | }, 170 | "releaseUrl": { 171 | "type": "boolean", 172 | "description": "Whether to generate and print a link to the prefilled release creation page for each package after versioning.\n\nDisabled by default." 173 | }, 174 | "fetchTags": { 175 | "type": "boolean", 176 | "description": "Whether to fetch tags from the origin remote before versioning.\n\nDefaults to true." 177 | }, 178 | "hooks": { 179 | "description": "Hooks to run at certain points of the command lifecycle.", 180 | "allOf": [ 181 | { "$id": "#/$defs/commonHooks" }, 182 | { 183 | "type": "object", 184 | "properties": { 185 | "preCommit": { "$ref": "#/$defs/script" } 186 | } 187 | } 188 | ] 189 | } 190 | } 191 | }, 192 | "clean": { 193 | "type": "object", 194 | "description": "The clean command configuration.", 195 | "additionalProperties": false, 196 | "properties": { 197 | "hooks": { 198 | "description": "Hooks to run at certain points of the command lifecycle.", 199 | "allOf": [{ "$ref": "#/$defs/commonHooks" }] 200 | } 201 | } 202 | } 203 | } 204 | }, 205 | "scripts": { 206 | "type": "object", 207 | "description": "A list of scripts that can be executed with `melos run` or will be executed before/after some specific melos commands.", 208 | "additionalProperties": { "$ref": "#/$defs/script" } 209 | } 210 | }, 211 | "$defs": { 212 | "script": { 213 | "anyOf": [ 214 | { "type": "string", "description": "The command to execute." }, 215 | { 216 | "allOf": [ 217 | { 218 | "anyOf": [ 219 | { 220 | "type": "object", 221 | "required": ["run"], 222 | "properties": { "run": { "type": "string" } } 223 | }, 224 | { 225 | "type": "object", 226 | "required": ["exec"], 227 | "properties": { "exec": { "type": "string" } } 228 | }, 229 | { 230 | "type": "object", 231 | "required": ["exec", "run"], 232 | "properties": { "exec": { "type": "object" } } 233 | } 234 | ] 235 | }, 236 | { 237 | "type": "object", 238 | "description": "The script definition.", 239 | "additionalProperties": false, 240 | "properties": { 241 | "name": { 242 | "type": "string", 243 | "description": "A unique identifier for the script." 244 | }, 245 | "description": { 246 | "type": "string", 247 | "description": "A short description, shown when using `melos run` with no argument." 248 | }, 249 | "run": { 250 | "type": "string", 251 | "description": "The command to execute." 252 | }, 253 | "exec": { 254 | "anyOf": [ 255 | { 256 | "type": "string", 257 | "description": "The command to execute in multiple packages." 258 | }, 259 | { 260 | "type": "object", 261 | "description": "The options to pass to `exec`.", 262 | "properties": { 263 | "concurrency": { 264 | "type": "number", 265 | "description": "The number of packages to execute the command in concurrently." 266 | }, 267 | "failFast": { 268 | "type": "boolean", 269 | "description": "Whether exec should fail fast and not execute the script in further packages if the script fails in a individual package." 270 | } 271 | } 272 | } 273 | ] 274 | }, 275 | "env": { 276 | "type": "object", 277 | "description": "Environment variables that will be passed to the command to execute.", 278 | "additionalProperties": { 279 | "anyOf": [ 280 | { "type": "string" }, 281 | { "type": "number" }, 282 | { "type": "integer" }, 283 | { "type": "boolean" }, 284 | { "type": "null" } 285 | ], 286 | "description": "The value of the environment variable." 287 | } 288 | }, 289 | "packageFilters": { 290 | "type": "object", 291 | "description": "If the command to execute is a melos command, allows filtering packages that will execute the command.", 292 | "additionalProperties": false, 293 | "properties": { 294 | "scope": { 295 | "description": "Patterns for including packages by name.", 296 | "anyOf": [ 297 | { 298 | "type": "string" 299 | }, 300 | { 301 | "type": "array", 302 | "items": { "type": "string" } 303 | } 304 | ] 305 | }, 306 | "ignore": { 307 | "description": "Patterns for excluding packages by name.", 308 | "anyOf": [ 309 | { 310 | "type": "string" 311 | }, 312 | { 313 | "type": "array", 314 | "items": { "type": "string" } 315 | } 316 | ] 317 | }, 318 | "dirExists": { 319 | "description": "Include a package only if a given directory exists.", 320 | "anyOf": [ 321 | { 322 | "type": "string" 323 | }, 324 | { 325 | "type": "array", 326 | "items": { "type": "string" } 327 | } 328 | ] 329 | }, 330 | "fileExists": { 331 | "description": "Include a package only if a given file exists.", 332 | "anyOf": [ 333 | { 334 | "type": "string" 335 | }, 336 | { 337 | "type": "array", 338 | "items": { "type": "string" } 339 | } 340 | ] 341 | }, 342 | "dependsOn": { 343 | "description": "Include only packages that depend on a specific package.", 344 | "anyOf": [ 345 | { 346 | "type": "string" 347 | }, 348 | { 349 | "type": "array", 350 | "items": { "type": "string" } 351 | } 352 | ] 353 | }, 354 | "noDependsOn": { 355 | "description": "Include only packages that do not depend on a specific package.", 356 | "anyOf": [ 357 | { 358 | "type": "string" 359 | }, 360 | { 361 | "type": "array", 362 | "items": { "type": "string" } 363 | } 364 | ] 365 | }, 366 | "diff": { 367 | "type": "string", 368 | "description": "Filter packages based on whether there were changes between a commit and the current HEAD or within a range of commits. A range of commits can be specified using the git short hand syntax `..` and `...." 369 | }, 370 | "private": { 371 | "type": "boolean", 372 | "description": "Include packages with `publish_to: none`." 373 | }, 374 | "noPrivate": { 375 | "type": "boolean", 376 | "description": "Exclude packages with `publish_to: none`." 377 | }, 378 | "published": { 379 | "type": "boolean", 380 | "description": "Include/Exclude packages that are up-to-date on pub.dev." 381 | }, 382 | "nullSafety": { 383 | "type": "boolean", 384 | "description": "Include/Exclude packages that are null-safe." 385 | }, 386 | "flutter": { 387 | "type": "boolean", 388 | "description": "Include/Exclude packages that are Flutter packages." 389 | } 390 | } 391 | } 392 | } 393 | } 394 | ] 395 | } 396 | ] 397 | }, 398 | "commonHooks": { 399 | "type": "object", 400 | "properties": { 401 | "pre": { "$ref": "#/$defs/script" }, 402 | "post": { "$ref": "#/$defs/script" } 403 | } 404 | } 405 | } 406 | } 407 | --------------------------------------------------------------------------------