lmjtfy.git / flake.nix
1{
2  description = "lmjtfy — let me Jev that for you: a Rust Cloudflare Worker that puts typed questions to Jev (TypeSafe AI) and shows the call";
3
4  inputs = {
5    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
6
7    # The estate's package set and facts, for the Cloudflare credentials:
8    # wrangler runs through a wrapper that reads the API token from 1Password
9    # per run, as ~/jevstrudel's does. Nothing is logged in and nothing is on disk.
10    nix-pkgs.url = "git+ssh://git@github.com/deizel/nix-pkgs";
11    nix-pkgs.inputs.nixpkgs.follows = "nixpkgs";
12    nix-facts.url = "git+ssh://git@github.com/deizel/nix-facts";
13  };
14
15  outputs =
16    {
17      nixpkgs,
18      nix-pkgs,
19      nix-facts,
20      ...
21    }:
22    let
23      system = "x86_64-linux";
24      pkgs = nixpkgs.legacyPackages.${system};
25
26      cloudflare = {
27        itemRef = nix-facts.facts.onePassword.cloudflareMasterKey;
28        accountId = nix-facts.facts.cloudflare.accounts.deizel;
29      };
30      # `lmjtfy-wrangler <dir> [wrangler args]`: wrangler with the account's
31      # token in its environment. Workers AI has no local emulation, so even
32      # `dev` needs it once the Worker calls a model.
33      wrangler = nix-pkgs.lib.infra.mkWranglerDeploy {
34        inherit pkgs;
35        inherit (cloudflare) itemRef accountId;
36        name = "lmjtfy-wrangler";
37      };
38
39      # `lmjtfy-eval [args]`: tools/eval with the account, and where in
40      # 1Password its token is, in its own environment only. The reference is
41      # an `op://` URL, and `op-env-run` resolves every such value it finds
42      # in the environment it is run from, with an account that cannot read
43      # this one. So it must not be a devshell variable (2026-10-02: it was,
44      # and the dev server's op-env-run refused to start).
45      evalTool = pkgs.writeShellApplication {
46        name = "lmjtfy-eval";
47        text = ''
48          export CLOUDFLARE_ACCOUNT_ID=${cloudflare.accountId}
49          export LMJTFY_OP_ITEM_REF=${pkgs.lib.escapeShellArg cloudflare.itemRef}
50          exec cargo run -q -p eval -- "$@"
51        '';
52      };
53
54      # `lmjtfy-secret <which> [environment]`: sets one of the deployed
55      # Worker's secrets, or of its `staging` environment's.
56      #   op-env-run -- lmjtfy-secret jev      LMJTFY_TYPESAFE_API_KEY, from the environment
57      #   lmjtfy-secret account                CLOUDFLARE_ACCOUNT_ID, from the estate's facts
58      #   … | lmjtfy-secret analytics          CLOUDFLARE_ANALYTICS_TOKEN, from stdin:
59      #       nix run ~/config#infra-core -- output -raw lmjtfy-analytics-token-value | tail -n 1 | lmjtfy-secret analytics
60      #   op-env-run -- lmjtfy-secret github   LMJTFY_GITHUB_TOKEN, from the environment: the clone
61      #       proxy's read-only fine-grained token (made on GitHub; the API cannot mint one)
62      #
63      # Not `printf value | lmjtfy-wrangler … secret put`: that wrapper runs
64      # op.exe to fetch the Cloudflare token before it runs wrangler, op.exe
65      # reads the stdin it is given, and the value is gone by the time
66      # wrangler looks. That uploaded an empty secret on 2026-10-02 and the
67      # live site said Jev was offline. Here op gets no stdin.
68      secretTool = pkgs.writeShellApplication {
69        name = "lmjtfy-secret";
70        runtimeInputs = [ pkgs.wrangler ];
71        text = ''
72          case "''${1:-}" in
73            jev)
74              name=LMJTFY_TYPESAFE_API_KEY
75              value=''${LMJTFY_TYPESAFE_API_KEY:-}
76              ;;
77            account)
78              name=CLOUDFLARE_ACCOUNT_ID
79              value=${cloudflare.accountId}
80              ;;
81            analytics)
82              name=CLOUDFLARE_ANALYTICS_TOKEN
83              value=$(cat)
84              ;;
85            github)
86              name=LMJTFY_GITHUB_TOKEN
87              value=''${LMJTFY_GITHUB_TOKEN:-}
88              ;;
89            *)
90              echo "usage: lmjtfy-secret jev | account | analytics | github [environment]" >&2
91              exit 2
92              ;;
93          esac
94          if [ -z "$value" ]; then
95            echo "lmjtfy-secret: no value for $name" >&2
96            exit 1
97          fi
98          op=op
99          if command -v op.exe >/dev/null; then op=op.exe; fi
100          CLOUDFLARE_API_TOKEN=$("$op" read ${pkgs.lib.escapeShellArg cloudflare.itemRef}/Token </dev/null | tr -d '\r')
101          if [ -z "$CLOUDFLARE_API_TOKEN" ]; then
102            echo "lmjtfy-secret: no Cloudflare token from 1Password" >&2
103            exit 1
104          fi
105          export CLOUDFLARE_API_TOKEN
106          export CLOUDFLARE_ACCOUNT_ID=${cloudflare.accountId}
107          cd "$(git rev-parse --show-toplevel)/apps/lmjtfy"
108          # A second word names the environment: `lmjtfy-secret jev staging`.
109          if [ -n "''${2:-}" ]; then
110            printf %s "$value" | wrangler secret put "$name" --env "$2"
111          else
112            printf %s "$value" | wrangler secret put "$name"
113          fi
114        '';
115      };
116
117      # The toolchain is mise's, written in mise.toml (Rust, wasm-bindgen, worker-build, node, wrangler, hk,
118      # pkl, pitchfork, fnox); anyone can use it without nix: see README.md. The shells only wrap it: mise
119      # itself, plus the native things a downloaded toolchain cannot bring (a C compiler and linker,
120      # pkg-config, OpenSSL for crates that link it, curl, unzip and bzip2, git).
121      #
122      # Entering one puts whatever `mise install` has already installed on PATH (it installs nothing itself:
123      # the first install is large). Downloaded programs are ordinary dynamically linked binaries; NixOS runs
124      # them through nix-ld (NIX_LD_LIBRARY_PATH below tells nix-ld where its libraries are). Where nix-ld is
125      # not enabled, use `nix develop .#fhs`.
126      #
127      # Nix fetches a locked input only when an output uses it (measured on Nix 2.34, 2026-10-02), so anyone
128      # who clones can enter this shell without access to nix-pkgs or nix-facts.
129      native = [
130        pkgs.mise
131        pkgs.gcc
132        pkgs.pkg-config
133        pkgs.openssl
134        pkgs.curl
135        pkgs.unzip
136        pkgs.bzip2
137        pkgs.git
138        pkgs.cacert
139      ];
140      shellHook = ''
141        root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
142        if [ -f "$root/mise.toml" ]; then
143          export MISE_TRUSTED_CONFIG_PATHS="$root''${MISE_TRUSTED_CONFIG_PATHS:+:$MISE_TRUSTED_CONFIG_PATHS}"
144          eval "$(mise env -s bash 2>/dev/null)" || true
145        fi
146      '';
147      nixLd = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib pkgs.zlib pkgs.openssl ];
148    in
149    {
150      packages.${system}.wrangler = wrangler;
151
152      devShells.${system} = {
153        # `nix develop`: mise and its native needs.
154        default = pkgs.mkShell {
155          packages = native;
156          NIX_LD_LIBRARY_PATH = nixLd;
157          inherit shellHook;
158        };
159
160        # The same, inside an FHS root (bubblewrap), for a machine without nix-ld.
161        fhs =
162          (pkgs.buildFHSEnv {
163            name = "lmjtfy-fhs";
164            targetPkgs = p: native ++ [ p.zlib p.stdenv.cc.cc.lib ];
165            runScript = "bash";
166          }).env;
167
168        # `nix develop .#owner`: the same, and the owner's tools, which read the Cloudflare account and the
169        # 1Password item from the private nix-facts: the credentialed wrangler (tools/wrangler prefers it
170        # when it is on PATH), the secrets and the eval.
171        owner = pkgs.mkShell {
172          packages = native ++ [
173            wrangler
174            secretTool
175            evalTool
176          ];
177          NIX_LD_LIBRARY_PATH = nixLd;
178          inherit shellHook;
179        };
180      };
181    };
182}