yarn-changed-workspaces
Advanced tools
Comparing version 1.0.0 to 2.0.0
#!/usr/bin/env node | ||
const yargs = require("yargs"); | ||
const path = require("path"); | ||
const { resolve, relative } = require("path"); | ||
const { format } = require("util"); | ||
const chalk = require("chalk"); | ||
const { getChangedWorkspacesIds } = require("../src/getChangedWorkspacesIds"); | ||
@@ -12,2 +13,11 @@ | ||
const log = (fmt, ...args) => { | ||
console.log(format(fmt, ...args)); | ||
}; | ||
const print = (color, fmt, ...args) => { | ||
const msg = format(fmt, ...args); | ||
return console.log(process.env.CI ? msg : chalk.white[color](msg)); | ||
}; | ||
const options = yargs | ||
@@ -17,3 +27,3 @@ .option("projectRoot", { | ||
required: true, | ||
default: path.resolve(process.cwd()), | ||
default: resolve(process.cwd()), | ||
type: "string", | ||
@@ -26,24 +36,34 @@ normalize: true, | ||
}) | ||
.option("naming", { | ||
alias: "c", | ||
.option("keyNaming", { | ||
alias: "k", | ||
choices: ["snakeCase", "camelCase"], | ||
}) | ||
.option("pattern") | ||
.option("filter", { | ||
choices: ["exclude", "include"], | ||
default: "include", | ||
}) | ||
.option("namespace", { | ||
alias: "n", | ||
type: "string", | ||
coerce: (str) => str + "/", | ||
coerce: (str) => str, | ||
}).argv; | ||
(async () => { | ||
const changedWorkspaces = await getChangedWorkspacesIds(options); | ||
if (changedWorkspaces.length <= 0) { | ||
return console.info("No changed workspases"); | ||
const cwd = process.cwd(); | ||
const workspaces = Object.entries(await getChangedWorkspacesIds(options)); | ||
if (workspaces.length <= 0) { | ||
const msg = "No changes found in workspaces"; | ||
if (process.env.CI) return console.info(msg); | ||
return console.info(msg); | ||
} | ||
console.info(format("Changed workspaces: %s", changedWorkspaces.length)); | ||
changedWorkspaces.forEach((id) => console.info(id)); | ||
console.info( | ||
workspaces | ||
.map(([id, files]) => | ||
[ | ||
id, | ||
...[...files].sort().map((filePath) => { | ||
const path = relative(cwd, filePath); | ||
if (process.env.CI) return " " + path; | ||
return " " + chalk.green(path); | ||
}), | ||
].join("\n") | ||
) | ||
.join("\n") | ||
); | ||
})(); |
{ | ||
"name": "yarn-changed-workspaces", | ||
"version": "1.0.0", | ||
"version": "2.0.0", | ||
"main": "./index.js", | ||
@@ -16,2 +16,3 @@ "bin": "./bin/cli.js", | ||
"dependencies": { | ||
"chalk": "^4.1.0", | ||
"glob": "^7.1.6", | ||
@@ -18,0 +19,0 @@ "lodash": "^4.17.20", |
# yarn-changed-workspaces | ||
This small utility helps to identify changed workspaces in `yarn` monorepo pattern. | ||
A small utility tool to be used in CI/CD pipelines along with `git` to trigger dependent libraries' workflows in a monorepo pattern. | ||
## Usage | ||
## Install | ||
``` | ||
yarn global add yarn-changed-workspaces | ||
yarn-changed-workspaces | ||
``` | ||
## CLI | ||
``` | ||
yarn-changed-workspaces --help | ||
``` | ||
## Node.js | ||
`./package.json` | ||
```json | ||
{ | ||
"workspaces": ["packages/*"] | ||
} | ||
``` | ||
```js | ||
const getChangedWorkspaces = require("yarn-changed-workspaces"); | ||
(async () => { | ||
const workspaces = await getChangedWorkspaces({ | ||
branch: "master", | ||
projectRoot: process.cwd(), | ||
}); | ||
console.log("changes", workspaces); | ||
})(); | ||
``` | ||
## Control scope of change | ||
`./package.json` | ||
```json | ||
{ | ||
"workspaces": ["packages/*"] | ||
} | ||
``` | ||
`./packages/app/package.json` | ||
```json | ||
{ | ||
"workspace": { | ||
"files": ["!**/*.(test|spec).(j|t)s(x)?"] | ||
} | ||
} | ||
``` | ||
## Limitation | ||
`git` is the core diffing tool. **This library will not work if you use a differnt distributed version-control system** for tracking changes in source code during software development |
@@ -1,11 +0,14 @@ | ||
const filterWorkspaces = ({ workspaces, files }) => { | ||
return [ | ||
...workspaces.reduce((items, { id, path }) => { | ||
const isChanged = files.some((fp) => fp.startsWith(path)); | ||
if (isChanged) items.add(id); | ||
return items; | ||
}, new Set()), | ||
]; | ||
const { relative } = require("path"); | ||
const { all } = require("micromatch"); | ||
const filterWorkspaces = ({ workspace, files }) => { | ||
return files.filter((filePath) => { | ||
const { path, config = {} } = workspace; | ||
if (!filePath.startsWith(path)) return false; | ||
if (!config.files) return true; | ||
const relativePath = relative(path, filePath); | ||
return all(relativePath, config.files); | ||
}); | ||
}; | ||
exports.filterWorkspaces = filterWorkspaces; |
const { filterWorkspaces } = require("./filterWorkspaces"); | ||
describe("filterWorkspaces", () => { | ||
test("it returns workspaces with matched files", () => { | ||
test("it selects files which match workspace path", () => { | ||
expect( | ||
filterWorkspaces({ | ||
workspaces: [{ id: "app1", path: "/app/packages/app1" }], | ||
files: ["/app/packages/app1/file1"], | ||
workspace: { id: "myapp", path: "/myapp" }, | ||
files: ["/myapp/src/myfile.js", "/test/myfile.js"], | ||
}) | ||
).toEqual(["app1"]); | ||
).toEqual(["/myapp/src/myfile.js"]); | ||
}); | ||
test("it does not return anything if files are not from the package", () => { | ||
test("it filters files based on config.files", () => { | ||
expect( | ||
filterWorkspaces({ | ||
workspaces: [{ id: "app1", path: "/app/packages/app1" }], | ||
files: ["/file1"], | ||
workspace: { | ||
id: "myapp", | ||
path: "/myapp", | ||
config: { files: ["!**/*.test.js", "**/*.js"] }, | ||
}, | ||
files: ["/myapp/src/myfile.js", "/myapp/src/mytest.test.js"], | ||
}) | ||
).toEqual([]); | ||
).toEqual(["/myapp/src/myfile.js"]); | ||
}); | ||
test("it returns unique ids", () => { | ||
expect( | ||
filterWorkspaces({ | ||
workspaces: [{ id: "app1", path: "/app1" }], | ||
files: ["/app1/file1", "/app1/file2"], | ||
}) | ||
).toEqual(["app1"]); | ||
}); | ||
}); |
@@ -21,2 +21,3 @@ const { join } = require("path"); | ||
path, | ||
config: pkg.workspace, | ||
dependencies: Object.keys({ | ||
@@ -23,0 +24,0 @@ ...pkg.dependencies, |
@@ -28,3 +28,3 @@ const { promises: fs } = require("fs"); | ||
_glob.mockImplementationOnce((_, cb) => | ||
cb(null, ["/app/packages/app1", "/app/packages/file1"]) | ||
cb(null, ["/app/packages/workspace", "/app/packages/file"]) | ||
); | ||
@@ -38,4 +38,4 @@ fs.stat.mockImplementationOnce(() => ({ | ||
readJSONFile.mockImplementationOnce(() => ({ | ||
name: "app1", | ||
path: "/app/packages/app1", | ||
name: "workspace", | ||
path: "/app/packages/workspace", | ||
dependencies: { dependencies: "" }, | ||
@@ -51,4 +51,4 @@ devDependencies: { devDependencies: "" }, | ||
{ | ||
id: "app1", | ||
path: "/app/packages/app1", | ||
id: "workspace", | ||
path: "/app/packages/workspace", | ||
dependencies: [ | ||
@@ -64,5 +64,5 @@ "dependencies", | ||
expect(readJSONFile).toHaveBeenCalledWith( | ||
"/app/packages/app1/package.json" | ||
"/app/packages/workspace/package.json" | ||
); | ||
}); | ||
}); |
@@ -10,15 +10,15 @@ const { getTouchedFiles } = require("./getTouchedFiles"); | ||
test("it returns unique files paths", async () => { | ||
getTouchedFiles.mockImplementationOnce(() => ["/app/file1"]); | ||
getTrackedFiles.mockImplementationOnce(() => ["/app/file1"]); | ||
getTouchedFiles.mockImplementationOnce(() => ["/myapp/myfile"]); | ||
getTrackedFiles.mockImplementationOnce(() => ["/myapp/myfile"]); | ||
await expect( | ||
getChangedFiles({ branch: "master", cwd: "/app" }) | ||
).resolves.toEqual(["/app/file1"]); | ||
getChangedFiles({ branch: "master", cwd: "/myapp" }) | ||
).resolves.toEqual(["/myapp/myfile"]); | ||
expect(getTouchedFiles).toHaveBeenCalledWith({ | ||
cwd: "/app", | ||
cwd: "/myapp", | ||
branch: "master", | ||
}); | ||
expect(getTrackedFiles).toHaveBeenCalledWith({ | ||
cwd: "/app", | ||
cwd: "/myapp", | ||
}); | ||
}); | ||
}); |
@@ -7,8 +7,3 @@ const { resolve } = require("path"); | ||
const getChangedWorkspaces = async ({ | ||
branch, | ||
projectRoot, | ||
pattern, | ||
filter, | ||
}) => { | ||
const getChangedWorkspaces = async ({ branch, projectRoot }) => { | ||
const path = resolve(projectRoot); | ||
@@ -19,10 +14,5 @@ const [workspaces, files] = await Promise.all([ | ||
]); | ||
return getTouchedDependencies({ | ||
files, | ||
filter, | ||
pattern, | ||
workspaces, | ||
}); | ||
return getTouchedDependencies({ files, workspaces }); | ||
}; | ||
exports.getChangedWorkspaces = getChangedWorkspaces; |
@@ -12,17 +12,11 @@ const { getChangedFiles } = require("./getChangedFiles"); | ||
test("it calls getWorkspaces", async () => { | ||
getTouchedDependencies.mockImplementationOnce(() => ["app1"]); | ||
await expect( | ||
getChangedWorkspaces({ branch: "master", projectRoot: "/app" }) | ||
).resolves.toEqual(["app1"]); | ||
expect(getWorkspaces).toHaveBeenCalledWith("/app"); | ||
await getChangedWorkspaces({ branch: "mybranch", projectRoot: "/" }); | ||
expect(getWorkspaces).toHaveBeenCalledWith("/"); | ||
}); | ||
test("it calls getChangedFiles", async () => { | ||
getTouchedDependencies.mockImplementationOnce(() => ["app1"]); | ||
await expect( | ||
getChangedWorkspaces({ branch: "master", projectRoot: "/app" }) | ||
).resolves.toEqual(["app1"]); | ||
await getChangedWorkspaces({ branch: "mybranch", projectRoot: "/" }); | ||
expect(getChangedFiles).toHaveBeenCalledWith({ | ||
cwd: "/app", | ||
branch: "master", | ||
cwd: "/", | ||
branch: "mybranch", | ||
}); | ||
@@ -33,27 +27,10 @@ }); | ||
getWorkspaces.mockImplementationOnce(() => ({ | ||
app1: { | ||
id: "app1", | ||
path: "/app/packages/app1", | ||
dependencies: [], | ||
}, | ||
myapp: { id: "myapp", path: "/packages/myapp", dependencies: [] }, | ||
})); | ||
getChangedFiles.mockImplementationOnce(() => ["/app/packages/app1/file1"]); | ||
getTouchedDependencies.mockImplementationOnce(() => ["app1"]); | ||
await expect( | ||
getChangedWorkspaces({ | ||
pattern: "*.js", | ||
filter: "include", | ||
projectRoot: "/app", | ||
}) | ||
).resolves.toEqual(["app1"]); | ||
getChangedFiles.mockImplementationOnce(() => ["/packages/myapp/myfile"]); | ||
await getChangedWorkspaces({ branch: "mybranch", projectRoot: "/" }); | ||
expect(getTouchedDependencies).toHaveBeenCalledWith({ | ||
files: ["/app/packages/app1/file1"], | ||
filter: "include", | ||
pattern: "*.js", | ||
files: ["/packages/myapp/myfile"], | ||
workspaces: { | ||
app1: { | ||
id: "app1", | ||
path: "/app/packages/app1", | ||
dependencies: [], | ||
}, | ||
myapp: { id: "myapp", path: "/packages/myapp", dependencies: [] }, | ||
}, | ||
@@ -60,0 +37,0 @@ }); |
@@ -5,10 +5,17 @@ const _ = require("lodash"); | ||
const getChangedWorkspacesIds = async (options) => { | ||
const { naming, namespace } = options; | ||
const formatName = (str) => (naming ? _[naming](str) : str); | ||
const getChangedWorkspacesIds = async ({ | ||
namespace, | ||
keyNaming, | ||
projectRoot, | ||
branch, | ||
}) => { | ||
const formatName = (str) => (keyNaming ? _[keyNaming](str) : str); | ||
const normalizeName = (str) => (namespace ? str.replace(namespace, "") : str); | ||
const workspaces = await getChangedWorkspaces(options); | ||
return [...workspaces].map((id) => formatName(normalizeName(id))).sort(); | ||
const workspaces = await getChangedWorkspaces({ branch, projectRoot }); | ||
return Object.entries(workspaces).reduce((obj, [id, files]) => { | ||
if (files.length <= 0) return obj; | ||
return { ...obj, [formatName(normalizeName(id))]: files }; | ||
}, {}); | ||
}; | ||
module.exports.getChangedWorkspacesIds = getChangedWorkspacesIds; |
@@ -8,19 +8,27 @@ const { getChangedWorkspaces } = require("./getChangedWorkspaces"); | ||
test("it removes namespaces", async () => { | ||
getChangedWorkspaces.mockImplementationOnce(() => ["nsp/app-1"]); | ||
getChangedWorkspaces.mockImplementationOnce(() => ({ | ||
"packages/app": ["packages/app/file"], | ||
})); | ||
await expect( | ||
getChangedWorkspacesIds({ namespace: "nsp/" }) | ||
).resolves.toEqual(["app-1"]); | ||
getChangedWorkspacesIds({ namespace: "packages/" }) | ||
).resolves.toEqual({ app: ["packages/app/file"] }); | ||
}); | ||
test("it applies a naming convention", async () => { | ||
getChangedWorkspaces.mockImplementationOnce(() => ["nsp/app-1"]); | ||
getChangedWorkspaces.mockImplementationOnce(() => ({ | ||
"packages/app": ["packages/app/file"], | ||
})); | ||
await expect( | ||
getChangedWorkspacesIds({ naming: "snakeCase" }) | ||
).resolves.toEqual(["nsp_app_1"]); | ||
getChangedWorkspacesIds({ keyNaming: "snakeCase" }) | ||
).resolves.toEqual({ packages_app: ["packages/app/file"] }); | ||
}); | ||
test("it sorts ASC", async () => { | ||
getChangedWorkspaces.mockImplementationOnce(() => ["foo", "bar"]); | ||
await expect(getChangedWorkspacesIds({})).resolves.toEqual(["bar", "foo"]); | ||
test("it returns empty list if no files changed", async () => { | ||
getChangedWorkspaces.mockImplementationOnce(() => ({ | ||
"packages/app": [], | ||
})); | ||
await expect( | ||
getChangedWorkspacesIds({ namespace: "packages/" }) | ||
).resolves.toEqual({}); | ||
}); | ||
}); |
@@ -1,22 +0,19 @@ | ||
const { filterFiles } = require("./filterFiles"); | ||
const { filterWorkspaces } = require("./filterWorkspaces"); | ||
const getTouchedDependencies = ({ workspaces, files, pattern, filter }) => { | ||
const visited = new Set(); | ||
const queue = filterWorkspaces({ | ||
files: filterFiles({ files, pattern, filter }), | ||
workspaces: Object.values(workspaces), | ||
}); | ||
while (queue.length > 0) { | ||
const id = queue.pop(); | ||
if (visited.has(id)) continue; | ||
const workspaceA = workspaces[id]; | ||
Object.values(workspaces) | ||
.filter((workspaceB) => workspaceB.dependencies.includes(workspaceA.id)) | ||
.forEach((workspaceB) => queue.push(workspaceB.id)); | ||
visited.add(id); | ||
} | ||
return [...visited].sort(); | ||
const getTouchedDependencies = ({ workspaces, files }) => { | ||
return Object.values(workspaces).reduce((changed, wa) => { | ||
const _files = filterWorkspaces({ workspace: wa, files }); | ||
changed[wa.id] = changed[wa.id] || []; | ||
changed[wa.id] = changed[wa.id].concat(_files); | ||
if (_files.length <= 0) return changed; | ||
Object.values(workspaces).forEach((wb) => { | ||
if (wa === wb) return; | ||
if (!wb.dependencies.includes(wa.id)) return; | ||
changed[wb.id] = changed[wb.id] || []; | ||
changed[wb.id] = changed[wb.id].concat(wa.path); | ||
}); | ||
return changed; | ||
}, {}); | ||
}; | ||
exports.getTouchedDependencies = getTouchedDependencies; |
@@ -1,15 +0,5 @@ | ||
const { filterWorkspaces } = require("./filterWorkspaces"); | ||
const { getTouchedDependencies } = require("./getTouchedDependencies"); | ||
jest.mock("./filterFiles"); | ||
jest.mock("./filterWorkspaces"); | ||
describe("getTouchedDependencies", () => { | ||
test("it returns empty list if no change", () => { | ||
filterWorkspaces.mockImplementationOnce(() => []); | ||
expect(getTouchedDependencies({ workspaces: {} })).toEqual([]); | ||
}); | ||
test("it returns changed workspaces", () => { | ||
filterWorkspaces.mockImplementationOnce(() => ["app1"]); | ||
test("it returns empty lists if no change", () => { | ||
expect( | ||
@@ -29,8 +19,11 @@ getTouchedDependencies({ | ||
}, | ||
files: [], | ||
}) | ||
).toEqual(["app1"]); | ||
).toEqual({ | ||
app1: [], | ||
app2: [], | ||
}); | ||
}); | ||
test("it returns deep dependants", () => { | ||
filterWorkspaces.mockImplementationOnce(() => ["app1"]); | ||
test("it returns workspaces with changed files", () => { | ||
expect( | ||
@@ -47,16 +40,14 @@ getTouchedDependencies({ | ||
path: "/app/packages/app2", | ||
dependencies: ["app1"], | ||
dependencies: [], | ||
}, | ||
app3: { | ||
id: "app3", | ||
path: "/app/packages/app3", | ||
dependencies: ["app2"], | ||
}, | ||
}, | ||
files: ["/app/packages/app1/file1"], | ||
}) | ||
).toEqual(["app1", "app2", "app3"]); | ||
).toEqual({ | ||
app1: ["/app/packages/app1/file1"], | ||
app2: [], | ||
}); | ||
}); | ||
test("it prevents infinite resolution", () => { | ||
filterWorkspaces.mockImplementationOnce(() => ["app1"]); | ||
test("it returns deep dependants", () => { | ||
expect( | ||
@@ -68,8 +59,23 @@ getTouchedDependencies({ | ||
path: "/app/packages/app1", | ||
dependencies: [], | ||
}, | ||
app2: { | ||
id: "app2", | ||
path: "/app/packages/app2", | ||
dependencies: ["app1"], | ||
}, | ||
app3: { | ||
id: "app3", | ||
path: "/app/packages/app3", | ||
dependencies: ["app2"], | ||
}, | ||
}, | ||
files: ["/app/packages/app1/file1", "/app/packages/app2/file1"], | ||
}) | ||
).toEqual(["app1"]); | ||
).toEqual({ | ||
app1: ["/app/packages/app1/file1"], | ||
app2: ["/app/packages/app1", "/app/packages/app2/file1"], | ||
app3: ["/app/packages/app2"], | ||
}); | ||
}); | ||
}); |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
62
22521
5
26
618
8
+ Addedchalk@^4.1.0
+ Addedchalk@4.1.2(transitive)
+ Addedhas-flag@4.0.0(transitive)
+ Addedsupports-color@7.2.0(transitive)