bash/shell scripts are a common part of every developer’s life. There is no doubt in my mind that anything can be done in bash, but I am often surprised how just how surprised that I am at the behavior or limitations of the language and library of functions available. As some shell scripts started to get complex and where already in a TypeScript repository I started writing them in TypeScript instead of bash. Some benefits of TypeScript over shell scripts include type safety, better tooling, and improved maintainability. I found a couple tricks that have been enduring and I have started using in multiple places now.
The first trick is the shebang. I’ve been through a few iterations of this and surprising frustration due to node’s handling of ES Modules in different versions, but my current favorite shebang to make any TypeScript script executable is:
#!/usr/bin/env -S npx tsx
This makes it so that nothing needs installed as npx will handle it. To make it work, add the shebang as the first line of the file and make the file executable with chmod +x <filename>
. Then you can just run the file like any other shell script.
At some point I ran across zx that provides some wrappers around child_process
and has some other handy functions for writing scripts.
Below is a full example that I use to move some files from a local computer to a nas:
#!/usr/bin/env -S npx tsx
import { config } from "dotenv"
import * as path from "path"
import { moveDirectories } from "./moveDirectories"
const dirname = path.dirname(new URL(import.meta.url).pathname)
config({ path: path.resolve(dirname, "../.env") })
const ENV_PAIRS = [
{ staging: "MEDIA_TV_STAGING", finished: "MEDIA_TV_FINISHED" },
{ staging: "MEDIA_MOVIES_STAGING", finished: "MEDIA_MOVIES_FINISHED" },
{
staging: "MEDIA_AUDIOBOOKS_STAGING",
finished: "MEDIA_AUDIOBOOKS_FINISHED",
},
]
for (const dirs of ENV_PAIRS) {
if (process.env[dirs.staging] && process.env[dirs.finished]) {
const stagingExpanded = expandVarsInPathString(process.env[dirs.staging])
const finishedExpanded = expandVarsInPathString(process.env[dirs.finished])
await moveDirectories(stagingExpanded, finishedExpanded)
} else {
console.error(
`Environment variables for ${dirs.staging} and ${dirs.finished} not specified. Skipping!`
)
}
}
function expandVarsInPathString(path: string): string {
return path.replace(/\$[a-z\d_]+/gi, function (match) {
const sub = process.env[match.substring(1)]
return sub || match
})
}
import { $, fs, echo, question } from "zx"
export async function moveDirectories(
staging: string,
finished: string
): Promise<void> {
if (!(await fs.pathExists(staging))) {
echo(`Skipping non-existent directory ${staging}!`)
return
}
const directories = fs
.readdirSync(staging, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)
if (directories.length > 0) {
echo(`The following directories will be moved to ${finished}:`)
for (const dir of directories) {
echo(`- ${dir}`)
}
} else {
echo(`No directories found at ${staging}.`)
return
}
const answer = await question(
"Do you want to continue (answer with y or yes to continue)?"
)
if (answer && answer.toLowerCase().startsWith("y")) {
for (const dir of directories) {
echo(`Moving ${dir} to ${finished}...`)
await $`rsync -ah --progress --remove-source-files ${staging}/${dir} ${finished}`
echo(`Done moving ${dir} to ${finished}.`)
await $`find ${staging}/${dir} -type d -empty -delete`
}
echo(`The listed directories have been moved to ${finished}.`)
} else {
echo(`No directories have been moved.`)
}
}
I’ve come to use this TypeScript shebang and zx
on debian & macOS more and more. As soon as the complexity reaches anything that requires a couple functions I tend to find them more maintainable and easier to write than using bash. Hope you do too.