Enhance installation process with terminal selection, update autostart commands, and add PolyForm license; remove unused AGS config
This commit is contained in:
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"js/ts.experimental.useTsgo": false
|
||||
}
|
||||
@@ -33,7 +33,7 @@ There is no build step — `tsx` runs the TypeScript directly.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/packages.ts` — the package model. Each entry has a `tier` (`required` installs with no prompt; `recommended` is offered via checkbox), an optional `group` for mutually-exclusive alternatives (e.g. `waybar` vs `ags` both have `group: "bar"`, only one gets picked via a select prompt — `launcher` currently has just `hyprlauncher` but is kept as a group so alternatives can be added the same way), and an optional `dir` for when a package's Stow directory name needs to differ from the package name. `toDirs()` converts chosen package names to their Stow directory names.
|
||||
- `src/packages.ts` — the package model. Each entry has a `tier` (`required` installs with no prompt; `recommended` is offered via checkbox), an optional `group` for mutually-exclusive alternatives (e.g. `waybar` vs an alternative both having `group: "bar"`, only one gets picked via a select prompt — `bar` and `launcher` currently each have just one member but are kept as groups so alternatives can be added the same way), and an optional `dir` for when a package's Stow directory name needs to differ from the package name. `toDirs()` converts chosen package names to their Stow directory names.
|
||||
- `src/gpu.ts` — `detectGpuVendors()` shells out to `lspci` and matches VGA/3D controller lines against NVIDIA/AMD/Intel keywords (word-boundary regexes — a naive `/amd|ati/i` used to false-positive on the "ati" inside "Corporation"); `driverPackagesFor()` maps detected vendors to their Arch driver packages. `install.ts` shows every detected vendor's packages in a pre-checked confirm prompt (installing all of them on hybrid/Optimus laptops), and if NVIDIA is kept, `stow.ts`'s `writeNvidiaEnvConfig()` writes `~/.config/hypr/conf.d/nvidia.lua` (machine-generated, not stowed) with the Wayland env vars Hyprland's NVIDIA docs recommend.
|
||||
- `src/stow.ts` — all filesystem/process side effects live here: bootstrapping `yay` and oh-my-zsh/zinit if missing, installing packages via `yay`, enabling the `sddm` systemd service (`ensureSddmEnabled`), backing up real (non-symlink) files that would collide with a Stow target into `~/.dotfiles-backup/<timestamp>/`, and wrapping `stow`/`--restow`/`-D` (always invoked with an explicit `-d <PACKAGES_DIR>` so behavior doesn't depend on cwd). Also reads/writes `~/.config/dotfiles/state.json`, which records which package dirs were chosen at install time — `update` and `remove` act on this list rather than re-prompting.
|
||||
- `src/install.ts`, `src/update.ts`, `src/remove.ts` — the three subcommands, each a thin sequence of calls into `stow.ts`, using `@clack/prompts` for interactive selection in `install.ts`.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# PolyForm Noncommercial License 1.0.0
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the
|
||||
software to do everything you might do with the software
|
||||
that would otherwise infringe the licensor's copyright
|
||||
in it for any permitted purpose. However, you may
|
||||
only distribute the software according to [Distribution
|
||||
License](#distribution-license) and make changes or new works
|
||||
based on the software according to [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Distribution License
|
||||
|
||||
The licensor grants you an additional copyright license
|
||||
to distribute copies of the software. Your license
|
||||
to distribute covers distributing the software with
|
||||
changes and new works permitted by [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of
|
||||
the software from you also gets a copy of these terms or the
|
||||
URL for them above, as well as copies of any plain-text lines
|
||||
beginning with `Required Notice:` that the licensor provided
|
||||
with the software. For example:
|
||||
|
||||
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to
|
||||
make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for
|
||||
the benefit of public knowledge, personal study, private
|
||||
entertainment, hobby projects, amateur pursuits, or religious
|
||||
observance, without any anticipated commercial application,
|
||||
is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution,
|
||||
public research organization, public safety or health
|
||||
organization, environmental protection organization,
|
||||
or government institution is use for a permitted purpose
|
||||
regardless of the source of funding or obligations resulting
|
||||
from the funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have
|
||||
violated any of these terms, or done anything with the software
|
||||
not covered by your licenses, your licenses can nonetheless
|
||||
continue if you come into full compliance with these terms,
|
||||
and take practical steps to correct past violations, within
|
||||
32 days of receiving notice. Otherwise, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Maintainer: Haylan
|
||||
pkgname=dotfiles-git
|
||||
pkgver=r0.0000000
|
||||
pkgrel=1
|
||||
pkgdesc="Interactive CLI to install/update/remove Haylan's Hyprland dotfiles"
|
||||
arch=('any')
|
||||
url="https://git.arthurerlich.de/haylan/DotFiles"
|
||||
license=('LicenseRef-PolyForm-Noncommercial-1.0.0')
|
||||
depends=('nodejs')
|
||||
makedepends=('npm' 'git')
|
||||
provides=('dotfiles')
|
||||
conflicts=('dotfiles')
|
||||
source=("$pkgname::git+https://git.arthurerlich.de/haylan/DotFiles.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
npm ci
|
||||
npm prune --production
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
install -d "$pkgdir/usr/lib/node_modules/$pkgname"
|
||||
cp -a src packages node_modules package.json package-lock.json \
|
||||
"$pkgdir/usr/lib/node_modules/$pkgname/"
|
||||
|
||||
install -Dm755 /dev/stdin "$pkgdir/usr/bin/dotfiles" <<-EOF
|
||||
#!/usr/bin/env bash
|
||||
exec "/usr/lib/node_modules/$pkgname/node_modules/.bin/tsx" \\
|
||||
"/usr/lib/node_modules/$pkgname/src/cli.ts" "\$@"
|
||||
EOF
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -62,3 +62,20 @@ preinstalled and the repo copied in, without touching the host:
|
||||
./docker/test.sh # interactive shell, deps installed
|
||||
./docker/test.sh "npx tsc --noEmit" # run one command non-interactively
|
||||
```
|
||||
|
||||
## Testing in a VM
|
||||
|
||||
The container above can't run a graphical Hyprland session (no display, no
|
||||
GPU). To actually see a config change before installing it on a real machine,
|
||||
boot an Arch Linux VM (QEMU/KVM, virt-manager, or whatever you're used to —
|
||||
there's no scripted provisioning for this, it's a manual one-off):
|
||||
|
||||
1. Get the repo into the VM (shared folder, `git clone`, `scp`, whatever's
|
||||
convenient), then `sudo pacman -Syu && npm install && npm start install`,
|
||||
choosing `hyprland` + `hyprpaper` + a bar.
|
||||
2. Reboot into SDDM and log into Hyprland.
|
||||
3. From a terminal in that session, use `hyprctl` to inspect/validate config
|
||||
live — `hyprctl reload`, `hyprctl monitors`, `hyprctl keyword <name> <value>`,
|
||||
`hyprctl dispatch <dispatcher> <args>` — instead of editing-and-relaunching
|
||||
blindly. See the [hyprctl wiki page](https://wiki.hypr.land/Configuring/Advanced-and-Cool/Using-hyprctl/)
|
||||
for the full command set, including its interactive REPL mode.
|
||||
|
||||
+3
-2
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "dotfiles",
|
||||
"private": true,
|
||||
"license": "LicenseRef-PolyForm-Noncommercial-1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/cli.ts",
|
||||
"test": "tsx --test \"test/**/*.test.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.9.0"
|
||||
"@clack/prompts": "^0.9.0",
|
||||
"tsx": "^4.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// ponytail: placeholder ags config, flesh out if ags is chosen over waybar
|
||||
import App from "resource:///com/github/Aylur/ags/app.js";
|
||||
|
||||
App.config({
|
||||
style: "",
|
||||
windows: [],
|
||||
});
|
||||
@@ -2,7 +2,6 @@ hl.monitor({ output = "", mode = "preferred", position = "auto", scale = 1 })
|
||||
|
||||
local mainMod = "SUPER"
|
||||
|
||||
hl.bind(mainMod .. " + T", hl.dsp.exec_cmd("kitty"))
|
||||
hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("hyprlauncher"))
|
||||
hl.bind(mainMod .. " + Q", hl.dsp.window.close())
|
||||
hl.bind(mainMod .. " + M", hl.dsp.exit())
|
||||
@@ -15,3 +14,4 @@ hl.config({
|
||||
pcall(require, "conf.d.autostart")
|
||||
pcall(require, "conf.d.locale")
|
||||
pcall(require, "conf.d.nvidia")
|
||||
pcall(require, "conf.d.terminal")
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
preload = ~/.config/hypr/wallpapers/wallpaper.png
|
||||
wallpaper = ,~/.config/hypr/wallpapers/wallpaper.png
|
||||
splash = false
|
||||
|
||||
wallpaper {
|
||||
monitor =
|
||||
path = ~/.config/hypr/wallpapers/wallpaper.png
|
||||
fit_mode = cover
|
||||
}
|
||||
ipc = on
|
||||
|
||||
+21
-3
@@ -2,6 +2,7 @@ import * as p from "@clack/prompts";
|
||||
import { groups, packages, toDirs } from "./packages.js";
|
||||
import { detectGpuVendors, driverPackagesFor, type GpuVendor } from "./gpu.js";
|
||||
import {
|
||||
autostartCmdsFor,
|
||||
backupConflicts,
|
||||
ensureSddmEnabled,
|
||||
ensureSudoSession,
|
||||
@@ -15,9 +16,11 @@ import {
|
||||
writeLocaleConfig,
|
||||
writeNvidiaEnvConfig,
|
||||
writeState,
|
||||
writeTerminalConfig,
|
||||
} from "./stow.js";
|
||||
|
||||
const CANCELLED = Symbol("cancelled");
|
||||
const TERMINAL_CANDIDATES = ["kitty", "foot"];
|
||||
|
||||
async function selectGroupPackages(chosen: Set<string>): Promise<typeof CANCELLED | void> {
|
||||
for (const group of groups) {
|
||||
@@ -63,6 +66,20 @@ async function selectGpuDriverPackages(chosen: Set<string>): Promise<GpuVendor[]
|
||||
return vendors.filter((vendor) => driverPackagesFor([vendor]).some((pkg) => picked.includes(pkg)));
|
||||
}
|
||||
|
||||
/** null means no terminal was chosen — the SUPER+T keybind is skipped. */
|
||||
async function selectMainTerminal(chosen: Set<string>): Promise<string | null | typeof CANCELLED> {
|
||||
const candidates = TERMINAL_CANDIDATES.filter((name) => chosen.has(name));
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const picked = await p.select({
|
||||
message: "Choose your main terminal (bound to SUPER+T)",
|
||||
options: candidates.map((name) => ({ value: name, label: name })),
|
||||
});
|
||||
if (p.isCancel(picked)) return CANCELLED;
|
||||
return picked;
|
||||
}
|
||||
|
||||
async function maybeReboot(yes: boolean) {
|
||||
if (yes) {
|
||||
p.log.info("Rebooting now...");
|
||||
@@ -85,6 +102,8 @@ export async function install({ yes = false }: { yes?: boolean } = {}) {
|
||||
|
||||
if ((await selectGroupPackages(chosen)) === CANCELLED) return p.cancel("Install cancelled.");
|
||||
if ((await selectRecommendedPackages(chosen)) === CANCELLED) return p.cancel("Install cancelled.");
|
||||
const mainTerminal = await selectMainTerminal(chosen);
|
||||
if (mainTerminal === CANCELLED) return p.cancel("Install cancelled.");
|
||||
const keptGpuVendors = await selectGpuDriverPackages(chosen);
|
||||
if (keptGpuVendors === CANCELLED) return p.cancel("Install cancelled.");
|
||||
|
||||
@@ -107,10 +126,9 @@ export async function install({ yes = false }: { yes?: boolean } = {}) {
|
||||
|
||||
if (keptGpuVendors.includes("nvidia")) writeNvidiaEnvConfig();
|
||||
|
||||
const autostartCmds = ["waybar", "ags", "mako", "hyprpaper"].filter((name) => chosen.has(name));
|
||||
if (chosen.has("hyprlauncher")) autostartCmds.push("hyprlauncher -d");
|
||||
writeAutostartConfig(autostartCmds);
|
||||
writeAutostartConfig(autostartCmdsFor([...chosen]));
|
||||
writeLocaleConfig();
|
||||
if (mainTerminal) writeTerminalConfig(mainTerminal);
|
||||
|
||||
const dirs = toDirs([...chosen]);
|
||||
const backupDir = backupConflicts(dirs);
|
||||
|
||||
@@ -15,7 +15,6 @@ export const packages: Pkg[] = [
|
||||
{ name: "stow", tier: "required" },
|
||||
{ name: "ttf-0xproto-nerd", tier: "required" },
|
||||
{ name: "waybar", tier: "recommended", group: "bar" },
|
||||
{ name: "ags", tier: "recommended", group: "bar", aur: true },
|
||||
{ name: "hyprlauncher", tier: "recommended", group: "launcher" },
|
||||
{ name: "kitty", tier: "recommended" },
|
||||
{ name: "foot", tier: "recommended" },
|
||||
|
||||
+43
-8
@@ -1,4 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
@@ -36,13 +36,22 @@ export function run(cmd: string, args: string[], options: RunOptions = {}) {
|
||||
|
||||
export function ensureSudoSession() {
|
||||
execFileSync("sudo", ["-v"], { stdio: "inherit" });
|
||||
setInterval(() => {
|
||||
try {
|
||||
execFileSync("sudo", ["-n", "-v"]);
|
||||
} catch {
|
||||
// credential expired or revoked; the next real sudo call will re-prompt
|
||||
}
|
||||
}, 60_000).unref();
|
||||
// A JS setInterval can't refresh this during a long synchronous step (e.g. makepkg's
|
||||
// multi-minute build in ensureYay) — execFileSync blocks the entire single-threaded event
|
||||
// loop, so the timer callback never gets a chance to run and the ticket silently expires
|
||||
// mid-build. Spawn the refresh loop as a real child process instead, so it keeps running
|
||||
// regardless of what our own process is blocked on. It's deliberately NOT `detached` —
|
||||
// sudo's default tty-scoped ticket is tied to the session/controlling-tty, and `detached`
|
||||
// calls setsid() on Linux, which moves the child to a new session and makes its `sudo -n -v`
|
||||
// fail to match our ticket (verified empirically: a setsid'd sudo call can't reuse a ticket
|
||||
// established outside it, while a plain background child in the same session can). It exits
|
||||
// on its own once this process (checked via `kill -0`) is gone.
|
||||
const child = spawn(
|
||||
"sh",
|
||||
["-c", `while kill -0 ${process.pid} 2>/dev/null; do sleep 60; sudo -n -v 2>/dev/null; done`],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
child.unref();
|
||||
}
|
||||
|
||||
function commandExists(cmd: string): boolean {
|
||||
@@ -60,6 +69,14 @@ export function ensureYay() {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "yay-bootstrap-"));
|
||||
try {
|
||||
run("git", ["clone", "https://aur.archlinux.org/yay.git", tmp]);
|
||||
// makepkg's own root pacman calls are always `sudo -k ...` (confirmed by reading
|
||||
// /usr/bin/makepkg) — per sudo(8), `-k` combined with a command deliberately ignores any
|
||||
// cached credential and forces a fresh password prompt. This is upstream makepkg's own
|
||||
// design, not something ensureSudoSession's ticket caching can avoid, and it happens once
|
||||
// per root escalation makepkg performs (dependency install, then package install), so a
|
||||
// clean install (yay never already present) will ask for the password again here — possibly
|
||||
// more than once.
|
||||
console.log("Building yay from source (one-time) — you may be asked for your password again during this step.");
|
||||
run("makepkg", ["-si", "--noconfirm"], { cwd: tmp });
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
@@ -136,6 +153,24 @@ export function writeAutostartConfig(execCmds: string[]) {
|
||||
writeFileSync(join(dir, "autostart.lua"), `hl.on("hyprland.start", function()\n${body}\nend)\n`);
|
||||
}
|
||||
|
||||
// Autostart commands for whichever of these package names are present. Assumes chosen
|
||||
// package names == state.dirs (true today: no package sets a custom `dir`). If a future
|
||||
// package needs dir != name AND is autostart-relevant, store chosen names in state.json too.
|
||||
export function autostartCmdsFor(names: string[]): string[] {
|
||||
const set = new Set(names);
|
||||
const cmds = ["waybar", "mako", "hyprpaper"].filter((name) => set.has(name));
|
||||
if (set.has("hyprlauncher")) cmds.push("hyprlauncher -d");
|
||||
return cmds;
|
||||
}
|
||||
|
||||
// SUPER+T's target — the static hyprland.lua no longer hardcodes a terminal, since kitty/foot
|
||||
// are both optional and either, both, or neither may be installed.
|
||||
export function writeTerminalConfig(terminal: string) {
|
||||
const dir = hyprConfD();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "terminal.lua"), `hl.bind("SUPER + T", hl.dsp.exec_cmd("${terminal}"))\n`);
|
||||
}
|
||||
|
||||
export function writeLocaleConfig() {
|
||||
const dir = hyprConfD();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { ensureSudoSession, readState, restow, run } from "./stow.js";
|
||||
import { autostartCmdsFor, ensureSudoSession, readState, restow, run, writeAutostartConfig } from "./stow.js";
|
||||
|
||||
export async function update({ yes = false }: { yes?: boolean } = {}) {
|
||||
p.intro("Hyprland dotfiles update");
|
||||
@@ -18,5 +18,6 @@ export async function update({ yes = false }: { yes?: boolean } = {}) {
|
||||
run("sudo", ["pacman", "-Syu"]);
|
||||
run("yay", ["-Syu"]);
|
||||
restow(dirs);
|
||||
writeAutostartConfig(autostartCmdsFor(dirs));
|
||||
p.outro("Done.");
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ process.env.HOME = fakeHome;
|
||||
after(() => rmSync(fakeHome, { recursive: true, force: true }));
|
||||
|
||||
const {
|
||||
autostartCmdsFor,
|
||||
backupConflicts,
|
||||
restoreLatestBackup,
|
||||
restow,
|
||||
@@ -42,6 +43,13 @@ const dirs = ["hyprland", "waybar", "kitty", "hyprpaper"];
|
||||
const hyprlandTarget = join(fakeHome, ".config", "hypr", "hyprland.lua");
|
||||
const wallpaperTarget = join(fakeHome, ".config", "hypr", "wallpapers", "wallpaper.png");
|
||||
|
||||
test("autostartCmdsFor picks known autostart packages and appends hyprlauncher -d", { skip }, () => {
|
||||
assert.deepStrictEqual(
|
||||
autostartCmdsFor(["hyprland", "mako", "hyprlauncher"]),
|
||||
["mako", "hyprlauncher -d"],
|
||||
);
|
||||
});
|
||||
|
||||
test("seeds a pre-existing conflicting file", { skip }, () => {
|
||||
mkdirSync(join(fakeHome, ".config", "hypr"), { recursive: true });
|
||||
writeFileSync(hyprlandTarget, "old config\n");
|
||||
|
||||
Reference in New Issue
Block a user