#!/usr/bin/env zx import osInfo from 'linux-os-info' import { Queue } from 'modern-async' $.verbose = false // Preserves color from subshells process.env.CLICOLOR_FORCE = 3 let installOrder, osArch, osId, osType, pkgs, sysType const cacheDir = os.homedir() + '/.cache/installx' const customArgv = minimist(process.argv.slice(3), { boolean: [ 'all' ], alias: { a: 'all', } }) function log(message) { console.log(`${chalk.greenBright.bold('installx ❯')} ${message}`) } async function getOsInfo() { return osInfo({ mode: 'sync' }) } function execPromise(command) { return new Promise(function (resolve, reject) { require('child_process').exec(command, (error, stdout, stderr) => { if (error) { reject(error) return } resolve(stdout.trim()) }) }) } function runSilentCommand(command) { return require('child_process').execSync(`${command}`, { stdio: 'inherit', shell: true }) } async function runScript(key, script) { fs.writeFileSync(`${cacheDir}/${key}-raw`, script) const [templatedScript, file, brief] = await Promise.all([ $`cat "${cacheDir}/${key}-raw" | chezmoi execute-template`, $`cat "${cacheDir}/${key}-raw" | ( grep "^# @file" || [ "$?" == "1" ] ) | sed 's/^# @file //'`, $`cat "${cacheDir}/${key}-raw" | ( grep "^# @brief" || [ "$?" == "1" ] ) | sed 's/^# @brief //'` ]) fs.writeFileSync(`${cacheDir}/${key}-glow`, (file.stdout ? `# ${file.stdout}\n\n` : '') + (brief.stdout ? `> ${brief.stdout}\n\n` : '') + '```sh\n' + templatedScript.stdout + "\n```") fs.writeFileSync(`${cacheDir}/${key}`, templatedScript.stdout) try { runSilentCommand(`glow --width 120 "${cacheDir}/${key}-glow"`) // TODO: Set process.env.DEBUG || true here because the asynchronous method is not logging properly / running slow if (process.env.DEBUG) { return await runSilentCommand(`bash "${cacheDir}/${key}" || gum log -sl error 'Error occurred while processing script for ${key}'`) } else { return await $`bash "${cacheDir}/${key}" || gum log -sl error 'Error occurred while processing script for ${key}'`.pipe(process.stdout) } } catch (e) { console.error(`Failed to run script associated with ${key}`, e) } } function getPkgData(pref, pkg, installer) { if (installer) { if (pkg[`${pref}:${installer}:${osId}:${osArch}`]) { return `${pref}:${installer}:${osId}:${osArch}` // Handles case like `_bin:pipx:debian:x64:` } else if (pkg[`${pref}:${osId}:${installer}:${osArch}`]) { return `${pref}:${osId}:${installer}:${osArch}` // Handles case like `_bin:debian:pipx:x64:` } else if (pkg[`${pref}:${installer}:${osType}:${osArch}`]) { return `${pref}:${installer}:${osType}:${osArch}` // Handles case like `_bin:pipx:windows:x64:` } else if (pkg[`${pref}:${osType}:${installer}:${osArch}`]) { return `${pref}:${osType}:${installer}:${osArch}` // Handles case like `_bin:windows:pipx:x64:` } else if (pkg[`${pref}:${installer}:${osId}`]) { return `${pref}:${installer}:${osType}` // Handles case like `_bin:pipx:fedora:` } else if (pkg[`${pref}:${osId}:${installer}`]) { return `${pref}:${osType}:${installer}` // Handles case like `_bin:fedora:pipx:` } else if (pkg[`${pref}:${installer}:${osType}`]) { return `${pref}:${installer}:${osType}` // Handles case like `_bin:pipx:darwin:` } else if (pkg[`${pref}:${osType}:${installer}`]) { return `${pref}:${osType}:${installer}` // Handles case like `_bin:darwin:pipx:` } else if (pkg[`${pref}:${installer}`]) { return `${pref}:${installer}` // Handles case like `_bin:pipx:` } else if (pkg[`${pref}`]) { return `${pref}` // Handles case like `_bin:` } else { return false } } else { if (pkg[`${pref}:${osId}:${osArch}`]) { return `${pref}:${osId}:${osArch}` // Handles case like `pipx:debian:x64:` } else if (pkg[`${pref}:${osType}:${osArch}`]) { return `${pref}:${osType}:${osArch}` // Handles case like `pipx:windows:x64:` } else if (pkg[`${pref}:${osId}`]) { return `${pref}:${osType}` // Handles case like `pipx:fedora:` } else if (pkg[`${pref}:${osType}`]) { return `${pref}:${osType}` // Handles case like `pipx:darwin:` } else if (pkg[`${pref}`]) { return `${pref}` // Handles case like `pipx:` } else { return false } } } async function getSoftwareDefinitions() { try { return YAML.parse(fs.readFileSync(`${os.homedir()}/.local/share/chezmoi/software.yml`, 'utf8')) } catch (e) { throw Error('Failed to load software definitions', e) } } async function getSystemType() { if (process.platform === "win32") { return "windows" } else if (process.platform === "linux") { if (which.sync('apk')) { return "apk" } else if (which.sync('apt-get')) { return "apt" } else if (which.sync('dnf')) { return "dnf" } else if (which.sync('pacman')) { return "pacman" } else if (which.sync('zypper')) { return "zypper" } else { return "linux" } } else { return process.platform } } function expandDeps(keys) { for (const i of keys) { for (const pref of installOrder[sysType]) { const installKey = getPkgData(pref, pkgs[i], false) if (installKey) { const installType = installKey.split(':')[0] const depsKey = getPkgData('_deps', pkgs[i], installType) if (depsKey) { const deps = typeof pkgs[i][depsKey] === 'string' ? [pkgs[i][depsKey]] : pkgs[i][depsKey] return [...keys, ...expandDeps(deps)] } } } return [...keys] } return [...keys] } async function createCaskLinks(caskMap) { if (!caskMap) return const caskApps = caskMap .filter(x => { // Filter out macOS apps that already have a _app installed if (x.installType === 'cask' || (osId === 'darwin' && x._app)) { const appField = getPkgData('_app', x, x.installType) const binField = getPkgData('_bin', x, x.installType) const sysDir = fs.existsSync(`/Applications/${x[appField]}`) const homeDir = fs.existsSync(`${os.homedir()}/Applications/${x[appField]}`) const binFile = fs.existsSync(`${os.homedir()}/.local/bin/cask/${x[binField]}`) if (sysDir || homeDir) { return !binFile } else { return false } } else { return false } }) caskApps.length && await $`mkdir -p "$HOME/.local/bin/cask"` for (const app of caskApps) { const appField = getPkgData('_app', app, app.installType) if (!appField) { log(`${app.listKey} is missing an _app definition`) return } const binField = getPkgData('_bin', app, app.installType) if (!binField) { log(`${app.listKey} is missing a _bin definition`) return } if (fs.existsSync(`${os.homedir()}/Applications/${app[appField]}`)) { fs.writeFileSync(`${os.homedir()}/.local/bin/cask/${app[binField]}`, `#!/usr/bin/env bash\nopen "$HOME/Applications/${app[appField]}" $*`) await $`chmod +x '${os.homedir()}/.local/bin/cask/${app[binField]}'` } else if (fs.existsSync(`/Applications/${app[appField]}`)) { fs.writeFileSync(`${os.homedir()}/.local/bin/cask/${app[binField]}`, `#!/usr/bin/env bash\nopen "/Applications/${app[appField]}" $*`) await $`chmod +x '${os.homedir()}/.local/bin/cask/${app[binField]}'` } else { log(`Unable to create bin link to ${app[appField]}`) } } caskApps.length && log(`Finished creating Homebrew cask links in ~/.local/bin/cask`) } async function createFlatpakLinks(flatpakMap) { const flatpakInstallations = await $`flatpak --installations` const flatpakDir = flatpakInstallations.stdout.replace('\n', '') const flatpakApps = flatpakMap .filter(x => { if (x.installType === 'flatpak') { const binField = getPkgData('_bin', x, x.installType) const binFile = fs.existsSync(`${os.homedir()}/.local/bin/flatpak/${x[binField]}`) return !binFile } return false }) flatpakApps.length && await $`mkdir -p "$HOME/.local/bin/flatpak"` for (const app of flatpakApps) { const binField = getPkgData('_bin', app, app.installType) if (!binField) { log(`${app.listKey} is missing a _bin definition`) return } if (fs.existsSync(`${flatpakDir}/app/${app.installList[0]}`)) { fs.writeFileSync(`${os.homedir()}/.local/bin/flatpak/${app[binField]}`, `#!/usr/bin/env bash\nflatpak run ${app.installList[0]} $*`) await $`chmod +x '${os.homedir()}/.local/bin/flatpak/${app[binField]}'` } else { log(`Unable to create bin link to ${x.flatpak}`) } } flatpakApps.length && log(`Finished creating Flatpak links in ~/.local/bin/flatpak`) } async function bundleInstall(brews, casks, caskMap) { try { const lines = [] casks.length && log(`Adding following casks to Brewfile for installation: ${casks.join(' ')}`) for (const cask of casks) { if (cask.indexOf('/') !== -1) { lines.push(`tap "${cask.substring(0, cask.lastIndexOf('/'))}"`) } if (cask.indexOf(' --no-quarantine') === -1) { lines.push(`cask "${cask}"`) } else { lines.push(`cask "${cask.replace(' --no-quarantine', '')}", args: { "no-quarantine": true }`) } } brews.length && log(`Adding following brews to Brewfile for installation: ${brews.join(' ')}`) for (const brew of brews) { if (brew.indexOf('/') !== -1) { lines.push(`tap "${brew.substring(0, brew.lastIndexOf('/'))}"`) } lines.push(`brew "${brew}"`) } log(`Creating Brewfile to install from`) cd(await $`mktemp -d`) fs.writeFileSync('Brewfile', lines.join('\n')) log(`Installing packages via brew bundle`) await $`brew bundle --file Brewfile` log(`Finished installing via Brewfile`) } catch (e) { console.log('Error:', e) log(`Error occurred while installing via Brewfile`) } try { await createCaskLinks(caskMap) } catch (e) { console.log('Error:', e) log(`Error occurred while creating cask bin links`) } } async function forEachSeries(iterable) { for (const x of iterable) { await x } } async function installPackages(pkgInstructions) { const queue = new Queue(3) const combined = {} const promises = [] log(`Populating install order lists`) for (const option of installOrder[sysType]) { const instructions = pkgInstructions.filter(x => x.installType === option).filter((value, index, array) => array.indexOf(value) === index) if (instructions.length) { combined[option] = instructions } } log(`Running Homebrew installation via Brewfile`) if ((combined.brew && combined.brew.length) || (combined.cask && combined.cask.length)) { promises.push(queue.exec(async () => { await bundleInstall(combined.brew ? combined.brew.flatMap(x => x.installList.flatMap(i => i)) : [], combined.cask ? combined.cask.flatMap(x => x.installList.flatMap(i => i)) : [], combined.cask) })) } for (const key of Object.keys(combined)) { if (key !== 'script') { log(`Install orders for ${key}: ${combined[key].flatMap(i => i.installList).join(' ')}`) } switch (key) { case 'ansible': promises.push( forEachSeries(combined[key].flatMap(x => x.installList.flatMap(i => queue.exec(async () => { await $`${key} 127.0.0.1 -v${process.env.DEBUG && 'vv'} -e '{ ansible_connection: "local", ansible_become_user: "root", ansible_user: "${process.env.USER}", ansible_family: "${osId.charAt(0).toUpperCase() + osId.slice(1)}", install_homebrew: False }' -m include_role -a name=${i}` })))) ) break case 'apk': promises.push(queue.exec(async () => { await $`sudo ${key} add ${combined[key].flatMap(x => x.installList).split(' ')}` })) break case 'appimage': promises.push(...combined[key].flatMap(x => x.installList.flatMap(i => { if (x.substring(0, 4) === 'http') { return queue.exec(async () => { await $`zap install --select-first -q --from ${i}` }) } else if ((x.match(/\//g) || []).length === 1) { return queue.exec(async () => { await $`zap install --select-first -q --github --from ${i}` }) } else { return queue.exec(async () => { $`zap install --select-first -q ${i}` }) } }))) break case 'apt': promises.push(queue.exec(async () => { await $`DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Options::=--force-confdef install -y ${combined[key].flatMap(x => x.installList).split(' ')}` })) break case 'basher': case 'baulk': case 'cargo': case 'crew': case 'gem': case 'go': case 'pip': case 'pipx': case 'scoop': // Maybe needs forEachSeries case 'winget': // Maybe needs forEachSeries promises.push( ...combined[key].flatMap(x => x.installList.flatMap(i => queue.exec(async () => { await $`${key} install ${i}` }))) ) break case 'npm': promises.push( ...combined[key].flatMap(x => x.installList.flatMap(i => queue.exec(async () => { await $`volta install ${i}` }))) ) break case 'binary': // TODO promises.push( ...combined[key].flatMap(x => x.installList.flatMap(i => queue.exec(async () => { await $`TMP="$(mktemp)" && curl -sSL ${i} > "$TMP" && sudo mv "$TMP" /usr/local/src/${x._bin} && chmod +x /usr/local/src/${x._bin}` }))) ) break case 'brew': case 'cask': // Handled above break case 'choco': promises.push(queue.exec(async () => { await $`${key} install -y ${combined[key].flatMap(x => x.installList).join(' ')}` })) break case 'dnf': case 'yum': case 'zypper': promises.push(queue.exec(async () => { await $`sudo ${key} install -y ${combined[key].flatMap(x => x.installList).join(' ')}` })) break case 'emerge': case 'pkg_add': promises.push(queue.exec(async () => { await $`sudo ${key} ${combined[key].flatMap(x => x.installList).join(' ')}` })) break case 'eopkg': case 'pkg-freebsd': case 'pkg-termux': case 'pkgin': case 'port': case 'snap': // TODO - snap testing.. combine with snap-classic and add appropriate logic promises.push(queue.exec(async () => { await $`sudo ${key === 'pkg-freebsd' || key === 'pkg-termux' ? 'pkg' : key} install ${combined[key].flatMap(x => x.installList).join(' ')}` })) break case 'flatpak': promises.push(queue.exec(async () => { await forEachSeries(combined[key].flatMap(x => x.installList.flatMap(i => $`sudo ${key} install -y flathub ${i}`))) })) break case 'github': // TODO break case 'nix-env': // TODO case 'nix-pkg': // TODO case 'nix-shell': // TODO break case 'pacman': promises.push(queue.exec(async () => { await $`sudo ${key} -Sy --noconfirm --needed ${combined[key].flatMap(x => x.installList).join(' ')}` })) break case 'pkg-darwin': break case 'sbopkg': // TODO break case 'script': promises.push( ...combined[key].flatMap(x => x.installList.flatMap(i => queue.exec(async () => await runScript(x.listKey, i)))) ) break case 'whalebrew': // TODO break case 'xbps': promises.push(queue.exec(async () => { await $`sudo xbps-install -S ${combined[key].flatMap(x => x.installList).join(' ')}` })) break case 'yay': promises.push(queue.exec(async () => { await $`yay -Sy --noconfirm --needed ${combined[key].flatMap(x => x.installList).join(' ')}` })) break default: log(`Unable to find install key instructions for ${key}`) } } log(`Performing ${promises.length} installations`) process.env.DEBUG && console.log('Queued installs:', promises) const insalls = await Promise.allSettled(promises) log(`All of the installations have finished`) process.env.DEBUG && console.log('Completed installs:', installs) await postInstall(combined) } async function postInstall(combined) { log(`Running post-install routine`) const promises = [] Object.keys(combined).includes('flatpak') && promises.push(createFlatpakLinks(combined.flatpak)) const postInstalls = await Promise.allSettled(promises) process.env.DEBUG && console.log('Post installs:', postInstalls) } async function acquireManagerList(type, command) { if (which.sync(type, { nothrow: true })) { if (fs.existsSync(`${cacheDir}/${type}`)) { setTimeout(() => { require('child_process').exec(`${command} > ${cacheDir}/${type}`) }, 100) } else { require('child_process').execSync(`${command} > ${cacheDir}/${type}`) } return fs.readFileSync(`${cacheDir}/${type}`).toString().split('\n') } else { log(`${type} is not installed`) return [] } } function pkgMap(pkgDefs) { return pkgDefs .map(i => { for (const pref of installOrder[sysType]) { const installKey = getPkgData(pref, pkgs[i], false) if (installKey) { return { ...pkgs[i], listKey: i, installKey, installType: installKey.split(':')[0], installList: typeof pkgs[i][installKey] === 'string' ? [pkgs[i][installKey]] : pkgs[i][installKey] } } } return { ...pkgs[i], listKey: i, installKey: false, installType: false, installList: [] } }) .filter(x => x.installKey) } async function main() { await $`mkdir -p ${cacheDir}` log(`Acquiring software definitions and system information`) const initData = await Promise.all([ getOsInfo(), getSoftwareDefinitions(), getSystemType() ]) osArch = initData[0].arch osId = process.platform === 'win32' ? 'win32' : (process.platform === 'linux' ? initData[0].id : process.platform) osType = process.platform === 'win32' ? 'windows' : process.platform pkgs = initData[1].softwarePackages sysType = initData[2] installOrder = initData[1].installerPreference log(`Populating lists of pre-installed packages`) const listPromises = [ acquireManagerList('apt', `if command -v dpkg; then dpkg -l; fi`), acquireManagerList('brew', `brew list -1`), acquireManagerList('cargo', `cargo install --list | awk '/^[[:alnum:]]/ {print $1}'`), acquireManagerList('dnf', `rpm -qa`), acquireManagerList('flatpak', `flatpak --columns=app list`), acquireManagerList('gem', `gem list | awk '{print $1}'`), acquireManagerList('npm', `volta list --format plain | awk '{print $2}' | sed 's/@.*//'`), acquireManagerList('pacman', `pacman -Qs`), acquireManagerList('pip', `pip3 list | awk '{print $1}'`), acquireManagerList('pipx', `pipx list --short | awk '{print $1}'`), acquireManagerList('snap', `if command -v snapd; then snap list; fi`), acquireManagerList('zap', `zap list`) ] const lists = await Promise.all(listPromises) const managerLists = { appimage: lists[6], apt: lists[0], brew: lists[1], cargo: lists[2], cask: lists[1], dnf: lists[3], flatpak: lists[4], gem: lists[5], npm: lists[6], pacman: lists[7], pip: lists[8], pipx: lists[9], snap: lists[10], zap: lists[11] } log(`Acquiring installation keys`) const installKeys = Object.keys(pkgs) .filter(i => expandDeps(argv._).includes(i)) log(`Constructing installation data`) const installData = pkgMap(customArgv.all ? Object.keys(pkgs) : installKeys) log(`Filtering install instructions`) const installInstructions = installData .map(x => { return { ...x, installList: x.installList.filter(y => { if ((x.installType === 'brew' || x.installType === 'cask') && y.includes('/')) { return managerLists[x.installType] ? !managerLists[x.installType].includes(y.substring(y.lastIndexOf('/') + 1, y.length)) : true } else { return managerLists[x.installType] ? !managerLists[x.installType].includes(y) : true } }) } }) .filter(x => { // Filter out packages already installed by by package managers return x.installList.length }) .filter(x => { // Filter out packages that contain a deprecation note return !x._deprecated }) .filter(x => { // Filter out macOS apps that already have a _app installed if (x.installType === 'cask' || (osId === 'darwin' && x._app)) { const appField = getPkgData('_app', x, x.installType) const appCheck = fs.existsSync(`/Applications/${x[appField]}`) || fs.existsSync(`${os.homedir()}/Applications/${x[appField]}`) appCheck && log(`Skipping installation of ${x.listKey} because the application is in an Applications folder`) return !appCheck } else { return true } }) .filter(x => { // Filter out packages that already have a bin in the PATH const binField = getPkgData('_bin', x, x.installType) const isArray = Array.isArray(x[binField]) if (typeof x[binField] === 'string' || isArray) { isArray && log(`_bin field for ${x.listKey} is an array so the first entry will be used to check`) const whichCheck = which.sync(typeof x[binField] === 'string' ? x[binField] : x[binField][0], { nothrow: true }) whichCheck && log(`Skipping installation of ${x.listKey} because its binary is available in the PATH`) return !whichCheck } else { log(`Ignoring _bin check because the _bin field for ${x.listKey} is not a string or array`) return true } }) .filter(x => { // Filter out packages that do not pass _when check const whenField = getPkgData('_when', x, x.installType) if (x[whenField]) { if (typeof x[whenField] === 'string') { try { runSilentCommand(`${x[whenField]}`) return true } catch (e) { return false } } else { log(`typeof _when for ${x.listKey} must be a string`) return true } } else { return true } }) log(`Running installation routine`) await installPackages(installInstructions) log(`Adding users / groups defined under _groups`) const usersGroupsAdditions = installData .flatMap(x => { const groupsField = getPkgData('_groups', x, x.installType) if (!groupsField) return Promise.resolve() log(`Ensuring user(s) / group(s) created for ${x.listKey}`) if (typeof typeof x[groupsField] !== 'string' && !Array.isArray(x[groupsField])) { log(`Failed to parse _groups for ${x.installKey}. The _groups field must be either a string or string[].`) return Promise.resolve() } else { const groups = typeof x[groupsField] === 'string' ? [x[groupsField]] : x[groupsField] return groups.flatMap(y => { return $`sudo "${os.homedir()}/.local/bin/add-usergroup" "${y}" "${y}" && sudo "${os.homedir()}/.local/bin/add-usergroup" "${process.env.USER}" "${y}"` }) } }) await Promise.allSettled(usersGroupsAdditions) log(`Running post-install inline scripts`) for (const x of installData) { const postField = getPkgData('_post', x, x.installType) if (postField) { log(`Running post-install script for ${x.listKey}`) const binField = getPkgData('_bin', x, x.installType) const isArray = Array.isArray(x[binField]) if (typeof x[binField] === 'string' || isArray) { isArray && log(`_bin field for ${x.listKey} is an array so the first entry will be used to check`) const whichCheck = which.sync(typeof x[binField] === 'string' ? x[binField] : x[binField][0], { nothrow: true }) whichCheck && await runScript(x.listKey, x[postField]) } else { console.error(`${x.listKey}'s _bin field should either be a string or an array of strings`) } } } log(`Running post-install scripts defined in ~/.local/bin/post-installx`) for (const x of installData) { const scriptPath = `${os.homedir()}/.local/bin/post-installx/post-${x.listKey}.sh` const scriptExists = fs.existsSync(scriptPath) if (scriptExists) { log(`Running post-install script defined in ${scriptPath}`) await runScript(`post-${x.listKey}.sh`, fs.readFileSync(scriptPath, 'utf8')) } } //await Promise.allSettled(postScripts) //await Promise.allSettled(postScriptFiles) log(`Starting services flagged with _serviceEnabled`) const systemctlInstalled = which.sync('systemctl', { nothrow: true }) const brewInstalled = which.sync('brew', { nothrow: true }) const servicePromises = installData .filter(x => !!x._serviceEnabled) .filter(x => !!x._service) .flatMap(x => { const serviceField = getPkgData('_service', x, x.installType) if (!serviceField) return Promise.resolve() const services = typeof x[serviceField] === 'string' ? [{ name: x[serviceField] }] : (Array.isArray(x[serviceField]) ? x[serviceField] : [{ name: x[serviceField].name, sudo: x[serviceField].sudo }]) return services.flatMap(y => { const name = typeof y === 'string' ? y : y.name const sudo = typeof y === 'string' ? null : y.sudo if (osType === 'linux' && x.installType !== 'brew' && x.installType !== 'cask' && systemctlInstalled) { return sudo === true ? $`sudo systemctl enable --now ${name}` : $`systemctl enable --now ${name}` } else if (brewInstalled) { return sudo === true ? $`sudo brew services restart ${name}` : $`brew services restart ${name}` } }) }) await Promise.allSettled(servicePromises) log(`Installation process complete!`) } main()