feat(nix): overhaul nix flake and packages (#9032)
parent
5009f10406
commit
dac099a489
|
|
@ -19,84 +19,7 @@ on:
|
||||||
- ".github/workflows/update-nix-hashes.yml"
|
- ".github/workflows/update-nix-hashes.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-flake:
|
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
env:
|
|
||||||
TITLE: flake.lock
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.head_ref || github.ref_name }}
|
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
|
|
||||||
|
|
||||||
- name: Setup Nix
|
|
||||||
uses: nixbuild/nix-quick-install-action@v34
|
|
||||||
|
|
||||||
- name: Configure git
|
|
||||||
run: |
|
|
||||||
git config --global user.email "action@github.com"
|
|
||||||
git config --global user.name "Github Action"
|
|
||||||
|
|
||||||
- name: Update ${{ env.TITLE }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "Updating $TITLE..."
|
|
||||||
nix flake update
|
|
||||||
echo "$TITLE updated successfully"
|
|
||||||
|
|
||||||
- name: Commit ${{ env.TITLE }} changes
|
|
||||||
env:
|
|
||||||
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "Checking for changes in tracked files..."
|
|
||||||
|
|
||||||
summarize() {
|
|
||||||
local status="$1"
|
|
||||||
{
|
|
||||||
echo "### Nix $TITLE"
|
|
||||||
echo ""
|
|
||||||
echo "- ref: ${GITHUB_REF_NAME}"
|
|
||||||
echo "- status: ${status}"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
|
|
||||||
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
fi
|
|
||||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
}
|
|
||||||
FILES=(flake.lock flake.nix)
|
|
||||||
STATUS="$(git status --short -- "${FILES[@]}" || true)"
|
|
||||||
if [ -z "$STATUS" ]; then
|
|
||||||
echo "No changes detected."
|
|
||||||
summarize "no changes"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Changes detected:"
|
|
||||||
echo "$STATUS"
|
|
||||||
echo "Staging files..."
|
|
||||||
git add "${FILES[@]}"
|
|
||||||
echo "Committing changes..."
|
|
||||||
git commit -m "Update $TITLE"
|
|
||||||
echo "Changes committed"
|
|
||||||
|
|
||||||
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
|
||||||
echo "Pulling latest from branch: $BRANCH"
|
|
||||||
git pull --rebase --autostash origin "$BRANCH"
|
|
||||||
echo "Pushing changes to branch: $BRANCH"
|
|
||||||
git push origin HEAD:"$BRANCH"
|
|
||||||
echo "Changes pushed successfully"
|
|
||||||
|
|
||||||
summarize "committed $(git rev-parse --short HEAD)"
|
|
||||||
|
|
||||||
compute-node-modules-hash:
|
compute-node-modules-hash:
|
||||||
needs: update-flake
|
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1768456270,
|
"lastModified": 1768302833,
|
||||||
"narHash": "sha256-NgaL2CCiUR6nsqUIY4yxkzz07iQUlUCany44CFv+OxY=",
|
"narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "f4606b01b39e09065df37905a2133905246db9ed",
|
"rev": "61db79b0c6b838d9894923920b612048e1201926",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
110
flake.nix
110
flake.nix
|
|
@ -6,11 +6,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
{
|
{ self, nixpkgs, ... }:
|
||||||
self,
|
|
||||||
nixpkgs,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
let
|
let
|
||||||
systems = [
|
systems = [
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
|
|
@ -18,99 +14,35 @@
|
||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
"x86_64-darwin"
|
"x86_64-darwin"
|
||||||
];
|
];
|
||||||
inherit (nixpkgs) lib;
|
forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||||
forEachSystem = lib.genAttrs systems;
|
rev = self.shortRev or self.dirtyShortRev or "dirty";
|
||||||
pkgsFor = system: nixpkgs.legacyPackages.${system};
|
|
||||||
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
|
|
||||||
bunTarget = {
|
|
||||||
"aarch64-linux" = "bun-linux-arm64";
|
|
||||||
"x86_64-linux" = "bun-linux-x64";
|
|
||||||
"aarch64-darwin" = "bun-darwin-arm64";
|
|
||||||
"x86_64-darwin" = "bun-darwin-x64";
|
|
||||||
};
|
|
||||||
|
|
||||||
# Parse "bun-{os}-{cpu}" to {os, cpu}
|
|
||||||
parseBunTarget =
|
|
||||||
target:
|
|
||||||
let
|
|
||||||
parts = lib.splitString "-" target;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
os = builtins.elemAt parts 1;
|
|
||||||
cpu = builtins.elemAt parts 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
hashesFile = "${./nix}/hashes.json";
|
|
||||||
hashesData =
|
|
||||||
if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { };
|
|
||||||
# Lookup hash: supports per-system ({system: hash}) or legacy single hash
|
|
||||||
nodeModulesHashFor =
|
|
||||||
system:
|
|
||||||
if builtins.isAttrs hashesData.nodeModules then
|
|
||||||
hashesData.nodeModules.${system}
|
|
||||||
else
|
|
||||||
hashesData.nodeModules;
|
|
||||||
modelsDev = forEachSystem (
|
|
||||||
system:
|
|
||||||
let
|
|
||||||
pkgs = pkgsFor system;
|
|
||||||
in
|
|
||||||
pkgs."models-dev"
|
|
||||||
);
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells = forEachSystem (
|
devShells = forEachSystem (pkgs: {
|
||||||
system:
|
default = pkgs.mkShell {
|
||||||
let
|
packages = with pkgs; [
|
||||||
pkgs = pkgsFor system;
|
bun
|
||||||
in
|
nodejs_20
|
||||||
{
|
pkg-config
|
||||||
default = pkgs.mkShell {
|
openssl
|
||||||
packages = with pkgs; [
|
git
|
||||||
bun
|
];
|
||||||
nodejs_20
|
};
|
||||||
pkg-config
|
});
|
||||||
openssl
|
|
||||||
git
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
packages = forEachSystem (
|
packages = forEachSystem (
|
||||||
system:
|
pkgs:
|
||||||
let
|
let
|
||||||
pkgs = pkgsFor system;
|
opencode = pkgs.callPackage ./nix/opencode.nix {
|
||||||
bunPlatform = parseBunTarget bunTarget.${system};
|
inherit rev;
|
||||||
mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
|
|
||||||
hash = nodeModulesHashFor system;
|
|
||||||
bunCpu = bunPlatform.cpu;
|
|
||||||
bunOs = bunPlatform.os;
|
|
||||||
};
|
};
|
||||||
mkOpencode = pkgs.callPackage ./nix/opencode.nix { };
|
desktop = pkgs.callPackage ./nix/desktop.nix {
|
||||||
mkDesktop = pkgs.callPackage ./nix/desktop.nix { };
|
inherit opencode;
|
||||||
|
|
||||||
opencodePkg = mkOpencode {
|
|
||||||
inherit (packageJson) version;
|
|
||||||
src = ./.;
|
|
||||||
scripts = ./nix/scripts;
|
|
||||||
target = bunTarget.${system};
|
|
||||||
modelsDev = "${modelsDev.${system}}/dist/_api.json";
|
|
||||||
inherit mkNodeModules;
|
|
||||||
};
|
|
||||||
|
|
||||||
desktopPkg = mkDesktop {
|
|
||||||
inherit (packageJson) version;
|
|
||||||
src = ./.;
|
|
||||||
scripts = ./nix/scripts;
|
|
||||||
mkNodeModules = mkNodeModules;
|
|
||||||
opencode = opencodePkg;
|
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = self.packages.${system}.opencode;
|
default = opencode;
|
||||||
opencode = opencodePkg;
|
inherit opencode desktop;
|
||||||
desktop = desktopPkg;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import solidPlugin from "./node_modules/@opentui/solid/scripts/solid-plugin"
|
|
||||||
import path from "path"
|
|
||||||
import fs from "fs"
|
|
||||||
|
|
||||||
const dir = process.cwd()
|
|
||||||
const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser.worker.js"))
|
|
||||||
const worker = "./src/cli/cmd/tui/worker.ts"
|
|
||||||
const version = process.env.OPENCODE_VERSION ?? "local"
|
|
||||||
const channel = process.env.OPENCODE_CHANNEL ?? "local"
|
|
||||||
|
|
||||||
fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true })
|
|
||||||
|
|
||||||
const result = await Bun.build({
|
|
||||||
entrypoints: ["./src/index.ts", worker, parser],
|
|
||||||
outdir: "./dist",
|
|
||||||
target: "bun",
|
|
||||||
sourcemap: "none",
|
|
||||||
tsconfig: "./tsconfig.json",
|
|
||||||
plugins: [solidPlugin],
|
|
||||||
external: ["@opentui/core"],
|
|
||||||
define: {
|
|
||||||
OPENCODE_VERSION: `'${version}'`,
|
|
||||||
OPENCODE_CHANNEL: `'${channel}'`,
|
|
||||||
// Leave undefined so runtime picks bundled/dist worker or fallback in code.
|
|
||||||
OPENCODE_WORKER_PATH: "undefined",
|
|
||||||
OTUI_TREE_SITTER_WORKER_PATH: 'new URL("./cli/cmd/tui/parser.worker.js", import.meta.url).href',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
console.error("bundle failed")
|
|
||||||
for (const log of result.logs) console.error(log)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parserOut = path.join(dir, "dist/src/cli/cmd/tui/parser.worker.js")
|
|
||||||
fs.mkdirSync(path.dirname(parserOut), { recursive: true })
|
|
||||||
await Bun.write(parserOut, Bun.file(parser))
|
|
||||||
191
nix/desktop.nix
191
nix/desktop.nix
|
|
@ -2,166 +2,99 @@
|
||||||
lib,
|
lib,
|
||||||
stdenv,
|
stdenv,
|
||||||
rustPlatform,
|
rustPlatform,
|
||||||
bun,
|
|
||||||
pkg-config,
|
pkg-config,
|
||||||
dbus ? null,
|
cargo-tauri,
|
||||||
openssl,
|
bun,
|
||||||
glib ? null,
|
nodejs,
|
||||||
gtk3 ? null,
|
|
||||||
libsoup_3 ? null,
|
|
||||||
webkitgtk_4_1 ? null,
|
|
||||||
librsvg ? null,
|
|
||||||
libappindicator-gtk3 ? null,
|
|
||||||
cargo,
|
cargo,
|
||||||
rustc,
|
rustc,
|
||||||
makeBinaryWrapper,
|
|
||||||
copyDesktopItems,
|
|
||||||
makeDesktopItem,
|
|
||||||
nodejs,
|
|
||||||
jq,
|
jq,
|
||||||
|
wrapGAppsHook4,
|
||||||
|
makeWrapper,
|
||||||
|
dbus,
|
||||||
|
glib,
|
||||||
|
gtk4,
|
||||||
|
libsoup_3,
|
||||||
|
librsvg,
|
||||||
|
libappindicator,
|
||||||
|
glib-networking,
|
||||||
|
openssl,
|
||||||
|
webkitgtk_4_1,
|
||||||
|
gst_all_1,
|
||||||
|
opencode,
|
||||||
}:
|
}:
|
||||||
args:
|
rustPlatform.buildRustPackage (finalAttrs: {
|
||||||
let
|
|
||||||
scripts = args.scripts;
|
|
||||||
mkModules =
|
|
||||||
attrs:
|
|
||||||
args.mkNodeModules (
|
|
||||||
attrs
|
|
||||||
// {
|
|
||||||
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
|
|
||||||
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
|
|
||||||
}
|
|
||||||
);
|
|
||||||
in
|
|
||||||
rustPlatform.buildRustPackage rec {
|
|
||||||
pname = "opencode-desktop";
|
pname = "opencode-desktop";
|
||||||
version = args.version;
|
inherit (opencode)
|
||||||
|
version
|
||||||
|
src
|
||||||
|
node_modules
|
||||||
|
patches
|
||||||
|
;
|
||||||
|
|
||||||
src = args.src;
|
cargoRoot = "packages/desktop/src-tauri";
|
||||||
|
cargoLock.lockFile = ../packages/desktop/src-tauri/Cargo.lock;
|
||||||
# We need to set the root for cargo, but we also need access to the whole repo.
|
buildAndTestSubdir = finalAttrs.cargoRoot;
|
||||||
postUnpack = ''
|
|
||||||
# Update sourceRoot to point to the tauri app
|
|
||||||
sourceRoot+=/packages/desktop/src-tauri
|
|
||||||
'';
|
|
||||||
|
|
||||||
cargoLock = {
|
|
||||||
lockFile = ../packages/desktop/src-tauri/Cargo.lock;
|
|
||||||
allowBuiltinFetchGit = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
node_modules = mkModules {
|
|
||||||
version = version;
|
|
||||||
src = src;
|
|
||||||
};
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
pkg-config
|
pkg-config
|
||||||
|
cargo-tauri.hook
|
||||||
bun
|
bun
|
||||||
makeBinaryWrapper
|
nodejs # for patchShebangs node_modules
|
||||||
copyDesktopItems
|
|
||||||
cargo
|
cargo
|
||||||
rustc
|
rustc
|
||||||
nodejs
|
|
||||||
jq
|
jq
|
||||||
];
|
makeWrapper
|
||||||
|
|
||||||
# based on packages/desktop/src-tauri/release/appstream.metainfo.xml
|
|
||||||
desktopItems = lib.optionals stdenv.isLinux [
|
|
||||||
(makeDesktopItem {
|
|
||||||
name = "ai.opencode.opencode";
|
|
||||||
desktopName = "OpenCode";
|
|
||||||
comment = "Open source AI coding agent";
|
|
||||||
exec = "opencode-desktop";
|
|
||||||
icon = "opencode";
|
|
||||||
terminal = false;
|
|
||||||
type = "Application";
|
|
||||||
categories = [ "Development" "IDE" ];
|
|
||||||
startupWMClass = "opencode";
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
buildInputs = [
|
|
||||||
openssl
|
|
||||||
]
|
]
|
||||||
++ lib.optionals stdenv.isLinux [
|
++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
|
||||||
|
|
||||||
|
buildInputs = lib.optionals stdenv.isLinux [
|
||||||
dbus
|
dbus
|
||||||
glib
|
glib
|
||||||
gtk3
|
gtk4
|
||||||
libsoup_3
|
libsoup_3
|
||||||
webkitgtk_4_1
|
|
||||||
librsvg
|
librsvg
|
||||||
libappindicator-gtk3
|
libappindicator
|
||||||
|
glib-networking
|
||||||
|
openssl
|
||||||
|
webkitgtk_4_1
|
||||||
|
gst_all_1.gstreamer
|
||||||
|
gst_all_1.gst-plugins-base
|
||||||
|
gst_all_1.gst-plugins-good
|
||||||
];
|
];
|
||||||
|
|
||||||
|
strictDeps = true;
|
||||||
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
# Restore node_modules
|
cp -a ${finalAttrs.node_modules}/{node_modules,packages} .
|
||||||
pushd ../../..
|
chmod -R u+w node_modules packages
|
||||||
|
|
||||||
# Copy node_modules from the fixed-output derivation
|
|
||||||
# We use cp -r --no-preserve=mode to ensure we can write to them if needed,
|
|
||||||
# though we usually just read.
|
|
||||||
cp -r ${node_modules}/node_modules .
|
|
||||||
cp -r ${node_modules}/packages .
|
|
||||||
|
|
||||||
# Ensure node_modules is writable so patchShebangs can update script headers
|
|
||||||
chmod -R u+w node_modules
|
|
||||||
# Ensure workspace packages are writable for tsgo incremental outputs (.tsbuildinfo)
|
|
||||||
chmod -R u+w packages
|
|
||||||
# Patch shebangs so scripts can run
|
|
||||||
patchShebangs node_modules
|
patchShebangs node_modules
|
||||||
|
patchShebangs packages/desktop/node_modules
|
||||||
|
|
||||||
# Copy sidecar
|
|
||||||
mkdir -p packages/desktop/src-tauri/sidecars
|
mkdir -p packages/desktop/src-tauri/sidecars
|
||||||
targetTriple=${stdenv.hostPlatform.rust.rustcTarget}
|
cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget}
|
||||||
cp ${args.opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-$targetTriple
|
|
||||||
|
|
||||||
# Merge prod config into tauri.conf.json
|
|
||||||
if ! jq -s '.[0] * .[1]' \
|
|
||||||
packages/desktop/src-tauri/tauri.conf.json \
|
|
||||||
packages/desktop/src-tauri/tauri.prod.conf.json \
|
|
||||||
> packages/desktop/src-tauri/tauri.conf.json.tmp; then
|
|
||||||
echo "Error: failed to merge tauri.conf.json with tauri.prod.conf.json" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mv packages/desktop/src-tauri/tauri.conf.json.tmp packages/desktop/src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
# Build the frontend
|
|
||||||
cd packages/desktop
|
|
||||||
|
|
||||||
# The 'build' script runs 'bun run typecheck && vite build'.
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
popd
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Tauri bundles the assets during the rust build phase (which happens after preBuild).
|
# see publish-tauri job in .github/workflows/publish.yml
|
||||||
# It looks for them in the location specified in tauri.conf.json.
|
tauriBuildFlags = [
|
||||||
|
"--config"
|
||||||
|
"tauri.prod.conf.json"
|
||||||
|
"--no-sign" # no code signing or auto updates
|
||||||
|
];
|
||||||
|
|
||||||
postInstall = lib.optionalString stdenv.isLinux ''
|
# FIXME: workaround for concerns about case insensitive filesystems
|
||||||
# Install icon
|
# should be removed once binary is renamed or decided otherwise
|
||||||
mkdir -p $out/share/icons/hicolor/128x128/apps
|
# darwin output is a .app bundle so no conflict
|
||||||
cp ../../../packages/desktop/src-tauri/icons/prod/128x128.png $out/share/icons/hicolor/128x128/apps/opencode.png
|
postFixup = lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||||
|
mv $out/bin/OpenCode $out/bin/opencode-desktop
|
||||||
# Wrap the binary to ensure it finds the libraries
|
sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop
|
||||||
wrapProgram $out/bin/opencode-desktop \
|
|
||||||
--prefix LD_LIBRARY_PATH : ${
|
|
||||||
lib.makeLibraryPath [
|
|
||||||
gtk3
|
|
||||||
webkitgtk_4_1
|
|
||||||
librsvg
|
|
||||||
glib
|
|
||||||
libsoup_3
|
|
||||||
]
|
|
||||||
}
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
meta = with lib; {
|
meta = {
|
||||||
description = "OpenCode Desktop App";
|
description = "OpenCode Desktop App";
|
||||||
homepage = "https://opencode.ai";
|
homepage = "https://opencode.ai";
|
||||||
license = licenses.mit;
|
license = lib.licenses.mit;
|
||||||
maintainers = with maintainers; [ ];
|
|
||||||
mainProgram = "opencode-desktop";
|
mainProgram = "opencode-desktop";
|
||||||
platforms = platforms.linux ++ platforms.darwin;
|
inherit (opencode.meta) platforms;
|
||||||
};
|
};
|
||||||
}
|
})
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
{
|
|
||||||
hash,
|
|
||||||
lib,
|
|
||||||
stdenvNoCC,
|
|
||||||
bun,
|
|
||||||
cacert,
|
|
||||||
curl,
|
|
||||||
bunCpu,
|
|
||||||
bunOs,
|
|
||||||
}:
|
|
||||||
args:
|
|
||||||
stdenvNoCC.mkDerivation {
|
|
||||||
pname = "opencode-node_modules";
|
|
||||||
inherit (args) version src;
|
|
||||||
|
|
||||||
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
|
|
||||||
"GIT_PROXY_COMMAND"
|
|
||||||
"SOCKS_SERVER"
|
|
||||||
];
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
bun
|
|
||||||
cacert
|
|
||||||
curl
|
|
||||||
];
|
|
||||||
|
|
||||||
dontConfigure = true;
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
export HOME=$(mktemp -d)
|
|
||||||
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
|
|
||||||
bun install \
|
|
||||||
--cpu="${bunCpu}" \
|
|
||||||
--os="${bunOs}" \
|
|
||||||
--frozen-lockfile \
|
|
||||||
--ignore-scripts \
|
|
||||||
--no-progress \
|
|
||||||
--linker=isolated
|
|
||||||
bun --bun ${args.canonicalizeScript}
|
|
||||||
bun --bun ${args.normalizeBinsScript}
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
runHook preInstall
|
|
||||||
mkdir -p $out
|
|
||||||
while IFS= read -r dir; do
|
|
||||||
rel="''${dir#./}"
|
|
||||||
dest="$out/$rel"
|
|
||||||
mkdir -p "$(dirname "$dest")"
|
|
||||||
cp -R "$dir" "$dest"
|
|
||||||
done < <(find . -type d -name node_modules -prune | sort)
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
dontFixup = true;
|
|
||||||
|
|
||||||
outputHashAlgo = "sha256";
|
|
||||||
outputHashMode = "recursive";
|
|
||||||
outputHash = hash;
|
|
||||||
}
|
|
||||||
211
nix/opencode.nix
211
nix/opencode.nix
|
|
@ -2,60 +2,115 @@
|
||||||
lib,
|
lib,
|
||||||
stdenvNoCC,
|
stdenvNoCC,
|
||||||
bun,
|
bun,
|
||||||
ripgrep,
|
sysctl,
|
||||||
makeBinaryWrapper,
|
makeBinaryWrapper,
|
||||||
|
models-dev,
|
||||||
|
ripgrep,
|
||||||
|
installShellFiles,
|
||||||
|
versionCheckHook,
|
||||||
|
writableTmpDirAsHomeHook,
|
||||||
|
rev ? "dirty",
|
||||||
}:
|
}:
|
||||||
args:
|
|
||||||
let
|
let
|
||||||
inherit (args) scripts;
|
packageJson = lib.pipe ../packages/opencode/package.json [
|
||||||
mkModules =
|
builtins.readFile
|
||||||
attrs:
|
builtins.fromJSON
|
||||||
args.mkNodeModules (
|
];
|
||||||
attrs
|
|
||||||
// {
|
|
||||||
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
|
|
||||||
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
|
|
||||||
}
|
|
||||||
);
|
|
||||||
in
|
in
|
||||||
stdenvNoCC.mkDerivation (finalAttrs: {
|
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
pname = "opencode";
|
pname = "opencode";
|
||||||
inherit (args) version src;
|
version = "${packageJson.version}-${rev}";
|
||||||
|
|
||||||
node_modules = mkModules {
|
src = lib.fileset.toSource {
|
||||||
|
root = ../.;
|
||||||
|
fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) (
|
||||||
|
lib.fileset.unions [
|
||||||
|
../packages
|
||||||
|
../bun.lock
|
||||||
|
../package.json
|
||||||
|
../patches
|
||||||
|
../install
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
node_modules = stdenvNoCC.mkDerivation {
|
||||||
|
pname = "${finalAttrs.pname}-node_modules";
|
||||||
inherit (finalAttrs) version src;
|
inherit (finalAttrs) version src;
|
||||||
|
|
||||||
|
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
|
||||||
|
"GIT_PROXY_COMMAND"
|
||||||
|
"SOCKS_SERVER"
|
||||||
|
];
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
bun
|
||||||
|
];
|
||||||
|
|
||||||
|
dontConfigure = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
export HOME=$(mktemp -d)
|
||||||
|
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
|
||||||
|
bun install \
|
||||||
|
--cpu="${if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64"}" \
|
||||||
|
--os="${if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin"}" \
|
||||||
|
--frozen-lockfile \
|
||||||
|
--ignore-scripts \
|
||||||
|
--no-progress \
|
||||||
|
--linker=isolated
|
||||||
|
bun --bun ${./scripts/canonicalize-node-modules.ts}
|
||||||
|
bun --bun ${./scripts/normalize-bun-binaries.ts}
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p $out
|
||||||
|
find . -type d -name node_modules -exec cp -R --parents {} $out \;
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
dontFixup = true;
|
||||||
|
|
||||||
|
outputHashAlgo = "sha256";
|
||||||
|
outputHashMode = "recursive";
|
||||||
|
outputHash =
|
||||||
|
(lib.pipe ./hashes.json [
|
||||||
|
builtins.readFile
|
||||||
|
builtins.fromJSON
|
||||||
|
]).nodeModules.${stdenvNoCC.hostPlatform.system};
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
bun
|
bun
|
||||||
|
installShellFiles
|
||||||
makeBinaryWrapper
|
makeBinaryWrapper
|
||||||
|
models-dev
|
||||||
|
writableTmpDirAsHomeHook
|
||||||
];
|
];
|
||||||
|
|
||||||
env.MODELS_DEV_API_JSON = args.modelsDev;
|
configurePhase = ''
|
||||||
env.OPENCODE_VERSION = args.version;
|
runHook preConfigure
|
||||||
env.OPENCODE_CHANNEL = "stable";
|
|
||||||
dontConfigure = true;
|
cp -R ${finalAttrs.node_modules}/. .
|
||||||
|
|
||||||
|
runHook postConfigure
|
||||||
|
'';
|
||||||
|
|
||||||
|
env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
|
||||||
|
env.OPENCODE_VERSION = finalAttrs.version;
|
||||||
|
env.OPENCODE_CHANNEL = "local";
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
|
|
||||||
cp -r ${finalAttrs.node_modules}/node_modules .
|
cd ./packages/opencode
|
||||||
cp -r ${finalAttrs.node_modules}/packages .
|
bun --bun ./script/build.ts --single --skip-install
|
||||||
|
bun --bun ./script/schema.ts schema.json
|
||||||
(
|
|
||||||
cd packages/opencode
|
|
||||||
|
|
||||||
chmod -R u+w ./node_modules
|
|
||||||
mkdir -p ./node_modules/@opencode-ai
|
|
||||||
rm -f ./node_modules/@opencode-ai/{script,sdk,plugin}
|
|
||||||
ln -s $(pwd)/../../packages/script ./node_modules/@opencode-ai/script
|
|
||||||
ln -s $(pwd)/../../packages/sdk/js ./node_modules/@opencode-ai/sdk
|
|
||||||
ln -s $(pwd)/../../packages/plugin ./node_modules/@opencode-ai/plugin
|
|
||||||
|
|
||||||
cp ${./bundle.ts} ./bundle.ts
|
|
||||||
chmod +x ./bundle.ts
|
|
||||||
bun run ./bundle.ts
|
|
||||||
)
|
|
||||||
|
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
@ -63,76 +118,52 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
|
|
||||||
cd packages/opencode
|
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
|
||||||
if [ ! -d dist ]; then
|
install -Dm644 schema.json $out/share/opencode/schema.json
|
||||||
echo "ERROR: dist directory missing after bundle step"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p $out/lib/opencode
|
wrapProgram $out/bin/opencode \
|
||||||
cp -r dist $out/lib/opencode/
|
--prefix PATH : ${
|
||||||
chmod -R u+w $out/lib/opencode/dist
|
lib.makeBinPath (
|
||||||
|
[
|
||||||
# Select bundled worker assets deterministically (sorted find output)
|
ripgrep
|
||||||
worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1)
|
]
|
||||||
parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1)
|
# bun runs sysctl to detect if dunning on rosetta2
|
||||||
if [ -z "$worker_file" ]; then
|
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
|
||||||
echo "ERROR: bundled worker not found"
|
)
|
||||||
exit 1
|
}
|
||||||
fi
|
|
||||||
|
|
||||||
main_wasm=$(printf '%s\n' "$out"/lib/opencode/dist/tree-sitter-*.wasm | sort | head -n1)
|
|
||||||
wasm_list=$(find "$out/lib/opencode/dist" -maxdepth 1 -name 'tree-sitter-*.wasm' -print)
|
|
||||||
for patch_file in "$worker_file" "$parser_worker_file"; do
|
|
||||||
[ -z "$patch_file" ] && continue
|
|
||||||
[ ! -f "$patch_file" ] && continue
|
|
||||||
if [ -n "$wasm_list" ] && grep -q 'tree-sitter' "$patch_file"; then
|
|
||||||
# Rewrite wasm references to absolute store paths to avoid runtime resolve failures.
|
|
||||||
bun --bun ${scripts + "/patch-wasm.ts"} "$patch_file" "$main_wasm" $wasm_list
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
mkdir -p $out/lib/opencode/node_modules
|
|
||||||
cp -r ../../node_modules/.bun $out/lib/opencode/node_modules/
|
|
||||||
mkdir -p $out/lib/opencode/node_modules/@opentui
|
|
||||||
|
|
||||||
mkdir -p $out/bin
|
|
||||||
makeWrapper ${bun}/bin/bun $out/bin/opencode \
|
|
||||||
--add-flags "run" \
|
|
||||||
--add-flags "$out/lib/opencode/dist/src/index.js" \
|
|
||||||
--prefix PATH : ${lib.makeBinPath [ ripgrep ]} \
|
|
||||||
--argv0 opencode
|
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
postInstall = ''
|
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
||||||
for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do
|
# trick yargs into also generating zsh completions
|
||||||
if [ -d "$pkg" ]; then
|
installShellCompletion --cmd opencode \
|
||||||
pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/')
|
--bash <($out/bin/opencode completion) \
|
||||||
ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \
|
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
|
||||||
$out/lib/opencode/node_modules/@opentui/$pkgName
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
dontFixup = true;
|
nativeInstallCheckInputs = [
|
||||||
|
versionCheckHook
|
||||||
|
writableTmpDirAsHomeHook
|
||||||
|
];
|
||||||
|
doInstallCheck = true;
|
||||||
|
versionCheckKeepEnvironment = [ "HOME" ];
|
||||||
|
versionCheckProgramArg = "--version";
|
||||||
|
|
||||||
|
passthru = {
|
||||||
|
jsonschema = "${placeholder "out"}/share/opencode/schema.json";
|
||||||
|
};
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
description = "AI coding agent built for the terminal";
|
description = "The open source coding agent";
|
||||||
longDescription = ''
|
homepage = "https://opencode.ai/";
|
||||||
OpenCode is a terminal-based agent that can build anything.
|
|
||||||
It combines a TypeScript/JavaScript core with a Go-based TUI
|
|
||||||
to provide an interactive AI coding experience.
|
|
||||||
'';
|
|
||||||
homepage = "https://github.com/anomalyco/opencode";
|
|
||||||
license = lib.licenses.mit;
|
license = lib.licenses.mit;
|
||||||
|
mainProgram = "opencode";
|
||||||
platforms = [
|
platforms = [
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
"x86_64-darwin"
|
"x86_64-darwin"
|
||||||
];
|
];
|
||||||
mainProgram = "opencode";
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin"
|
|
||||||
import path from "path"
|
|
||||||
import fs from "fs"
|
|
||||||
|
|
||||||
const version = "@VERSION@"
|
|
||||||
const pkg = path.join(process.cwd(), "packages/opencode")
|
|
||||||
const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js"))
|
|
||||||
const worker = "./src/cli/cmd/tui/worker.ts"
|
|
||||||
const target = process.env["BUN_COMPILE_TARGET"]
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
throw new Error("BUN_COMPILE_TARGET not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
process.chdir(pkg)
|
|
||||||
|
|
||||||
const manifestName = "opencode-assets.manifest"
|
|
||||||
const manifestPath = path.join(pkg, manifestName)
|
|
||||||
|
|
||||||
const readTrackedAssets = () => {
|
|
||||||
if (!fs.existsSync(manifestPath)) return []
|
|
||||||
return fs
|
|
||||||
.readFileSync(manifestPath, "utf8")
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeTrackedAssets = () => {
|
|
||||||
for (const file of readTrackedAssets()) {
|
|
||||||
const filePath = path.join(pkg, file)
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
fs.rmSync(filePath, { force: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const assets = new Set<string>()
|
|
||||||
|
|
||||||
const addAsset = async (p: string) => {
|
|
||||||
const file = path.basename(p)
|
|
||||||
const dest = path.join(pkg, file)
|
|
||||||
await Bun.write(dest, Bun.file(p))
|
|
||||||
assets.add(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
removeTrackedAssets()
|
|
||||||
|
|
||||||
const result = await Bun.build({
|
|
||||||
conditions: ["browser"],
|
|
||||||
tsconfig: "./tsconfig.json",
|
|
||||||
plugins: [solidPlugin],
|
|
||||||
sourcemap: "external",
|
|
||||||
entrypoints: ["./src/index.ts", parser, worker],
|
|
||||||
define: {
|
|
||||||
OPENCODE_VERSION: `'@VERSION@'`,
|
|
||||||
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"),
|
|
||||||
OPENCODE_CHANNEL: "'latest'",
|
|
||||||
},
|
|
||||||
compile: {
|
|
||||||
target,
|
|
||||||
outfile: "opencode",
|
|
||||||
autoloadBunfig: false,
|
|
||||||
autoloadDotenv: false,
|
|
||||||
//@ts-ignore (bun types aren't up to date)
|
|
||||||
autoloadTsconfig: true,
|
|
||||||
autoloadPackageJson: true,
|
|
||||||
execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"],
|
|
||||||
windows: {},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
console.error("Build failed!")
|
|
||||||
for (const log of result.logs) {
|
|
||||||
console.error(log)
|
|
||||||
}
|
|
||||||
throw new Error("Compilation failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetOutputs = result.outputs?.filter((x) => x.kind === "asset") ?? []
|
|
||||||
for (const x of assetOutputs) {
|
|
||||||
await addAsset(x.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bundle = await Bun.build({
|
|
||||||
entrypoints: [worker],
|
|
||||||
tsconfig: "./tsconfig.json",
|
|
||||||
plugins: [solidPlugin],
|
|
||||||
target: "bun",
|
|
||||||
outdir: "./.opencode-worker",
|
|
||||||
sourcemap: "none",
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!bundle.success) {
|
|
||||||
console.error("Worker build failed!")
|
|
||||||
for (const log of bundle.logs) {
|
|
||||||
console.error(log)
|
|
||||||
}
|
|
||||||
throw new Error("Worker compilation failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
const workerAssets = bundle.outputs?.filter((x) => x.kind === "asset") ?? []
|
|
||||||
for (const x of workerAssets) {
|
|
||||||
await addAsset(x.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = bundle.outputs.find((x) => x.kind === "entry-point")
|
|
||||||
if (!output) {
|
|
||||||
throw new Error("Worker build produced no entry-point output")
|
|
||||||
}
|
|
||||||
|
|
||||||
const dest = path.join(pkg, "opencode-worker.js")
|
|
||||||
await Bun.write(dest, Bun.file(output.path))
|
|
||||||
fs.rmSync(path.dirname(output.path), { recursive: true, force: true })
|
|
||||||
|
|
||||||
const list = Array.from(assets)
|
|
||||||
await Bun.write(manifestPath, list.length > 0 ? list.join("\n") + "\n" : "")
|
|
||||||
|
|
||||||
console.log("Build successful!")
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrite tree-sitter wasm references inside a JS file to absolute paths.
|
|
||||||
* argv: [node, script, file, mainWasm, ...wasmPaths]
|
|
||||||
*/
|
|
||||||
const [, , file, mainWasm, ...wasmPaths] = process.argv
|
|
||||||
|
|
||||||
if (!file || !mainWasm) {
|
|
||||||
console.error("usage: patch-wasm <file> <mainWasm> [wasmPaths...]")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = fs.readFileSync(file, "utf8")
|
|
||||||
const byName = new Map<string, string>()
|
|
||||||
|
|
||||||
for (const wasm of wasmPaths) {
|
|
||||||
const name = path.basename(wasm)
|
|
||||||
byName.set(name, wasm)
|
|
||||||
}
|
|
||||||
|
|
||||||
let next = content
|
|
||||||
|
|
||||||
for (const [name, wasmPath] of byName) {
|
|
||||||
next = next.replaceAll(name, wasmPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
|
|
||||||
|
|
||||||
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
|
|
||||||
const nixStorePrefix = process.env.NIX_STORE || "/nix/store"
|
|
||||||
next = next.replace(/(\.\/)+/g, "./")
|
|
||||||
next = next.replace(
|
|
||||||
new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"),
|
|
||||||
"/$2",
|
|
||||||
)
|
|
||||||
next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
|
|
||||||
next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
|
|
||||||
|
|
||||||
if (next !== content) fs.writeFileSync(file, next)
|
|
||||||
|
|
@ -90,6 +90,11 @@ const targets = singleFlag
|
||||||
return baselineFlag
|
return baselineFlag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// also skip abi-specific builds for the same reason
|
||||||
|
if (item.abi !== undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
: allTargets
|
: allTargets
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue