joe307bad/plaintextpantry

Local-first, open source, free, minimalist recipe and grocery list manager

F#

0

43 commits

updated Sep 28, 2026

See the code

See what people are saying

SourceMessageScoreDate

Plaintext pantry: local first, free, minimalist recipe, shopping list and weekly family menu planning app (r/SideProject)

There are probably a million recipe managers so here is what motivated me to make my own: 1. The workflow is 1) Create a recipe via ChatGPT/Claude integration (MCP) then 2) one click from a recipe you can add those ingredients to a shopping list then 3) one click add the recipe to a weekly menu…

1

Sep 28, 2026

README

Plaintext Pantry

A local-first, open source, minimalist recipe and grocery list manager. Recipes are plain text in Cooklang (@flour{2%cups}, #pan{}, ~{10%minutes}), so ingredients fall out of the recipe for free: add a recipe to the shopping list, or collect recipes onto a menu and see which ones the list already covers. Everything works offline and syncs between devices when it can. An MCP endpoint lets an AI assistant read and edit your recipes and lists on your behalf.

Live at plaintextpantry.com.

On a phone

RecipesA recipeShopping listMenuSettings
The recipe list, with tag filters across the topA recipe open on its Cooklang tab, syntax highlightedThe shopping list, items noting the recipe they came fromThe menu, four recipes with sides under themSettings: the pantry name, its QR code and its members

Taken at iPhone SE size by the run in e2e/: the real client against a mock backend, so the household is invented but nothing about the app is. ./e2e/run.sh retakes them, and needs only Docker.

Built with

Everything is F#, front to back, with one Cooklang parser shared by both.

ClientFable → JavaScript, Elmish + Feliz (React 19), Tailwind v4, Vite, CodeMirror 6 for the recipe editor
Local dataPowerSync — a SQLite database in the browser (wa-sqlite) that syncs with Postgres. The UI reads live queries and writes locally; sync happens behind it
ServerASP.NET Core 10 + Giraffe. Signs users in, applies PowerSync's upload queue to Postgres, mints sync tokens, and serves the MCP tools
Sharedapp/src/Shared: the Cooklang parser, JSON codecs (Thoth), and the small bits of domain logic; compiled once for .NET and once by Fable
IdentityKeycloak, brokering Google sign-in; also the OAuth server for MCP clients
InfraPostgres 16, Caddy (auto-HTTPS, static files), Docker Compose, Terraform, one arm64 EC2 box

Layout:

app/src/Client     Fable app (App.fs is the whole UI; Db.fs the data layer)
app/src/Server     API, auth, MCP tools, Postgres access
app/src/Shared     Cooklang parser + types shared by both
app/tests          xunit tests for Shared (incl. the Cooklang canonical suite)
infra/docker       Local compose stack, Postgres schema + migrations, PowerSync sync rules, Dockerfiles
infra/keycloak     Realm provisioning and the login theme
infra/terraform    AWS resources
infra/deploy       Production compose stack and the script that applies it on the box
e2e                Screenshot run: the real client against a mock backend, in Docker
user-activity.sh   Who has signed in to production, and how often

Everything belongs to a pantry: a household with an owner, a name and a list of members, carried on every row as pantry_id. You get one of your own the first time you sign in, and join someone else's by scanning the QR code on their settings page — which asks to be let in and waits for the owner to approve it. Inside a pantry every member may do anything, so the upload path checks membership rather than ownership (decide in Server/Db.fs) and the sync rules hand a device the rows of the pantries it is approved in.

Running locally

You need Docker, the .NET 10 SDK and Node.

./dev.sh

That starts Postgres, PowerSync and Keycloak in Docker (applying infra/docker/postgres/migrate.sql and provisioning the realm), then the F# server under dotnet watch and the client under Fable + Vite, both reloading on save. Open http://localhost:5173 and press Login with Google — locally that signs you in as the seeded dev@localhost user without leaving the app.

  • ./dev.sh down stops the containers; ./dev.sh reset also wipes their data.
  • Tests: cd app && dotnet test tests/Shared.Tests.
  • Screenshots: ./e2e/run.sh — needs Docker and none of the above running.
  • The MCP endpoint is at http://localhost:5050/mcp and works with the dev user.

Schema changes go in two places: infra/docker/postgres/init/01-init.sh (fresh databases) and migrate.sql (re-runnable, applied on every start). New tables also need a pantry_id, a stream in infra/docker/powersync/sync-config.yaml, a column list in the client's Db.fs, and an entry in the server's upload whitelist (Server/Db.fs).

Deploying to AWS

Production is a single t4g.small running the compose stack in infra/deploy; the full story, including one-time setup (Terraform bootstrap, domain delegation, the Google OAuth client), lives in infra/terraform/README.md.

Day to day, merging to main deploys. The workflow has three stages, each of which skips itself when nothing relevant changed:

  1. infra — terraform plan; applies only if the plan is non-empty.
  2. build — builds the server and web images on the runner, tagged by a hash of app/ and the Dockerfiles, and pushes to ECR unless that tag already exists.
  3. deploy — ships infra/deploy to the box over SSM and runs deploy.sh, which pulls images, runs the migration, re-provisions the Keycloak realm and does docker compose up -d (only containers whose image or config changed are recreated).

To redeploy without a code change: gh workflow run deploy.yml. There is no SSH; for a shell on the box use aws ssm start-session --target <instance id>, then cd /opt/plaintextpantry and docker compose logs -f server. Secrets live in SSM Parameter Store under /plaintextpantry/*.

What gets used

Two questions, two places, because they want different things: how much the app is being used, and who is using it.

How much is a Grafana dashboard on the observability box this project shares with fastbreak and topspin — https://fastbreak-o11y.fly.dev/grafana, dashboard Plaintext Pantry. The server writes the counters behind it (Usage.fs): pages opened, sign-ins, recipes created, pantries shared, and the MCP calls that changed something. Totals only — no user id, no session, nothing that says which of them was whom — and no key in Parameter Store means no counting at all. The dashboard itself is defined by o11y/grafana/plaintextpantry-dashboard.py in the fastbreak repository; edit there and re-run it.

Who is Keycloak's, since it is the only thing that sees a sign-in as a person. It keeps login events for 90 days (turned on by provision.sh), and this prints them, a column of addresses and a column of counts:

./user-activity.sh        # every sign-in there is a record of
./user-activity.sh 7      # or just the last week
      who      | logins | days
---------------+--------+------
 sam@gmail.com |      2 |   31
 joe@gmail.com |      1 |    4

logins is sign-ins; days is days the app was opened, which is the one to read for someone who signed in once in March and has been cooking from it ever since — the app renews its session against Keycloak once a day, so a day of use leaves a mark without a login.

user-activity.sh runs infra/deploy/user-activity.sh on the box through SSM Run Command, which needs no SSH and no session-manager plugin. Away from a checkout, that is:

C=$(aws ssm send-command --targets Key=tag:Name,Values=plaintextpantry --document-name AWS-RunShellScript --parameters 'commands=["/opt/plaintextpantry/user-activity.sh"]' --query Command.CommandId --output text) && sleep 6 && aws ssm list-command-invocations --command-id "$C" --details --query 'CommandInvocations[0].CommandPlugins[0].Output' --output text

Both only know about sign-ins since events were switched on, so the record starts at the deploy that shipped them.

joe307bad/plaintextpantry

Local-first, open source, free, minimalist recipe and grocery list manager

F#

0

43 commits

updated Sep 28, 2026

See the code

See what people are saying

SourceMessageScoreDate

Plaintext pantry: local first, free, minimalist recipe, shopping list and weekly family menu planning app (r/SideProject)

There are probably a million recipe managers so here is what motivated me to make my own: 1. The workflow is 1) Create a recipe via ChatGPT/Claude integration (MCP) then 2) one click from a recipe you can add those ingredients to a shopping list then 3) one click add the recipe to a weekly menu…

1

Sep 28, 2026

README

Plaintext Pantry

A local-first, open source, minimalist recipe and grocery list manager. Recipes are plain text in Cooklang (@flour{2%cups}, #pan{}, ~{10%minutes}), so ingredients fall out of the recipe for free: add a recipe to the shopping list, or collect recipes onto a menu and see which ones the list already covers. Everything works offline and syncs between devices when it can. An MCP endpoint lets an AI assistant read and edit your recipes and lists on your behalf.

Live at plaintextpantry.com.

On a phone

RecipesA recipeShopping listMenuSettings
The recipe list, with tag filters across the topA recipe open on its Cooklang tab, syntax highlightedThe shopping list, items noting the recipe they came fromThe menu, four recipes with sides under themSettings: the pantry name, its QR code and its members

Taken at iPhone SE size by the run in e2e/: the real client against a mock backend, so the household is invented but nothing about the app is. ./e2e/run.sh retakes them, and needs only Docker.

Built with

Everything is F#, front to back, with one Cooklang parser shared by both.

ClientFable → JavaScript, Elmish + Feliz (React 19), Tailwind v4, Vite, CodeMirror 6 for the recipe editor
Local dataPowerSync — a SQLite database in the browser (wa-sqlite) that syncs with Postgres. The UI reads live queries and writes locally; sync happens behind it
ServerASP.NET Core 10 + Giraffe. Signs users in, applies PowerSync's upload queue to Postgres, mints sync tokens, and serves the MCP tools
Sharedapp/src/Shared: the Cooklang parser, JSON codecs (Thoth), and the small bits of domain logic; compiled once for .NET and once by Fable
IdentityKeycloak, brokering Google sign-in; also the OAuth server for MCP clients
InfraPostgres 16, Caddy (auto-HTTPS, static files), Docker Compose, Terraform, one arm64 EC2 box

Layout:

app/src/Client     Fable app (App.fs is the whole UI; Db.fs the data layer)
app/src/Server     API, auth, MCP tools, Postgres access
app/src/Shared     Cooklang parser + types shared by both
app/tests          xunit tests for Shared (incl. the Cooklang canonical suite)
infra/docker       Local compose stack, Postgres schema + migrations, PowerSync sync rules, Dockerfiles
infra/keycloak     Realm provisioning and the login theme
infra/terraform    AWS resources
infra/deploy       Production compose stack and the script that applies it on the box
e2e                Screenshot run: the real client against a mock backend, in Docker
user-activity.sh   Who has signed in to production, and how often

Everything belongs to a pantry: a household with an owner, a name and a list of members, carried on every row as pantry_id. You get one of your own the first time you sign in, and join someone else's by scanning the QR code on their settings page — which asks to be let in and waits for the owner to approve it. Inside a pantry every member may do anything, so the upload path checks membership rather than ownership (decide in Server/Db.fs) and the sync rules hand a device the rows of the pantries it is approved in.

Running locally

You need Docker, the .NET 10 SDK and Node.

./dev.sh

That starts Postgres, PowerSync and Keycloak in Docker (applying infra/docker/postgres/migrate.sql and provisioning the realm), then the F# server under dotnet watch and the client under Fable + Vite, both reloading on save. Open http://localhost:5173 and press Login with Google — locally that signs you in as the seeded dev@localhost user without leaving the app.

  • ./dev.sh down stops the containers; ./dev.sh reset also wipes their data.
  • Tests: cd app && dotnet test tests/Shared.Tests.
  • Screenshots: ./e2e/run.sh — needs Docker and none of the above running.
  • The MCP endpoint is at http://localhost:5050/mcp and works with the dev user.

Schema changes go in two places: infra/docker/postgres/init/01-init.sh (fresh databases) and migrate.sql (re-runnable, applied on every start). New tables also need a pantry_id, a stream in infra/docker/powersync/sync-config.yaml, a column list in the client's Db.fs, and an entry in the server's upload whitelist (Server/Db.fs).

Deploying to AWS

Production is a single t4g.small running the compose stack in infra/deploy; the full story, including one-time setup (Terraform bootstrap, domain delegation, the Google OAuth client), lives in infra/terraform/README.md.

Day to day, merging to main deploys. The workflow has three stages, each of which skips itself when nothing relevant changed:

  1. infra — terraform plan; applies only if the plan is non-empty.
  2. build — builds the server and web images on the runner, tagged by a hash of app/ and the Dockerfiles, and pushes to ECR unless that tag already exists.
  3. deploy — ships infra/deploy to the box over SSM and runs deploy.sh, which pulls images, runs the migration, re-provisions the Keycloak realm and does docker compose up -d (only containers whose image or config changed are recreated).

To redeploy without a code change: gh workflow run deploy.yml. There is no SSH; for a shell on the box use aws ssm start-session --target <instance id>, then cd /opt/plaintextpantry and docker compose logs -f server. Secrets live in SSM Parameter Store under /plaintextpantry/*.

What gets used

Two questions, two places, because they want different things: how much the app is being used, and who is using it.

How much is a Grafana dashboard on the observability box this project shares with fastbreak and topspin — https://fastbreak-o11y.fly.dev/grafana, dashboard Plaintext Pantry. The server writes the counters behind it (Usage.fs): pages opened, sign-ins, recipes created, pantries shared, and the MCP calls that changed something. Totals only — no user id, no session, nothing that says which of them was whom — and no key in Parameter Store means no counting at all. The dashboard itself is defined by o11y/grafana/plaintextpantry-dashboard.py in the fastbreak repository; edit there and re-run it.

Who is Keycloak's, since it is the only thing that sees a sign-in as a person. It keeps login events for 90 days (turned on by provision.sh), and this prints them, a column of addresses and a column of counts:

./user-activity.sh        # every sign-in there is a record of
./user-activity.sh 7      # or just the last week
      who      | logins | days
---------------+--------+------
 sam@gmail.com |      2 |   31
 joe@gmail.com |      1 |    4

logins is sign-ins; days is days the app was opened, which is the one to read for someone who signed in once in March and has been cooking from it ever since — the app renews its session against Keycloak once a day, so a day of use leaves a mark without a login.

user-activity.sh runs infra/deploy/user-activity.sh on the box through SSM Run Command, which needs no SSH and no session-manager plugin. Away from a checkout, that is:

C=$(aws ssm send-command --targets Key=tag:Name,Values=plaintextpantry --document-name AWS-RunShellScript --parameters 'commands=["/opt/plaintextpantry/user-activity.sh"]' --query Command.CommandId --output text) && sleep 6 && aws ssm list-command-invocations --command-id "$C" --details --query 'CommandInvocations[0].CommandPlugins[0].Output' --output text

Both only know about sign-ins since events were switched on, so the record starts at the deploy that shipped them.

Languages

F#

77.6%

Shell

7.2%

JavaScript

5.2%

HCL

3.8%

CSS

3.0%

PLpgSQL

1.6%