Skip to content

Commit 4ec1064

Browse files
committed
Actual CLI stuff
1 parent 6e9da3e commit 4ec1064

File tree

8 files changed

+243
-24
lines changed

8 files changed

+243
-24
lines changed

README.md

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,2 @@
1-
# typescript-project-template
2-
Template repo for new TypeScript projects.
3-
4-
This repo assumes the following:
5-
6-
* Source code lives in the `src` directory.
7-
* The main export is located in `src/index.ts`.
8-
* Tests live in the `test` directory.
9-
* Each test is name `<name>.test.ts`.
10-
* The `npm_token`, `app_id`, and `app_private_key` secrets are present in the repo.
11-
* The code will be a CommonJS module.
1+
# github-run-script
2+
Run a script on multiple repositories, cloning them if needed.

package-lock.json

Lines changed: 71 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"name": "<package-name>",
2+
"name": "github-run-script",
33
"version": "1.0.0",
4-
"description": "<package-description>",
4+
"description": "Run a script on multiple repositories, cloning them if needed.",
55
"main": "dist/index.js",
66
"type": "commonjs",
77
"types": "dist/index.d.ts",
@@ -16,16 +16,19 @@
1616
},
1717
"repository": {
1818
"type": "git",
19-
"url": "git+https://github.com/PythonCoderAS/<package-name>.git"
19+
"url": "git+https://github.com/PythonCoderAS/github-run-script.git"
2020
},
2121
"keywords": [],
2222
"author": "PythonCoderAS",
2323
"license": "MIT",
2424
"bugs": {
25-
"url": "https://github.com/PythonCoderAS/<package-name>/issues"
25+
"url": "https://github.com/PythonCoderAS/github-run-script/issues"
2626
},
27-
"homepage": "https://github.com/PythonCoderAS/<package-name>#readme",
27+
"homepage": "https://github.com/PythonCoderAS/github-run-script#readme",
2828
"dependencies": {
29+
"array-string-map": "^3.0.0",
30+
"node-filter-async": "^2.0.0",
31+
"sade": "^1.8.1"
2932
},
3033
"devDependencies": {
3134
"@types/chai": "^4.3.1",

src/cli.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as sade from "sade";
2+
import handler from "./handler";
3+
4+
// eslint-disable-next-line @typescript-eslint/no-var-requires -- Needed in order to not copy over package.json and make new src directory inside of dist
5+
const { version, description } = require("../package.json");
6+
7+
const cli = sade("github-run-script <script>", true)
8+
.version(version)
9+
.describe(description)
10+
.option(
11+
"-o, --owner",
12+
"The owner for repositories without an explicit owner."
13+
)
14+
.option(
15+
"-s, --search-path",
16+
"A path to search for already-cloned repositories."
17+
)
18+
.action(handler);
19+
20+
export default cli;

src/handler.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { mkdtemp, readdir, stat, rm } from "fs/promises";
2+
import { spawn } from "child_process";
3+
import { CliFlags, RepoOwner } from "./types";
4+
import { getRepoAndOwner, waitOnChildProcessToExit } from "./utils";
5+
import ArrayStringMap from "array-string-map";
6+
import filterAsync from "node-filter-async";
7+
import { tmpdir } from "os";
8+
9+
export default async function handler(script: string, flags: CliFlags) {
10+
console.log(script, flags);
11+
const { owner: defaultOwner, _: repoNames } = flags;
12+
const repos: RepoOwner[] = [];
13+
for (const repoName of repoNames) {
14+
try {
15+
const [owner, repo] = getRepoAndOwner(repoName, defaultOwner);
16+
repos.push([owner, repo]);
17+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- We need the `message` attribute.
18+
} catch (e: any) {
19+
console.error(e.message);
20+
process.exit(1);
21+
}
22+
}
23+
const tempDir = await mkdtemp(`${tmpdir()}/github-run-script-`);
24+
try {
25+
const directoryMapping: ArrayStringMap<RepoOwner, string> =
26+
new ArrayStringMap();
27+
const scanDirectories: Map<string, string[]> = new Map();
28+
if (flags.searchPath) {
29+
// If it's one path, it won't be in an array. This converts it into an array.
30+
if (typeof flags.searchPath === "string") {
31+
flags.searchPath = [flags.searchPath];
32+
}
33+
// What this code block does is simple. It stores in `scanDirectories`
34+
// a mapping of source path name to an array of directories in that directory.
35+
await Promise.all(
36+
flags.searchPath.map(async (path) => {
37+
scanDirectories.set(
38+
path,
39+
await filterAsync(await readdir(path), async (item) => {
40+
return (await stat(`${path}/${item}`)).isDirectory();
41+
})
42+
);
43+
})
44+
);
45+
}
46+
await Promise.all(
47+
repos.map(async ([owner, repo]) => {
48+
// First, we need to check if the repository exists in `scanDirectories`.
49+
// TODO: Handle cases where the same repo is present multiple times in
50+
// TODO: different directories, or if two repos with the same name but
51+
// TODO: different owners is provided. (Maybe we can check `.git`.)
52+
for (const [path, directories] of scanDirectories) {
53+
for (const directory of directories) {
54+
if (repo === directory) {
55+
directoryMapping.set([owner, repo], path);
56+
break;
57+
}
58+
}
59+
// If we already found a match earlier, no need to re-iterate over the other
60+
// directories.
61+
if (directoryMapping.has([owner, repo])) {
62+
break;
63+
}
64+
}
65+
// Deal wit the special case where we did not find a match. Time to clone.
66+
if (!directoryMapping.has([owner, repo])) {
67+
const destPath = `${tempDir}/${repo}`;
68+
console.log(`Cloning ${owner}/${repo} to ${destPath}`);
69+
const childProc = await spawn(
70+
"git",
71+
["clone", `https://github.com/${owner}/${repo}.git`, destPath],
72+
{ stdio: "inherit" }
73+
);
74+
await waitOnChildProcessToExit(childProc);
75+
directoryMapping.set([owner, repo], destPath);
76+
}
77+
// Time to execute the script!
78+
const path = directoryMapping.get([owner, repo]);
79+
const childProc = await spawn(script, [], {
80+
cwd: path,
81+
env: {
82+
...process.env,
83+
REPO_OWNER: owner,
84+
REPO_NAME: repo,
85+
REPO: `${owner}/${repo}`,
86+
REPO_PATH: path,
87+
},
88+
stdio: "inherit",
89+
});
90+
await waitOnChildProcessToExit(childProc);
91+
})
92+
);
93+
} finally {
94+
// We need to clean up the temporary directory.
95+
await rm(tempDir, { recursive: true, force: true });
96+
}
97+
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env node
2+
3+
import cli from "./cli";
4+
5+
cli.parse(process.argv);

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface CliFlags {
2+
owner?: string;
3+
searchPath?: string | string[];
4+
_: string[];
5+
}
6+
7+
/**
8+
* A two-element tuple that contains [owner, repository].
9+
*/
10+
export type RepoOwner = [string, string];

src/utils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { RepoOwner } from "./types";
2+
import { ChildProcess } from "child_process";
3+
4+
export function getRepoAndOwner(
5+
input: string,
6+
defaultOwner?: string
7+
): RepoOwner {
8+
let [owner, repo] = input.split("/");
9+
if (!repo) {
10+
// This means that there was no slash, so we need to do some reassignments.
11+
repo = owner;
12+
13+
if (!defaultOwner) {
14+
throw new Error(
15+
`${input}: No owner specified and no default owner was provided.`
16+
);
17+
}
18+
19+
owner = defaultOwner;
20+
}
21+
22+
return [owner, repo];
23+
}
24+
25+
export async function waitOnChildProcessToExit(process: ChildProcess) {
26+
return new Promise((resolve, reject) => {
27+
process.on("exit", resolve);
28+
process.on("error", reject);
29+
});
30+
}

0 commit comments

Comments
 (0)