diff --git a/nixos/flake.nix b/nixos/flake.nix index 85960abc..9ea98f44 100644 --- a/nixos/flake.nix +++ b/nixos/flake.nix @@ -225,31 +225,7 @@ ... }: let # Nixpkgs PR patches - just specify PR number and hash - nixpkgsPRPatches = [ - # playwright-cli - { - pr = 490230; - hash = "sha256-vQM2mmIgpxFo3ctjk2tXkfhocXnTuHSEy8iYkTexScc="; - } - # t3code base PR - { - pr = 497465; - hash = "sha256-pFofbc6noAqAq6vF+uxvdAk8cV5lftAREWPJGXue/cg="; - } - { - pr = 492656; - hash = "sha256-0TGZ12iIfSYs6cs5kgWDAyiThJdlLMhqRGUscVQv5hU="; - } - # claude-code - # { - # pr = 464698; - # hash = "sha256-Pe9G6b/rI0874mM7FIOSEKiaubk95NcFhTQ7paAeLTU="; - # } - # { - # pr = 464816; - # hash = "sha256-bKEoRy4dzP5TyRBjYskwEzr7tj8/ez/Y1XHiQgu5q5I="; - # } - ]; + nixpkgsPRPatches = [ ]; # Custom patches that don't fit the PR template nixpkgsCustomPatches = [ ]; @@ -361,10 +337,6 @@ name = "nixpkgs-patched"; src = nixpkgs; patches = map bootstrapPkgs.fetchpatch allNixpkgsPatches; - prePatch = '' - mkdir -p pkgs/by-name/an/antigravity - mkdir -p pkgs/by-name/pl/playwright-cli - ''; }; # Get eval-config from patched source evalConfig = import "${patchedSource}/nixos/lib/eval-config.nix"; diff --git a/nixos/overlay.nix b/nixos/overlay.nix index dc1ba6d1..0c17ff40 100644 --- a/nixos/overlay.nix +++ b/nixos/overlay.nix @@ -254,6 +254,10 @@ in }; }); + happy-coder = final.callPackage ./packages/happy-coder { }; + playwright-cli = final.callPackage ./packages/playwright-cli { }; + t3code = final.callPackage ./packages/t3code { }; + # Custom Waybar fork for workspace taskbar support + external SNI watcher option. waybar = prev.waybar.overrideAttrs (old: { src = prev.fetchFromGitHub { diff --git a/nixos/packages/happy-coder/default.nix b/nixos/packages/happy-coder/default.nix new file mode 100644 index 00000000..6f2a742c --- /dev/null +++ b/nixos/packages/happy-coder/default.nix @@ -0,0 +1,119 @@ +{ + lib, + stdenv, + fetchFromGitHub, + fetchYarnDeps, + yarnConfigHook, + nodejs, + makeWrapper, +}: + +stdenv.mkDerivation ( + finalAttrs: + let + toolArchiveSuffix = + if stdenv.hostPlatform.isLinux then + if stdenv.hostPlatform.isAarch64 then + "arm64-linux" + else if stdenv.hostPlatform.isx86_64 then + "x64-linux" + else + throw "Unsupported Linux architecture for happy-coder: ${stdenv.hostPlatform.system}" + else if stdenv.hostPlatform.isDarwin then + if stdenv.hostPlatform.isAarch64 then + "arm64-darwin" + else if stdenv.hostPlatform.isx86_64 then + "x64-darwin" + else + throw "Unsupported Darwin architecture for happy-coder: ${stdenv.hostPlatform.system}" + else + throw "Unsupported platform for happy-coder: ${stdenv.hostPlatform.system}"; + in + { + pname = "happy-coder"; + version = "0.11.2-unstable-2026-03-26"; + + src = fetchFromGitHub { + owner = "slopus"; + repo = "happy"; + rev = "94a6bdc7b41e96b878a5ca0f8a2becdfe5a7f219"; + hash = "sha256-kcZq8raSM111wb58Uk3cyhQ5MrwtwV8zUQx+2f6kPXA="; + }; + + yarnOfflineCache = fetchYarnDeps { + yarnLock = finalAttrs.src + "/yarn.lock"; + hash = "sha256-VjxmoOVKdOtyRAx0zVgdmLWiXzeqHVbgAXc7D3GFbc8="; + }; + + nativeBuildInputs = [ + nodejs + yarnConfigHook + makeWrapper + ]; + + # Fix a type mismatch in upstream TS sources. + postPatch = '' + substituteInPlace packages/happy-cli/src/agent/acp/runAcp.ts \ + --replace-fail 'formatOptionalDetail(mode.description,' \ + 'formatOptionalDetail(mode.description ?? undefined,' + ''; + + buildPhase = '' + runHook preBuild + + yarn --offline workspace @slopus/happy-wire build + yarn --offline workspace happy build + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + local packageOut="$out/lib/node_modules/happy-coder" + mkdir -p "$packageOut" + mkdir -p "$out/bin" + + cp -r packages/happy-cli/dist "$packageOut/dist" + cp -r packages/happy-cli/bin "$packageOut/bin" + cp -r packages/happy-cli/scripts "$packageOut/scripts" + cp packages/happy-cli/package.json "$packageOut/package.json" + + mkdir -p "$packageOut/tools/archives" "$packageOut/tools/licenses" + cp -r packages/happy-cli/tools/licenses/. "$packageOut/tools/licenses/" + cp packages/happy-cli/tools/archives/difftastic-${toolArchiveSuffix}.tar.gz \ + "$packageOut/tools/archives/" + cp packages/happy-cli/tools/archives/ripgrep-${toolArchiveSuffix}.tar.gz \ + "$packageOut/tools/archives/" + + find node_modules -mindepth 1 -maxdepth 2 -type l -delete + cp -r node_modules "$packageOut/node_modules" + + if [ -d packages/happy-cli/node_modules ]; then + find packages/happy-cli/node_modules -mindepth 1 -maxdepth 2 -type l -delete + cp -rn packages/happy-cli/node_modules/. "$packageOut/node_modules/" + fi + + mkdir -p "$packageOut/node_modules/@slopus/happy-wire" + cp -r packages/happy-wire/dist "$packageOut/node_modules/@slopus/happy-wire/dist" + cp packages/happy-wire/package.json "$packageOut/node_modules/@slopus/happy-wire/package.json" + + find "$packageOut/node_modules" -xtype l -delete + + for bin in happy happy-mcp; do + makeWrapper ${nodejs}/bin/node "$out/bin/$bin" \ + --add-flags "$packageOut/bin/$bin.mjs" + done + + runHook postInstall + ''; + + meta = { + description = "Mobile and web client wrapper for Claude Code and Codex with end-to-end encryption"; + homepage = "https://github.com/slopus/happy"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ onsails ]; + mainProgram = "happy"; + }; + } +) diff --git a/nixos/packages/playwright-cli/default.nix b/nixos/packages/playwright-cli/default.nix new file mode 100644 index 00000000..d53fe28f --- /dev/null +++ b/nixos/packages/playwright-cli/default.nix @@ -0,0 +1,52 @@ +{ + lib, + buildNpmPackage, + fetchFromGitHub, + makeBinaryWrapper, + playwright-driver, + versionCheckHook, + writeShellScript, +}: + +buildNpmPackage (finalAttrs: { + pname = "playwright-cli"; + version = "0.1.1"; + + src = fetchFromGitHub { + owner = "microsoft"; + repo = "playwright-cli"; + tag = "v${finalAttrs.version}"; + hash = "sha256-Ao3phIPinliFDK04u/V3ouuOfwMDVf/qBUpQPESziFQ="; + }; + + npmDepsHash = "sha256-4x3ozVrST6LtLoHl9KtmaOKrkYwCK84fwEREaoNaESc="; + + dontNpmBuild = true; + + # playwright-cli imports playwright/lib/cli/client/program, which current + # nixpkgs playwright-test does not export, so keep the vendored Playwright + # until nixpkgs Playwright is updated to a compatible version. + nativeBuildInputs = [ makeBinaryWrapper ]; + + postFixup = '' + wrapProgram $out/bin/playwright-cli \ + --set-default PLAYWRIGHT_BROWSERS_PATH ${playwright-driver.browsers} + ''; + + doInstallCheck = true; + nativeInstallCheckInputs = [ versionCheckHook ]; + versionCheckProgram = writeShellScript "version-check" '' + "$1" --version >/dev/null + echo "${finalAttrs.version}" + ''; + versionCheckProgramArg = "${placeholder "out"}/bin/playwright-cli"; + + meta = { + description = "Playwright CLI for browser automation"; + homepage = "https://github.com/microsoft/playwright-cli"; + changelog = "https://github.com/microsoft/playwright-cli/releases/tag/v${finalAttrs.version}"; + license = lib.licenses.asl20; + maintainers = with lib.maintainers; [ imalison ]; + mainProgram = "playwright-cli"; + }; +}) diff --git a/nixos/packages/t3code/canonicalize-node-modules.ts b/nixos/packages/t3code/canonicalize-node-modules.ts new file mode 100644 index 00000000..f053b9a4 --- /dev/null +++ b/nixos/packages/t3code/canonicalize-node-modules.ts @@ -0,0 +1,105 @@ +import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"; +import { join, relative } from "path"; + +type Entry = { + dir: string; + version: string; +}; + +async function isDirectory(path: string) { + try { + const info = await lstat(path); + return info.isDirectory(); + } catch { + return false; + } +} + +const isValidSemver = (version: string) => Bun.semver.satisfies(version, "x.x.x"); + +const bunRoot = join(process.cwd(), "node_modules/.bun"); +const linkRoot = join(bunRoot, "node_modules"); +const directories = (await readdir(bunRoot)).sort(); + +const versions = new Map(); + +for (const entry of directories) { + const full = join(bunRoot, entry); + if (!(await isDirectory(full))) { + continue; + } + + const parsed = parseEntry(entry); + if (!parsed) { + continue; + } + + const list = versions.get(parsed.name) ?? []; + list.push({ dir: full, version: parsed.version }); + versions.set(parsed.name, list); +} + +const selections = new Map(); + +for (const [slug, list] of versions) { + list.sort((a, b) => { + const aValid = isValidSemver(a.version); + const bValid = isValidSemver(b.version); + + if (aValid && bValid) { + return -Bun.semver.order(a.version, b.version); + } + if (aValid) { + return -1; + } + if (bValid) { + return 1; + } + return b.version.localeCompare(a.version); + }); + + const first = list[0]; + if (first) { + selections.set(slug, first); + } +} + +await rm(linkRoot, { recursive: true, force: true }); +await mkdir(linkRoot, { recursive: true }); + +for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0].localeCompare(b[0]))) { + const parts = slug.split("/"); + const leaf = parts.pop(); + if (!leaf) { + continue; + } + + const parent = join(linkRoot, ...parts); + await mkdir(parent, { recursive: true }); + + const linkPath = join(parent, leaf); + const desired = join(entry.dir, "node_modules", slug); + if (!(await isDirectory(desired))) { + continue; + } + + const relativeTarget = relative(parent, desired); + await rm(linkPath, { recursive: true, force: true }); + await symlink(relativeTarget.length == 0 ? "." : relativeTarget, linkPath); +} + +function parseEntry(label: string) { + const marker = label.startsWith("@") ? label.indexOf("@", 1) : label.indexOf("@"); + if (marker <= 0) { + return null; + } + + const name = label.slice(0, marker).replace(/\+/g, "/"); + const version = label.slice(marker + 1); + + if (!name || !version) { + return null; + } + + return { name, version }; +} diff --git a/nixos/packages/t3code/default.nix b/nixos/packages/t3code/default.nix new file mode 100644 index 00000000..616c62f1 --- /dev/null +++ b/nixos/packages/t3code/default.nix @@ -0,0 +1,250 @@ +{ + buildPackages, + lib, + stdenv, + stdenvNoCC, + fetchFromGitHub, + nix-update-script, + makeDesktopItem, + electron_40, + nodejs, +}: + +stdenv.mkDerivation ( + finalAttrs: + let + electron = electron_40; + nodeModules = stdenvNoCC.mkDerivation { + pname = "${finalAttrs.pname}-node_modules"; + inherit (finalAttrs) src version strictDeps; + + nativeBuildInputs = [ + buildPackages.bun + buildPackages.nodejs + buildPackages.writableTmpDirAsHomeHook + ]; + + dontConfigure = true; + dontFixup = true; + + postPatch = '' + replacePackageVersion() { + local packageJson="$1" + local currentVersion="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$packageJson" | head -n1)" + local currentVersionPattern + + printf -v currentVersionPattern '"version": "%s"' "$currentVersion" + + substituteInPlace "$packageJson" \ + --replace-fail "$currentVersionPattern" '"version": "${finalAttrs.version}"' + } + + for packageJson in \ + apps/{desktop,server,web}/package.json \ + packages/{contracts,shared}/package.json + do + replacePackageVersion "$packageJson" + done + + for packageJson in packages/{contracts,shared}/package.json; do + substituteInPlace "$packageJson" \ + --replace-fail '"prepare": "effect-language-service patch",' '"prepare": "true",' + done + ''; + + buildPhase = '' + runHook preBuild + + bun install \ + --cpu="*" \ + --ignore-scripts \ + --no-progress \ + --frozen-lockfile \ + --os="*" + + bun --bun ${./canonicalize-node-modules.ts} + bun --bun ${./normalize-bun-binaries.ts} + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out + cp -r node_modules $out + find apps packages -type d -name node_modules -exec cp -r --parents {} $out \; + + runHook postInstall + ''; + + outputHash = "sha256-yrzdhw+NPYZku10piHoxMy+TUJ8MYySZorMOMOztJY4="; + outputHashMode = "recursive"; + }; + in + { + pname = "t3code"; + version = "0.0.15"; + strictDeps = true; + + src = fetchFromGitHub { + owner = "pingdotgg"; + repo = "t3code"; + tag = "v${finalAttrs.version}"; + hash = "sha256-HOPiA8X/FzswKGmOuYKog3YIn5iq5rJ/7kDoGhN11x0="; + }; + + postPatch = '' + replacePackageVersion() { + local packageJson="$1" + local currentVersion="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$packageJson" | head -n1)" + local currentVersionPattern + + printf -v currentVersionPattern '"version": "%s"' "$currentVersion" + + substituteInPlace "$packageJson" \ + --replace-fail "$currentVersionPattern" '"version": "${finalAttrs.version}"' + } + + for packageJson in \ + apps/{desktop,server,web}/package.json \ + packages/contracts/package.json + do + replacePackageVersion "$packageJson" + done + + printf -v resourcePathSearch '%s\n%s' \ + 'Path.join(__dirname, "../prod-resources", fileName),' \ + ' Path.join(process.resourcesPath, "resources", fileName),' + printf -v resourcePathReplacement '%s\n%s\n%s' \ + 'Path.join(__dirname, "../prod-resources", fileName),' \ + ' Path.join(ROOT_DIR, "apps", "desktop", "prod-resources", fileName),' \ + ' Path.join(process.resourcesPath, "resources", fileName),' + + substituteInPlace apps/desktop/src/main.ts \ + --replace-fail "$resourcePathSearch" "$resourcePathReplacement" + + substituteInPlace apps/web/vite.config.ts \ + --replace-fail ' server: {' $' server: {\n host: "127.0.0.1",' \ + --replace-fail 'host: "localhost"' 'host: "127.0.0.1"' + ''; + + nativeBuildInputs = [ + buildPackages.bun + buildPackages.copyDesktopItems + buildPackages.installShellFiles + buildPackages.makeBinaryWrapper + buildPackages.node-gyp + buildPackages.nodejs + buildPackages.python3 + buildPackages.writableTmpDirAsHomeHook + ] ++ lib.optionals stdenv.buildPlatform.isDarwin [ + buildPackages.cctools.libtool + buildPackages.xcbuild + ]; + + nativeInstallCheckInputs = [ buildPackages.versionCheckHook ]; + doInstallCheck = stdenv.buildPlatform.canExecute stdenv.hostPlatform; + + configurePhase = '' + runHook preConfigure + + cp -r ${nodeModules}/. . + + chmod -R u+rwX node_modules + patchShebangs node_modules + + export npm_config_nodedir=${nodejs} + cd node_modules/.bun/node-pty@*/node_modules/node-pty + node-gyp rebuild + node scripts/post-install.js + cd - + + runHook postConfigure + ''; + + buildPhase = '' + runHook preBuild + + for app in web server desktop; do + bun run --cwd apps/"$app" build + done + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p "$out"/libexec/t3code/apps/desktop "$out"/libexec/t3code/apps/server + cp -r --no-preserve=mode node_modules "$out"/libexec/t3code + cp -r --no-preserve=mode apps/server/{node_modules,dist} "$out"/libexec/t3code/apps/server + cp -r --no-preserve=mode apps/desktop/{node_modules,dist-electron} "$out"/libexec/t3code/apps/desktop + + mkdir -p "$out"/libexec/t3code/apps/desktop/prod-resources + install -m444 assets/prod/black-universal-1024.png \ + "$out"/libexec/t3code/apps/desktop/prod-resources/icon.png + + find "$out"/libexec/t3code -xtype l -delete + + makeWrapper ${lib.getExe nodejs} "$out"/bin/t3code \ + --add-flags "$out"/libexec/t3code/apps/server/dist/index.mjs + + makeWrapper ${lib.getExe electron} "$out"/bin/t3code-desktop \ + --add-flags "$out"/libexec/t3code/apps/desktop/dist-electron/main.js \ + --inherit-argv0 + + mkdir -p \ + "$out"/share/icons/hicolor/1024x1024/apps \ + "$out"/share/icons/hicolor/scalable/apps + install -m444 assets/prod/black-universal-1024.png \ + "$out"/share/icons/hicolor/1024x1024/apps/t3code.png + install -m444 assets/prod/logo.svg \ + "$out"/share/icons/hicolor/scalable/apps/t3code.svg + + runHook postInstall + ''; + + postInstall = lib.optionalString (stdenv.buildPlatform.canExecute stdenv.hostPlatform) '' + installShellCompletion --cmd t3code \ + --bash <("$out"/bin/t3code --completions bash) \ + --fish <("$out"/bin/t3code --completions fish) \ + --zsh <("$out"/bin/t3code --completions zsh) + ''; + + desktopItems = [ + (makeDesktopItem { + name = "t3code"; + desktopName = "T3 Code"; + comment = finalAttrs.meta.description; + exec = "t3code-desktop %U"; + terminal = false; + icon = "t3code"; + startupWMClass = "T3 Code"; + categories = [ "Development" ]; + }) + ]; + + passthru = { + inherit nodeModules; + updateScript = nix-update-script { + extraArgs = [ + "--subpackage" + "nodeModules" + ]; + }; + }; + + meta = { + description = "Minimal web GUI for coding agents"; + homepage = "https://t3.codes"; + inherit (nodejs.meta) platforms; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ + imalison + qweered + ]; + mainProgram = "t3code"; + }; + } +) diff --git a/nixos/packages/t3code/normalize-bun-binaries.ts b/nixos/packages/t3code/normalize-bun-binaries.ts new file mode 100644 index 00000000..231b673d --- /dev/null +++ b/nixos/packages/t3code/normalize-bun-binaries.ts @@ -0,0 +1,135 @@ +import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"; +import { join, relative } from "path"; + +type PackageManifest = { + name?: string; + bin?: string | Record; +}; + +const bunRoot = join(process.cwd(), "node_modules/.bun"); +const bunEntries = (await readdir(bunRoot)).sort(); + +for (const entry of bunEntries) { + const modulesRoot = join(bunRoot, entry, "node_modules"); + if (!(await exists(modulesRoot))) { + continue; + } + + const binRoot = join(modulesRoot, ".bin"); + await rm(binRoot, { recursive: true, force: true }); + await mkdir(binRoot, { recursive: true }); + + const packageDirs = await collectPackages(modulesRoot); + for (const packageDir of packageDirs) { + const manifest = await readManifest(packageDir); + if (!manifest?.bin) { + continue; + } + + const seen = new Set(); + if (typeof manifest.bin == "string") { + const fallback = manifest.name ?? packageDir.split("/").pop(); + if (fallback) { + await linkBinary(binRoot, fallback, packageDir, manifest.bin, seen); + } + continue; + } + + const entries = Object.entries(manifest.bin).sort((a, b) => a[0].localeCompare(b[0])); + for (const [name, target] of entries) { + await linkBinary(binRoot, name, packageDir, target, seen); + } + } +} + +async function collectPackages(modulesRoot: string) { + const found: string[] = []; + const topLevel = (await readdir(modulesRoot)).sort(); + + for (const name of topLevel) { + if (name == ".bin" || name == ".bun") { + continue; + } + + const full = join(modulesRoot, name); + if (!(await isDirectory(full))) { + continue; + } + + if (name.startsWith("@")) { + const scoped = (await readdir(full)).sort(); + for (const child of scoped) { + const scopedDir = join(full, child); + if (await isDirectory(scopedDir)) { + found.push(scopedDir); + } + } + continue; + } + + found.push(full); + } + + return found.sort(); +} + +async function readManifest(dir: string) { + const file = Bun.file(join(dir, "package.json")); + if (!(await file.exists())) { + return null; + } + + return (await file.json()) as PackageManifest; +} + +async function linkBinary( + binRoot: string, + name: string, + packageDir: string, + target: string, + seen: Set, +) { + if (!name || !target) { + return; + } + + const normalizedName = normalizeBinName(name); + if (seen.has(normalizedName)) { + return; + } + + const resolved = join(packageDir, target); + const script = Bun.file(resolved); + if (!(await script.exists())) { + return; + } + + seen.add(normalizedName); + const destination = join(binRoot, normalizedName); + const relativeTarget = relative(binRoot, resolved); + await rm(destination, { force: true }); + await symlink(relativeTarget.length == 0 ? "." : relativeTarget, destination); +} + +async function exists(path: string) { + try { + await lstat(path); + return true; + } catch { + return false; + } +} + +async function isDirectory(path: string) { + try { + const info = await lstat(path); + return info.isDirectory(); + } catch { + return false; + } +} + +function normalizeBinName(name: string) { + const slash = name.lastIndexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +}