├── src ├── handler.ts ├── watcher.ts ├── cli │ ├── index.ts │ ├── options.ts │ └── main.ts ├── store │ ├── selectors.ts │ ├── middleware │ │ ├── resize.ts │ │ ├── log.ts │ │ ├── notify.ts │ │ ├── test.ts │ │ └── compile.ts │ ├── state.ts │ ├── index.ts │ ├── action.ts │ └── reducer.ts ├── lib │ ├── ansi.ts │ ├── lerna.ts │ ├── ansi.test.ts │ └── gitignore.ts ├── tester.ts ├── compiler.ts └── renderer.ts ├── .prettierrc.js ├── .eslintignore ├── index.js ├── lerna.json ├── examples ├── example-random-error │ ├── random-exit.js │ └── package.json ├── example-no-script │ └── package.json ├── example-always-error │ └── package.json └── example-sleep │ └── package.json ├── jest.config.js ├── greenkeeper.json ├── .eslintrc.js ├── LICENSE ├── .gitignore ├── .github └── workflows │ └── CI.yml ├── tsconfig.json ├── package.json └── README.md /src/handler.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("./dist/cli"); 3 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["examples/*"], 3 | "version": "0.0.0" 4 | } 5 | -------------------------------------------------------------------------------- /examples/example-random-error/random-exit.js: -------------------------------------------------------------------------------- 1 | if (Math.random() >= 0.5) { 2 | throw new Error("Some error occurred"); 3 | } 4 | -------------------------------------------------------------------------------- /examples/example-no-script/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-no-script", 3 | "version": "0.0.0", 4 | "private": true 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | collectCoverage: true, 5 | collectCoverageFrom: ["src/**/*"], 6 | testMatch: ["**/*.test.ts?(x)"] 7 | }; 8 | -------------------------------------------------------------------------------- /examples/example-random-error/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-random-error", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "node random-exit.js" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/example-always-error/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-always-error", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "echo stdout; echo stderr 1>&2; exit 1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/watcher.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | export function watch(dir: string) { 4 | const watcher = fs.watch(dir, { 5 | persistent: true, 6 | recursive: true 7 | }); 8 | 9 | return watcher; 10 | } 11 | -------------------------------------------------------------------------------- /greenkeeper.json: -------------------------------------------------------------------------------- 1 | { 2 | "groups": { 3 | "default": { 4 | "packages": [ 5 | "examples/example-always-error/package.json", 6 | "examples/example-random-error/package.json", 7 | "examples/example-sleep/package.json", 8 | "package.json" 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/example-sleep/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-sleep", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "sleep 1", 7 | "test": "sleep 1" 8 | }, 9 | "dependencies": { 10 | "example-random-error": "*", 11 | "example-always-error": "*" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | import { main } from "./main"; 2 | import { parse } from "./options"; 3 | 4 | const options = parse(); 5 | 6 | main(process.cwd(), { 7 | buildScript: options["build-script"], 8 | testScript: options["test-script"], 9 | runTests: options["run-test"], 10 | allowNotify: !options["no-notify"] 11 | }).catch(error => { 12 | console.error(error.stack); 13 | process.exit(1); 14 | }); 15 | -------------------------------------------------------------------------------- /src/store/selectors.ts: -------------------------------------------------------------------------------- 1 | import sortBy from "lodash.sortby"; 2 | import { State } from "./state"; 3 | 4 | export const getWidth = (state: State) => state.size.width; 5 | 6 | export const getHeight = (state: State) => state.size.height; 7 | 8 | export const getPackageMap = (state: State) => state.packages; 9 | 10 | export const getPackages = (state: State) => 11 | sortBy( 12 | Object.values(getPackageMap(state)), 13 | subState => subState.package.name 14 | ); 15 | -------------------------------------------------------------------------------- /src/store/middleware/resize.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from "redux"; 2 | import { State } from "../state"; 3 | import { Action, setSize } from "../action"; 4 | 5 | export const createMiddleware = ( 6 | tty: typeof process.stdout 7 | ): Middleware<{}, State> => store => { 8 | process.stdout.on("resize", () => { 9 | store.dispatch( 10 | setSize({ 11 | width: tty.columns!, 12 | height: tty.rows! 13 | }) 14 | ); 15 | }); 16 | 17 | return next => (action: Action) => { 18 | return next(action); 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/lib/ansi.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from "os"; 2 | import wrapANSI from "wrap-ansi"; 3 | 4 | export function head(str: string, n: number): string { 5 | return str 6 | .split(EOL) 7 | .slice(0, n) 8 | .join(EOL); 9 | } 10 | 11 | export function wordWrap(str: string, width: number): string { 12 | return wrapANSI(str, width, { hard: true, trim: false }); 13 | } 14 | 15 | export function headWordWrap(str: string, width: number, height: number) { 16 | return head(wordWrap(str, width), height); 17 | } 18 | 19 | export function countLines(str: string) { 20 | return (str.match(/\n/g) || "").length + 1; 21 | } 22 | -------------------------------------------------------------------------------- /src/store/state.ts: -------------------------------------------------------------------------------- 1 | // import { PackageGraphNode } from "@lerna/package-graph"; 2 | 3 | export type Package = { 4 | name: string; 5 | location: string; 6 | localDependents: Map; 7 | }; 8 | 9 | export type SubState = { 10 | ready: boolean; 11 | buildQueued: boolean; 12 | testQueued: boolean; 13 | buildBusy: boolean; 14 | testBusy: boolean; 15 | error: Error | null; 16 | logPath: string; 17 | package: Package; 18 | }; 19 | 20 | export type State = { 21 | size: { 22 | width: number; 23 | height: number; 24 | }; 25 | packages: { 26 | [dir: string]: SubState; 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/store/middleware/log.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { Middleware } from "redux"; 3 | import { State } from "../state"; 4 | import { Action } from "../action"; 5 | import { getPackageMap } from "../../store/selectors"; 6 | 7 | export const createMiddleware = (): Middleware<{}, State> => store => next => ( 8 | action: Action 9 | ) => { 10 | switch (action.type) { 11 | case "TEST_COMPLETED": 12 | case "COMPILE_COMPLETED": { 13 | const nextAction = next(action); 14 | const { logPath, error } = getPackageMap(store.getState())[action.dir]; 15 | fs.writeFileSync(logPath, error ? error.message : ""); 16 | 17 | return nextAction; 18 | } 19 | } 20 | 21 | return next(action); 22 | }; 23 | -------------------------------------------------------------------------------- /src/tester.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import execa from "execa"; 3 | 4 | type Options = { 5 | scriptName: string; 6 | }; 7 | 8 | export class Tester { 9 | scriptName: string; 10 | 11 | constructor(options: Options) { 12 | this.scriptName = options.scriptName; 13 | } 14 | 15 | async test(cwd: string): Promise { 16 | if (!(await this.shouldRun(cwd))) { 17 | return; 18 | } 19 | try { 20 | await execa("npm", ["run", this.scriptName], { 21 | cwd 22 | }); 23 | } catch (e) { 24 | throw new Error(`[exitCode:${e.exitCode}] ${e.stderr}`); 25 | } 26 | } 27 | 28 | private async shouldRun(cwd: string) { 29 | const pkg = require(path.join(cwd, "package.json")); 30 | return pkg.scripts && !!pkg.scripts[this.scriptName]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/compiler.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import execa from "execa"; 3 | 4 | type Options = { 5 | scriptName: string; 6 | }; 7 | 8 | export class Compiler { 9 | scriptName: string; 10 | 11 | constructor(options: Options) { 12 | this.scriptName = options.scriptName; 13 | } 14 | 15 | async compile(cwd: string): Promise { 16 | if (!(await this.shouldRun(cwd))) { 17 | return; 18 | } 19 | try { 20 | await execa("npm", ["run", this.scriptName], { 21 | cwd, 22 | all: true, 23 | }); 24 | } catch (e) { 25 | throw new Error(`[exitCode:${e.exitCode}] ${e.all}`); 26 | } 27 | } 28 | 29 | private async shouldRun(cwd: string) { 30 | const pkg = require(path.join(cwd, "package.json")); 31 | return pkg.scripts && !!pkg.scripts[this.scriptName]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/lerna.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { sync as findUp } from "find-up"; 3 | import { Package } from "../store/state"; 4 | 5 | export function getRootDir(cwd: string): string | null { 6 | const dir = findUp("lerna.json", { cwd }); 7 | return dir ? path.dirname(dir) : null; 8 | } 9 | 10 | export async function getLernaPackages(rootDir: string): Promise { 11 | // @ts-ignore peerDependencies 12 | const { default: Project } = await import( 13 | path.join(rootDir, "node_modules", "@lerna/project") 14 | ); 15 | // @ts-ignore peerDependencies 16 | const { default: PackageGraph } = await import( 17 | path.join(rootDir, "node_modules", "@lerna/package-graph") 18 | ); 19 | 20 | const project = new Project(rootDir); 21 | const packages = await project.getPackages(); 22 | const graph = new PackageGraph(packages); 23 | 24 | return Array.from(graph.values()); 25 | } 26 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | project: "./tsconfig.json", 6 | ecmaVersion: 2019, 7 | sourceType: "module" 8 | }, 9 | plugins: ["@typescript-eslint", "prettier"], 10 | extends: [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:import/errors", 15 | "plugin:import/warnings", 16 | "plugin:import/typescript", 17 | "prettier/@typescript-eslint" 18 | ], 19 | rules: { 20 | "import/default": "off", 21 | "import/named": "off", 22 | "@typescript-eslint/no-non-null-assertion": "off", 23 | "@typescript-eslint/explicit-function-return-type": "off", 24 | "@typescript-eslint/ban-ts-ignore": "off", 25 | "@typescript-eslint/no-var-requires": "off", 26 | "node/no-unsupported-features/es-syntax": "off" 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/store/middleware/notify.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import notifier from "node-notifier"; 3 | import { Middleware } from "redux"; 4 | import { State } from "../state"; 5 | import { Action } from "../action"; 6 | 7 | export const createMiddleware = ({ 8 | allowNotify 9 | }: { 10 | allowNotify: boolean; 11 | }): Middleware<{}, State> => () => { 12 | return next => (action: Action) => { 13 | if (!allowNotify) { 14 | return next(action); 15 | } 16 | 17 | if (action.type === "COMPILE_COMPLETED") { 18 | notifier.notify({ 19 | title: action.error ? "Build failed" : "Build successful", 20 | message: path.relative(process.cwd(), action.dir) 21 | }); 22 | } 23 | if (action.type === "TEST_COMPLETED") { 24 | notifier.notify({ 25 | title: action.error ? "Test failed" : "Test successful", 26 | message: path.relative(process.cwd(), action.dir) 27 | }); 28 | } 29 | return next(action); 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/lib/ansi.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { head, wordWrap, countLines } from "./ansi"; 3 | 4 | describe("head", () => { 5 | it("returns string that specified number of lines", () => { 6 | const expected = "a\n".repeat(4).trim(); 7 | const actual = head("a\n".repeat(5), 4); 8 | assert.strictEqual(actual, expected); 9 | }); 10 | }); 11 | 12 | describe("wordWrap", () => { 13 | it("returns string that wrapped at specified width", () => { 14 | const expected = "a".repeat(5) + "\na"; 15 | const actual = wordWrap("a".repeat(6), 5); 16 | assert.strictEqual(actual, expected); 17 | }); 18 | }); 19 | 20 | describe("countLines", () => { 21 | it("returns number of lines from the string", () => { 22 | const expected = 6; 23 | const actual = countLines("a\n".repeat(5)); 24 | assert.strictEqual(actual, expected); 25 | }); 26 | it("case without EOL", () => { 27 | const expected = 1; 28 | const actual = countLines("a".repeat(5)); 29 | assert.strictEqual(actual, expected); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Shingo Inoue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | .vscode 63 | dist/ 64 | -------------------------------------------------------------------------------- /src/lib/gitignore.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import ignore from "ignore"; 4 | 5 | type Ignore = ReturnType; 6 | 7 | function findUpRecursive(cwd: string, fileName: string): string[] { 8 | const paths: string[] = []; 9 | while (cwd !== "/") { 10 | const p = path.join(cwd, fileName); 11 | if (fs.existsSync(p)) { 12 | paths.push(p); 13 | } 14 | cwd = path.dirname(cwd); 15 | } 16 | return paths; 17 | } 18 | 19 | function findIgnoreFiles(cwd: string): string[] { 20 | return findUpRecursive(cwd, ".gitignore"); 21 | } 22 | 23 | export function getIgnore(cwd: string): Ignore { 24 | const rawIgnore = findIgnoreFiles(cwd) 25 | .map(p => fs.readFileSync(p, "utf8")) 26 | .reduce((ign, f) => ign.add(f), ignore()); 27 | 28 | return { 29 | ...rawIgnore, 30 | // Patch for "path should be a `path.relative()`d string" 31 | ignores(pathname: string): boolean { 32 | if (cwd === pathname) { 33 | return false; 34 | } 35 | 36 | const relative = path.isAbsolute(pathname) 37 | ? path.relative(cwd, pathname) 38 | : pathname; 39 | 40 | return rawIgnore.ignores(relative); 41 | } 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: pull_request 3 | 4 | jobs: 5 | lint: 6 | runs-on: ubuntu-18.04 7 | strategy: 8 | matrix: 9 | node-version: ['13.x', '12.x', '10.x'] 10 | steps: 11 | - uses: actions/checkout@master 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: ${{ matrix.node-version }} 15 | - uses: actions/cache@v1 16 | with: 17 | path: ~/.npm 18 | key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} 19 | - run: npm ci 20 | - run: npm run lint 21 | test: 22 | runs-on: ubuntu-18.04 23 | strategy: 24 | matrix: 25 | node-version: ['13.x', '12.x', '10.x'] 26 | steps: 27 | - uses: actions/checkout@master 28 | - uses: actions/setup-node@v1 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - uses: actions/cache@v1 32 | with: 33 | path: ~/.npm 34 | key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} 35 | - run: npm ci 36 | - run: npm run test 37 | - uses: codecov/codecov-action@v1 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | fail_ci_if_error: true 41 | -------------------------------------------------------------------------------- /src/cli/options.ts: -------------------------------------------------------------------------------- 1 | import yargs from "yargs"; 2 | // @ts-ignore 3 | import { name, version, repository } from "../../package.json"; 4 | 5 | const cmd = yargs 6 | .scriptName(name) 7 | .version(version) 8 | .option("build-script", { 9 | alias: "b", 10 | type: "string", 11 | default: "prepare", 12 | description: "Shell script to build your package" 13 | }) 14 | .option("test-script", { 15 | alias: "t", 16 | type: "string", 17 | default: "test", 18 | description: "Shell script to test your package" 19 | }) 20 | .option("run-test", { 21 | alias: "T", 22 | type: "boolean", 23 | default: false, 24 | description: "Run test when dependent packages changed" 25 | }) 26 | .option("no-notify", { 27 | alias: "N", 28 | type: "boolean", 29 | default: false, 30 | description: "Do not notify" 31 | }) 32 | .example("$0", "Run build only") 33 | .example("$0 -T", "Run build and test when dependent packages changed") 34 | .example('$0 -b "make build"', "Customize build script") 35 | .example('$0 -t "lint"', "Customize test script") 36 | .epilogue( 37 | `For more information, please visit our repository at:\nhttps://github.com/${repository}` 38 | ); 39 | 40 | export const parse = cmd.parse; 41 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore as createReduxStore, applyMiddleware } from "redux"; 2 | import { reducer } from "./reducer"; 3 | import { State } from "./state"; 4 | import { Action } from "./action"; 5 | import { Compiler } from "../compiler"; 6 | import { Tester } from "../tester"; 7 | import { createMiddleware as createCompileMiddleware } from "./middleware/compile"; 8 | import { createMiddleware as createTestMiddleware } from "./middleware/test"; 9 | import { createMiddleware as createLogMiddleware } from "./middleware/log"; 10 | import { createMiddleware as createResizeMiddleware } from "./middleware/resize"; 11 | import { createMiddleware as createNotifyMiddleware } from "./middleware/notify"; 12 | 13 | export function createStore( 14 | initialState: State, 15 | { 16 | compiler, 17 | tester, 18 | runTests, 19 | allowNotify, 20 | tty 21 | }: { 22 | compiler: Compiler; 23 | tester: Tester; 24 | runTests: boolean; 25 | allowNotify: boolean; 26 | tty: typeof process.stdout; 27 | } 28 | ) { 29 | const middlewares = [ 30 | createNotifyMiddleware({ allowNotify }), 31 | createResizeMiddleware(tty), 32 | createCompileMiddleware(compiler, { runTests }), 33 | createTestMiddleware(tester), 34 | createLogMiddleware() 35 | ]; 36 | 37 | return createReduxStore( 38 | reducer, 39 | initialState, 40 | applyMiddleware(...middlewares) 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 4 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 5 | // "lib": [] /* Specify library files to be included in the compilation. */, 6 | "declaration": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 7 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 8 | "sourceMap": true /* Generates corresponding '.map' file. */, 9 | "strict": true /* Enable all strict type-checking options. */, 10 | "noUnusedLocals": true /* Report errors on unused locals. */, 11 | "noUnusedParameters": true /* Report errors on unused parameters. */, 12 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 13 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 14 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 15 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 16 | }, 17 | "include": ["src/**/*.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /src/store/middleware/test.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from "redux"; 2 | import { State } from "../state"; 3 | import { Action, runTest, completeTest, enqueueTest } from "../action"; 4 | import { Tester } from "../../tester"; 5 | import { getPackageMap } from "../../store/selectors"; 6 | 7 | export const createMiddleware = ( 8 | tester: Tester 9 | ): Middleware<{}, State> => store => next => (action: Action) => { 10 | switch (action.type) { 11 | case "COMPILE_COMPLETED": { 12 | const nextAction = next(action); 13 | const { testQueued } = getPackageMap(store.getState())[action.dir]; 14 | if (testQueued) { 15 | store.dispatch(runTest(action.dir)); 16 | } 17 | return nextAction; 18 | } 19 | 20 | case "TEST_STARTED": { 21 | const { testBusy, buildBusy } = getPackageMap(store.getState())[ 22 | action.dir 23 | ]; 24 | if (testBusy || buildBusy) { 25 | store.dispatch(enqueueTest(action.dir)); 26 | return; 27 | } 28 | 29 | tester 30 | .test(action.dir) 31 | .then(() => store.dispatch(completeTest(action.dir, null))) 32 | .catch(error => store.dispatch(completeTest(action.dir, error))); 33 | return next(action); 34 | } 35 | 36 | case "TEST_COMPLETED": { 37 | const { testQueued } = getPackageMap(store.getState())[action.dir]; 38 | if (testQueued) { 39 | const nextAction = next(action); 40 | store.dispatch(runTest(action.dir)); 41 | return nextAction; 42 | } 43 | break; 44 | } 45 | } 46 | 47 | return next(action); 48 | }; 49 | -------------------------------------------------------------------------------- /src/store/action.ts: -------------------------------------------------------------------------------- 1 | import { Package } from "./state"; 2 | 3 | export const addPackage = (pkg: Package, logPath: string) => ({ 4 | type: "ADD_PACKAGE" as const, 5 | pkg, 6 | logPath 7 | }); 8 | 9 | export const makeReady = (dir: string) => ({ 10 | type: "MAKE_READY" as const, 11 | dir 12 | }); 13 | 14 | export const startCompile = (dir: string) => ({ 15 | type: "COMPILE_STARTED" as const, 16 | dir 17 | }); 18 | 19 | export const completeCompile = (dir: string, error: Error | null) => ({ 20 | type: "COMPILE_COMPLETED" as const, 21 | dir, 22 | error 23 | }); 24 | 25 | export const enqueueCompile = (dir: string) => ({ 26 | type: "COMPILE_QUEUED" as const, 27 | dir 28 | }); 29 | 30 | export const runTest = (dir: string) => ({ 31 | type: "TEST_STARTED" as const, 32 | dir 33 | }); 34 | 35 | export const completeTest = (dir: string, error: Error | null) => ({ 36 | type: "TEST_COMPLETED" as const, 37 | dir, 38 | error 39 | }); 40 | 41 | export const enqueueTest = (dir: string) => ({ 42 | type: "TEST_QUEUED" as const, 43 | dir 44 | }); 45 | 46 | export const setSize = ({ 47 | width, 48 | height 49 | }: { 50 | width: number; 51 | height: number; 52 | }) => ({ 53 | type: "RESIZED" as const, 54 | width, 55 | height 56 | }); 57 | 58 | export type Action = 59 | | ReturnType 60 | | ReturnType 61 | | ReturnType 62 | | ReturnType 63 | | ReturnType 64 | | ReturnType 65 | | ReturnType 66 | | ReturnType 67 | | ReturnType; 68 | -------------------------------------------------------------------------------- /src/store/middleware/compile.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from "redux"; 2 | import { State } from "../state"; 3 | import { 4 | Action, 5 | startCompile, 6 | completeCompile, 7 | enqueueCompile, 8 | runTest 9 | } from "../action"; 10 | import { Compiler } from "../../compiler"; 11 | import { getPackageMap } from "../../store/selectors"; 12 | 13 | export const createMiddleware = ( 14 | compiler: Compiler, 15 | { 16 | runTests 17 | }: { 18 | runTests: boolean; 19 | } 20 | ): Middleware<{}, State> => store => next => (action: Action) => { 21 | switch (action.type) { 22 | case "COMPILE_STARTED": { 23 | const { buildBusy } = getPackageMap(store.getState())[action.dir]; 24 | if (buildBusy) { 25 | store.dispatch(enqueueCompile(action.dir)); 26 | return; 27 | } 28 | 29 | compiler 30 | .compile(action.dir) 31 | .then(() => { 32 | store.dispatch(completeCompile(action.dir, null)); 33 | if (!runTests) { 34 | return; 35 | } 36 | const { package: pkg } = getPackageMap(store.getState())[action.dir]; 37 | const dependents = Array.from( 38 | pkg.localDependents.values(), 39 | pkg => pkg.location 40 | ); 41 | for (const dependent of dependents) { 42 | store.dispatch(runTest(dependent)); 43 | } 44 | }) 45 | .catch(error => store.dispatch(completeCompile(action.dir, error))); 46 | return next(action); 47 | } 48 | 49 | case "COMPILE_COMPLETED": { 50 | const { buildQueued } = getPackageMap(store.getState())[action.dir]; 51 | if (buildQueued) { 52 | const nextAction = next(action); 53 | store.dispatch(startCompile(action.dir)); 54 | return nextAction; 55 | } 56 | } 57 | } 58 | 59 | return next(action); 60 | }; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monow", 3 | "version": "0.4.0", 4 | "description": "Zero configuration monorepo watcher", 5 | "bin": "index.js", 6 | "files": [ 7 | "index.js", 8 | "dist" 9 | ], 10 | "scripts": { 11 | "prepare": "rm -rf dist && npm-run-all build:*", 12 | "build:cjs": "tsc --module commonjs --target es2017 --outDir dist", 13 | "test": "jest", 14 | "lint": "eslint src/** --ext .ts,.tsx", 15 | "lint:fix": "yarn lint --fix", 16 | "format": "yarn prettier" 17 | }, 18 | "repository": "Leko/monow", 19 | "author": "Leko ", 20 | "license": "MIT", 21 | "peerDependencies": { 22 | "lerna": ">= 3.0.0" 23 | }, 24 | "dependencies": { 25 | "chalk": "^3.0.0", 26 | "execa": "~3.4.0", 27 | "figures": "^3.1.0", 28 | "find-up": "~4.1.0", 29 | "ignore": "~5.1.4", 30 | "immer": "~5.1.0", 31 | "lodash.sortby": "~4.7.0", 32 | "log-update": "~3.3.0", 33 | "node-notifier": "~6.0.0", 34 | "redux": "~4.0.4", 35 | "string-width": "~4.2.0", 36 | "terminal-link": "~2.1.0", 37 | "tmp": "~0.1.0", 38 | "wrap-ansi": "~6.2.0", 39 | "yargs": "~15.0.2" 40 | }, 41 | "devDependencies": { 42 | "@types/execa": "~2.0.0", 43 | "@types/find-up": "~4.0.0", 44 | "@types/jest": "~24.0.23", 45 | "@types/lodash.sortby": "~4.7.6", 46 | "@types/node-notifier": "~5.4.0", 47 | "@types/tmp": "~0.1.0", 48 | "@types/wrap-ansi": "~3.0.0", 49 | "@types/yargs": "~13.0.3", 50 | "jest": "~24.9.0", 51 | "lerna": "~3.20.0", 52 | "npm-run-all": "~4.1.5", 53 | "ts-jest": "~24.2.0", 54 | "ts-node": "~8.5.4", 55 | "typescript": "~3.7.3", 56 | "eslint": "~6.8.0", 57 | "@typescript-eslint/parser": "~2.13.0", 58 | "@typescript-eslint/eslint-plugin": "~2.13.0", 59 | "prettier": "~1.19.1", 60 | "eslint-plugin-import": "~2.19.1", 61 | "eslint-config-prettier": "~6.9.0", 62 | "eslint-plugin-prettier": "~3.1.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/store/reducer.ts: -------------------------------------------------------------------------------- 1 | import { produce, Draft } from "immer"; 2 | import { State } from "./state"; 3 | import { Action } from "./action"; 4 | 5 | const initialState: State = { 6 | size: { 7 | width: -1, 8 | height: -1 9 | }, 10 | packages: {} 11 | }; 12 | 13 | function reduce(draft: Draft, action: Action) { 14 | switch (action.type) { 15 | case "ADD_PACKAGE": 16 | draft.packages[action.pkg.location] = { 17 | package: action.pkg, 18 | logPath: action.logPath, 19 | ready: false, 20 | buildBusy: false, 21 | buildQueued: false, 22 | testBusy: false, 23 | testQueued: false, 24 | error: null 25 | }; 26 | break; 27 | case "MAKE_READY": 28 | draft.packages[action.dir].ready = true; 29 | break; 30 | case "COMPILE_STARTED": 31 | draft.packages[action.dir].buildBusy = true; 32 | draft.packages[action.dir].buildQueued = false; 33 | draft.packages[action.dir].error = null; 34 | break; 35 | case "COMPILE_COMPLETED": 36 | draft.packages[action.dir].buildBusy = false; 37 | draft.packages[action.dir].error = action.error; 38 | break; 39 | case "COMPILE_QUEUED": 40 | draft.packages[action.dir].buildQueued = true; 41 | break; 42 | case "TEST_STARTED": 43 | draft.packages[action.dir].testBusy = true; 44 | draft.packages[action.dir].testQueued = false; 45 | draft.packages[action.dir].error = null; 46 | break; 47 | case "TEST_COMPLETED": 48 | draft.packages[action.dir].testBusy = false; 49 | draft.packages[action.dir].error = action.error; 50 | break; 51 | case "TEST_QUEUED": 52 | draft.packages[action.dir].testQueued = true; 53 | break; 54 | case "RESIZED": { 55 | draft.size.width = action.width; 56 | draft.size.height = action.height; 57 | break; 58 | } 59 | } 60 | return draft; 61 | } 62 | 63 | export function reducer(state: State = initialState, action: Action): State { 64 | return produce(state, draft => reduce(draft, action)); 65 | } 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # monow 2 | 3 | ![](https://img.shields.io/npm/v/monow.svg) 4 | [![CircleCI](https://circleci.com/gh/Leko/monow.svg?style=svg)](https://circleci.com/gh/Leko/monow) 5 | [![Greenkeeper badge](https://badges.greenkeeper.io/Leko/monow.svg)](https://greenkeeper.io/) 6 | [![codecov](https://codecov.io/gh/Leko/monow/branch/master/graph/badge.svg)](https://codecov.io/gh/Leko/monow) 7 | ![](https://img.shields.io/npm/dm/monow.svg) 8 | ![](https://img.shields.io/npm/l/monow.svg) 9 | 10 | :clap: Zero configuration monorepo watcher 11 | 12 | ![render1554649008364-min](https://user-images.githubusercontent.com/1424963/55685438-3670c180-5991-11e9-87cc-37729a06f107.gif) 13 | 14 | - Currently `monow` is under active development 15 | - Currently `monow` supports [lerna](https://github.com/lerna/lerna) only 16 | 17 | ## Install 18 | 19 | ``` 20 | npm install -g monow 21 | ``` 22 | 23 | Or you can use monow with `npx` without `npx install`. 24 | 25 | ``` 26 | npx monow 27 | ``` 28 | 29 | ## Usage 30 | 31 | ``` 32 | Options: 33 | --help Show help [boolean] 34 | --version Show version number [boolean] 35 | --build-script, -b Shell script to build your package 36 | [string] [default: "prepare"] 37 | --test-script, -t Shell script to test your package 38 | [string] [default: "test"] 39 | --run-test, -T Run test when dependent packages changed 40 | [boolean] [default: false] 41 | --no-notify, -N Do not notify [boolean] [default: false] 42 | 43 | Examples: 44 | monow Run build only 45 | monow -T Run build and test when dependent packages changed 46 | monow -b "make build" Customize build script 47 | monow -t "lint" Customize test script 48 | ``` 49 | 50 | ## Contribution 51 | 52 | 1. Fork this repository 53 | 1. Write your code 54 | 1. Run tests 55 | 1. Create pull request to master branch 56 | 57 | ## Development 58 | 59 | ``` 60 | git clone git@github.com:Leko/monow.git 61 | cd monow 62 | npm i 63 | 64 | npx ts-node -T src/cli/index.ts # debug 65 | ``` 66 | 67 | ## License 68 | 69 | This package under [MIT](https://opensource.org/licenses/MIT) license. 70 | -------------------------------------------------------------------------------- /src/cli/main.ts: -------------------------------------------------------------------------------- 1 | import { FSWatcher } from "fs"; 2 | import tmp from "tmp"; 3 | import { createStore } from "../store"; 4 | import { getPackages } from "../store/selectors"; 5 | import { watch } from "../watcher"; 6 | import { createRenderer } from "../renderer"; 7 | import { getIgnore } from "../lib/gitignore"; 8 | import { getRootDir, getLernaPackages } from "../lib/lerna"; 9 | import { reducer } from "../store/reducer"; 10 | import * as actions from "../store/action"; 11 | import { Compiler } from "../compiler"; 12 | import { Tester } from "../tester"; 13 | 14 | type Options = { 15 | buildScript: string; 16 | testScript: string; 17 | runTests: boolean; 18 | allowNotify: boolean; 19 | }; 20 | 21 | async function getStore(rootDir: string, options: Options) { 22 | const tty = process.stdout; 23 | const compiler = new Compiler({ scriptName: options.buildScript }); 24 | const tester = new Tester({ scriptName: options.testScript }); 25 | const lernaPackages = await getLernaPackages(rootDir); 26 | 27 | const initialState = lernaPackages.reduce( 28 | (state, pkg) => { 29 | const { name: logPath } = tmp.fileSync({ 30 | template: `.monow-XXXXXX.log` 31 | }); 32 | return reducer(state, actions.addPackage(pkg, logPath)); 33 | }, 34 | { 35 | size: { 36 | width: tty.columns!, 37 | height: tty.rows! 38 | }, 39 | packages: {} 40 | } 41 | ); 42 | 43 | return createStore(initialState, { 44 | compiler, 45 | tty, 46 | runTests: options.runTests, 47 | allowNotify: options.allowNotify, 48 | tester 49 | }); 50 | } 51 | 52 | export async function main(cwd: string, options: Options) { 53 | const rootDir = getRootDir(cwd); 54 | if (!rootDir) { 55 | throw new Error("Cannot find lerna.json"); 56 | } 57 | 58 | tmp.setGracefulCleanup(); 59 | 60 | const store = await getStore(rootDir, options); 61 | store.subscribe(createRenderer(store)); 62 | 63 | const packages = getPackages(store.getState()); 64 | if (packages.length === 0) { 65 | console.log(`package not found in ${rootDir}`); 66 | } 67 | 68 | const watchers: FSWatcher[] = []; 69 | process.on("SIGINT", () => { 70 | watchers.forEach(w => w.close()); 71 | }); 72 | for (const { package: pkg } of packages) { 73 | const ignore = getIgnore(pkg.location); 74 | const watcher = watch(pkg.location); 75 | watchers.push(watcher); 76 | watcher.on("change", (_, filename: string) => { 77 | if (ignore.ignores(filename)) { 78 | return; 79 | } 80 | store.dispatch(actions.startCompile(pkg.location)); 81 | }); 82 | watcher.on("error", (error: Error) => { 83 | store.dispatch(actions.completeCompile(pkg.location, error)); 84 | }); 85 | store.dispatch(actions.makeReady(pkg.location)); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | import { EOL } from "os"; 2 | import { Store } from "redux"; 3 | import figures from "figures"; 4 | import logUpdate from "log-update"; 5 | import terminalLink from "terminal-link"; 6 | import stringWidth from "string-width"; 7 | import chalk from "chalk"; 8 | import { headWordWrap, wordWrap, countLines } from "./lib/ansi"; 9 | import { SubState, State } from "./store/state"; 10 | import { Action } from "./store/action"; 11 | import { getPackages, getWidth, getHeight } from "./store/selectors"; 12 | 13 | type Props = { 14 | width: number; 15 | height: number; 16 | packages: SubState[]; 17 | }; 18 | 19 | function renderIndicator({ 20 | indicator, 21 | ready, 22 | buildBusy, 23 | testBusy, 24 | error 25 | }: SubState & { indicator: string }): string { 26 | if (error) { 27 | return chalk.red(indicator); 28 | } 29 | if (!ready) { 30 | return chalk.dim(indicator); 31 | } 32 | if (buildBusy || testBusy) { 33 | return chalk.yellow(indicator); 34 | } 35 | return chalk.green(indicator); 36 | } 37 | 38 | function renderStatus({ 39 | error, 40 | buildBusy, 41 | testBusy, 42 | buildQueued, 43 | testQueued, 44 | package: pkg 45 | }: SubState): string { 46 | if (error) { 47 | return chalk.red(pkg.name); 48 | } 49 | if (buildBusy || testBusy) { 50 | if (buildQueued || testQueued) { 51 | return `${pkg.name} (queued)`; 52 | } 53 | return chalk.dim(pkg.name); 54 | } 55 | return pkg.name; 56 | } 57 | 58 | function renderLogPath({ error, logPath }: SubState): string { 59 | const link = terminalLink.isSupported 60 | ? terminalLink(logPath, `file://${logPath}`) 61 | : logPath; 62 | return error ? `(saved to ${link})` : ""; 63 | } 64 | 65 | function renderError({ error }: SubState): string { 66 | return error ? error.message.trim() || "" : ""; 67 | } 68 | 69 | function renderDivider({ 70 | title, 71 | width, 72 | char = "-", 73 | padChar = " ", 74 | padding = 1, 75 | numOfHeadChars = 3 76 | }: { 77 | title: string; 78 | width: number; 79 | char?: string; 80 | padding?: number; 81 | padChar?: string; 82 | numOfHeadChars?: number; 83 | }): string { 84 | const headChars = char.repeat(numOfHeadChars); 85 | const padChars = padChar.repeat(padding); 86 | const headCharsWithTitle = wordWrap( 87 | headChars + padChars + title + padChars, 88 | width 89 | ); 90 | const lastLine = headCharsWithTitle 91 | .split(EOL) 92 | .slice(-1) 93 | .join(EOL); 94 | const restWidth = width - stringWidth(lastLine); 95 | const tailChars = char.repeat(restWidth); 96 | 97 | return headCharsWithTitle + tailChars; 98 | } 99 | 100 | function renderErrorSummary({ 101 | lines, 102 | width, 103 | ...subState 104 | }: SubState & { width: number; lines: number }): string { 105 | const { package: pkg } = subState; 106 | const divider = renderDivider({ title: `Error: ${pkg.name}`, width }); 107 | const separator = EOL + divider + EOL; 108 | const separatorLines = countLines(separator); 109 | const log = headWordWrap( 110 | renderError(subState), 111 | width, 112 | lines - separatorLines 113 | ); 114 | 115 | return separator + chalk.red(log); 116 | } 117 | 118 | export function render(props: Props): string { 119 | const { width, height, packages } = props; 120 | 121 | const lines = packages 122 | .map(subState => ({ 123 | indicator: renderIndicator({ ...subState, indicator: figures.bullet }), 124 | status: renderStatus(subState), 125 | logPath: renderLogPath(subState) 126 | })) 127 | .map(({ indicator, status, logPath }) => { 128 | return `${indicator} ${status} ${logPath}`; 129 | }) 130 | .map(line => wordWrap(line, width)); 131 | 132 | const linesCount = lines.reduce((acc, line) => acc + countLines(line), 0); 133 | const restLines = height - linesCount; 134 | const erroredPackages = packages.filter(({ error }) => !!error); 135 | const linesPerError = Math.floor(restLines / erroredPackages.length); 136 | const errorSummaries = erroredPackages.map(subState => 137 | renderErrorSummary({ width, lines: linesPerError, ...subState }) 138 | ); 139 | 140 | return lines.concat(errorSummaries).join(EOL); 141 | } 142 | 143 | export const createRenderer = (store: Store) => () => { 144 | const state = store.getState(); 145 | 146 | logUpdate( 147 | render({ 148 | width: getWidth(state), 149 | height: getHeight(state), 150 | packages: getPackages(state) 151 | }) 152 | ); 153 | }; 154 | --------------------------------------------------------------------------------