monkrb 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -0
  3. data/README.md +7 -6
  4. data/exe/monk +3 -181
  5. data/lib/monk/assets.rb +39 -9
  6. data/lib/monk/auth/errors.rb +3 -0
  7. data/lib/monk/auth/helpers.rb +11 -0
  8. data/lib/monk/auth/send_login_link.rb +36 -0
  9. data/lib/monk/auth.rb +34 -5
  10. data/lib/monk/bin.rb +107 -0
  11. data/lib/monk/cli.rb +277 -0
  12. data/lib/monk/context.rb +17 -0
  13. data/lib/monk/errors.rb +0 -6
  14. data/lib/monk/generator/definition.rb +153 -0
  15. data/lib/monk/generator/result.rb +279 -0
  16. data/lib/monk/generator/run.rb +449 -0
  17. data/lib/monk/generator.rb +48 -0
  18. data/lib/monk/generators/auth.rb +29 -0
  19. data/lib/monk/generators/base.rb +19 -0
  20. data/lib/monk/generators/jobs.rb +21 -0
  21. data/lib/monk/generators/live.rb +26 -0
  22. data/lib/monk/generators/mail.rb +24 -0
  23. data/lib/monk/generators/postgres.rb +23 -0
  24. data/lib/monk/generators/redis.rb +20 -0
  25. data/lib/monk/generators/websocket.rb +24 -0
  26. data/lib/monk/generators.rb +7 -0
  27. data/lib/monk/jobs.rb +7 -0
  28. data/lib/monk/live/helpers.rb +13 -0
  29. data/lib/monk/live.rb +12 -1
  30. data/lib/monk/log.rb +10 -1
  31. data/lib/monk/mail/deliver_later.rb +54 -0
  32. data/lib/monk/mail/later.rb +3 -51
  33. data/lib/monk/mail.rb +4 -0
  34. data/lib/monk/templates/auth/agents.md +29 -0
  35. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  36. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  37. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  38. data/lib/monk/templates/auth/config/auth.rb +8 -8
  39. data/lib/monk/templates/auth/setup.md +37 -0
  40. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  41. data/lib/monk/templates/base/CLAUDE.md +1 -0
  42. data/lib/monk/templates/base/Dockerfile +10 -5
  43. data/lib/monk/templates/base/Gemfile +7 -2
  44. data/lib/monk/templates/base/Rakefile +8 -0
  45. data/lib/monk/templates/base/agents.md +41 -0
  46. data/lib/monk/templates/base/app/app.rb +5 -0
  47. data/lib/monk/templates/base/app/routes/.keep +0 -0
  48. data/lib/monk/templates/base/app/views/index.erb +1 -1
  49. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  50. data/lib/monk/templates/base/config/load.rb +14 -0
  51. data/lib/monk/templates/base/config/settings.rb +4 -5
  52. data/lib/monk/templates/base/setup.md +22 -0
  53. data/lib/monk/templates/base/test/app_test.rb +12 -0
  54. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  55. data/lib/monk/templates/jobs/agents.md +25 -0
  56. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  57. data/lib/monk/templates/jobs/bin/jobs +19 -13
  58. data/lib/monk/templates/jobs/setup.md +30 -0
  59. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  60. data/lib/monk/templates/live/agents.md +25 -0
  61. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  62. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  63. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  64. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  65. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  66. data/lib/monk/templates/live/config/live.rb +30 -32
  67. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  68. data/lib/monk/templates/live/setup.md +39 -0
  69. data/lib/monk/templates/live/test/live_test.rb +22 -0
  70. data/lib/monk/templates/mail/agents.md +22 -0
  71. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  72. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  73. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  74. data/lib/monk/templates/mail/config/mail.rb +4 -4
  75. data/lib/monk/templates/mail/setup.md +32 -0
  76. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  77. data/lib/monk/templates/postgres/agents.md +25 -0
  78. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  79. data/lib/monk/templates/postgres/bin/console +3 -1
  80. data/lib/monk/templates/postgres/bin/migrate +23 -17
  81. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  82. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  83. data/lib/monk/templates/postgres/setup.md +38 -0
  84. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  85. data/lib/monk/templates/redis/agents.md +18 -0
  86. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  87. data/lib/monk/templates/redis/config/redis.rb +13 -0
  88. data/lib/monk/templates/redis/setup.md +20 -0
  89. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  90. data/lib/monk/templates/websocket/agents.md +28 -0
  91. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  92. data/lib/monk/templates/websocket/bin/websocket_server +62 -0
  93. data/lib/monk/templates/websocket/config/websocket_postgres.rb +38 -0
  94. data/lib/monk/templates/websocket/config/websocket_redis.rb +26 -0
  95. data/lib/monk/templates/websocket/setup.md +54 -0
  96. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  97. data/lib/monk/version.rb +1 -1
  98. data/lib/monk/websocket/server.rb +3 -1
  99. metadata +81 -14
  100. data/lib/monk/scaffold.rb +0 -1063
  101. data/lib/monk/templates/base/bin/websocket_server +0 -77
  102. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  103. data/lib/monk/templates/live/app/app.rb +0 -22
  104. data/lib/monk/templates/live/app/views/index.erb +0 -18
  105. data/lib/monk/templates/live/bin/websocket_server +0 -50
  106. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  107. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  108. data/lib/monk/templates/postgres/Dockerfile +0 -32
  109. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  110. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  111. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0346b062053981ee7d06c7d3cc9b563c429c1b81443e449b683e53a8080005a1
4
- data.tar.gz: 8793cbc85210dc77a03b41e3adc39f58fed92488ef2662d2222c9136f5c55f4f
3
+ metadata.gz: fd1a1140ab4c223d2bdd9c10a226ea274e0051af1ab01444194cb4dc88ebb166
4
+ data.tar.gz: 9fab9365ba1a31224caf14038c2e36c0ba59c9cc70dd60c037f4dbfa78aec8c8
5
5
  SHA512:
6
- metadata.gz: 711bddca3ff4fbe17dd0b776e3a759ede7a810304e7c42ad96e5e2dfb6c37e9ad5b77ebad8c5f6224a3c993544722e55067ebffc57c1d20f7436c18021b5afae
7
- data.tar.gz: e79bb8a6e8d1b77c8bf0227ce0ceeaf16a3ec87b733ce063e8dc32ae7ebd0b24d19915440fb8603f1fe453c74ee91fbe04c68e41e41c84060e6f2d75eaca7353
6
+ metadata.gz: 36bf94be6268d9f12fbb703132f96b00a84c52e5eeb6b03d9b90b9e981d5c9be2ee89003ecc7952b2880d288454d4d6d1734ffb6cfd8bd70328dded0cbbc1e0e
7
+ data.tar.gz: 58c8a897231b31edcc82cfd0e3162c64633a2a121f2b3cc01dbbe3b2fa6ef7a7d9760eb2b623a4b6b8def85004322abba20b001758dadd291f8a2ada52b3d26b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,105 @@ All notable changes to this project are documented here. Format is loosely
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are as released
5
5
  in `lib/monk/version.rb`.
6
6
 
7
+ ## 0.21.1 - 2026-10-09
8
+
9
+ ### Fixed
10
+
11
+ - **`monk add websocket --transport=postgres` publishes through the
12
+ `:notify` pool.** `config/websocket.rb` declares
13
+ `Monk::Persistence::Pg.pool(:notify, size: 1)` and builds
14
+ `PgFanout.new(..., db_pool: :notify)`, so a process holds one publishing
15
+ connection however many Ractors publish. Before, every `bin/server`
16
+ worker opened its own. `AppWebSocket.start!` starts the pool (with
17
+ Redis it does nothing): `config/load.rb` calls it at the end, which
18
+ covers `bin/server`, `bin/jobs`, `bin/console` and the tests, and
19
+ `bin/websocket_server` calls it too. An existing app: replace
20
+ `config/websocket.rb` with
21
+ `monk help websocket --file config/websocket.rb --transport=postgres`
22
+ and add the last lines of `monk help base --file config/load.rb`.
23
+ - **The scaffolded `Dockerfile` builds with `monk add postgres`.** Its
24
+ builder stage installs `build-essential`: `irb`'s dependencies (`erb`,
25
+ `io-console`, `prism`) compile native extensions. The image that runs
26
+ still has no compiler. An existing app: add the `apt-get` line from
27
+ `monk help base --file Dockerfile`.
28
+
29
+ ## 0.21.0 - 2026-10-09
30
+
31
+ ### Changed (breaking)
32
+
33
+ - **`monk new` no longer takes module flags.** `--postgres`, `--auth`,
34
+ `--mail`, `--jobs`, `--redis` and `--live` are a usage error that shows
35
+ the same command's new form, and nothing is written. Modules are added
36
+ with `monk add MODULE`, on any app, new or existing, or with
37
+ `monk new NAME --with a,b` (ADR 0017, `docs/guides/scaffolding.md`).
38
+
39
+ ### Added
40
+
41
+ - **`monk add`**: one generator per module (`postgres`, `redis`, `mail`,
42
+ `auth`, `jobs`, `websocket`, `live`). Each adds its dependencies first,
43
+ creates files and appends lines (never edits or replaces one), and
44
+ brings its wiring, commented examples of how to use it (tagged
45
+ `# monk:example`), a test in `test/`, and its sections of `SETUP.md` and
46
+ `AGENTS.md`. Adding a module twice does nothing. A file the module needs
47
+ that the app already has with other content stops the command before
48
+ anything is written; any other is left alone. `monk add --list`,
49
+ `monk help MODULE [--file PATH]`, `--dry-run`, and `--json` for agents
50
+ and scripts, with exit codes 0 (done), 1 (usage), 2 (a missing choice)
51
+ and 3 (a conflict). A missing choice is asked in a terminal, never
52
+ elsewhere.
53
+ - **Redis and WebSocket are modules of their own.** `monk add redis` is for
54
+ any use of Redis (a cache, rate limits). `monk add websocket
55
+ --transport=postgres|redis` writes `config/websocket.rb`, one registry
56
+ (`AppWebSocket::REGISTRY`) over the chosen transport, and the new
57
+ `bin/websocket_server`; the transport is inferred when only one is
58
+ installed. Live uses websocket's registry.
59
+ - **Every app ships its tests** (`Rakefile`, `test/test_helper.rb`,
60
+ `test/app_test.rb`), an `AGENTS.md` for coding agents and a `CLAUDE.md`
61
+ importing it; dotenv is in every `Gemfile`.
62
+ - **Module routes live in `app/routes/`**, which `app/app.rb` loads; each
63
+ file reopens `class App` (amends ADR 0015). `config/load.rb` is the same
64
+ for every app: it requires each module's config that exists, in a fixed
65
+ order.
66
+ - `Monk::Auth.configure(callback_path:)` (default `"/auth/callback"`) and
67
+ `Monk::Auth.login_link(token)`. `Monk::Auth::SendLoginLink`, the job that
68
+ creates and sends a login link, ships with Monk, once auth and jobs are
69
+ both loaded. `log_out!` ends the current session and clears its cookies.
70
+ - `Monk::Mail.deliver_later` is defined once `monk/mail` and `monk/jobs`
71
+ are both loaded, in either order; `require "monk/mail/later"` still
72
+ works.
73
+ - `<%= monk_head %>` in a layout renders the `<head>` tags of the loaded
74
+ modules (Live's, once `config/live.rb` has configured it).
75
+ - `Monk::Assets.mount(prefix, dir)` serves a module's files from the gem
76
+ under `/_monk/`; Monk::Live's browser client is served at
77
+ `/_monk/live/`, so apps no longer copy it.
78
+ - `Monk::Live::ALLOW_ALL`, a shareable rule block letting everyone in.
79
+ - `Monk::Bin.run(__FILE__) { ... }`: a scaffolded app's `bin/` scripts end
80
+ with what failed and the command that fixes it (Postgres or Redis not
81
+ running, a missing database, refused credentials, an unset setting),
82
+ exit 1, instead of a backtrace.
83
+
84
+ ### Changed
85
+
86
+ - `monk new`'s `Gemfile` asks for `gem "monkrb", "~> 0.21"`: a new app
87
+ needs this version's APIs (`monk_head`, `log_out!`, `Monk::Bin`), so an
88
+ older monkrb installed on the machine can't satisfy it.
89
+ - `JOBS_QUEUES` defaults to `mailers,default` in `bin/jobs`, with or
90
+ without mail.
91
+ - `monk add postgres` adds `gem "pg", "~> 1.6"`: 1.6+ installs precompiled
92
+ with its own libpq, so the one `Dockerfile` needs no system packages.
93
+ - `config/persistence.rb` loads `Monk::Persistence::Pg::Model`, so an app
94
+ with only Postgres can define models.
95
+ - Live's demo is its own files, at `/demo/live`, in development only
96
+ (`--no-demo` to leave it out); it no longer replaces `app/app.rb` and the
97
+ home page.
98
+
99
+ ### Fixed
100
+
101
+ - `Monk::Log` works before Boot. In `bin/console`, which never boots,
102
+ anything that logged raised on a nil path: `Monk::Mail.deliver` through
103
+ `log://`, a job's `perform`, `Monk::Log.info`. Before Boot the first
104
+ write now works out the same `log/<env>.log` path.
105
+
7
106
  ## 0.20.0 - 2026-10-05
8
107
 
9
108
  ### Fixed
data/README.md CHANGED
@@ -18,13 +18,14 @@ gem install monkrb
18
18
  monk new my_app && cd my_app
19
19
  bundle install
20
20
  bin/server # -> http://localhost:9292/hello
21
+ monk add --list # the modules you can add: postgres, auth, jobs, live...
21
22
  ```
22
23
 
23
- `monk new` scaffolds the app's own `Gemfile` with `gem "monkrb", require: "monk"`.
24
+ `monk new` scaffolds the app's own `Gemfile` with `gem "monkrb", "~> 0.21", require: "monk"`.
24
25
 
25
- `monk new` writes a working skeleton (an HTML home page, a `/hello` route, a `/api/hello` JSON route, `public/`, a `SETUP.md`), with the app's own code under `app/`: `app/app.rb` for routes, `app/views/`, and one directory per role (`models/`, `presenters/`, `helpers/`, `mailers/`, `broadcasts/`, `jobs/`). Flags add Postgres, auth, email, background jobs, Redis and live updates: see [`docs/guides/scaffolding.md`](docs/guides/scaffolding.md), which also says where each kind of code goes.
26
+ `monk new` writes a working skeleton (an HTML home page, a `/hello` route, a `/api/hello` JSON route, `public/`, its tests, a `SETUP.md` and an `AGENTS.md` for coding agents), with the app's own code under `app/`: `app/app.rb` and `app/routes/` for routes, `app/views/`, and one directory per role (`models/`, `presenters/`, `helpers/`, `mailers/`, `broadcasts/`, `jobs/`). `monk add` adds Postgres, Redis, email, auth, background jobs, WebSocket and live updates, to a new app or an old one, each with its wiring, commented examples of how to use it, and a test (`monk new my_app --with auth,jobs` does both at once): see [`docs/guides/scaffolding.md`](docs/guides/scaffolding.md), which also says where each kind of code goes.
26
27
 
27
- All `monk` commands and flags are listed by:
28
+ All `monk` commands and options are listed by:
28
29
 
29
30
  ```
30
31
  monk --help
@@ -60,11 +61,11 @@ Everything beyond the core is opt-in (`require "monk"` alone loads none of it).
60
61
  | Persistence | `Monk::Persistence::Pg`: raw `pg`, per-Ractor connections, hash-based `Model` | `require "monk/persistence/pg"` (+ `.../pg/model`); needs the `pg` gem | [`persistence.md`](docs/guides/persistence.md) |
61
62
  | Migrations | plain `.sql` up/down pairs, `Migrator` | `require "monk/persistence/pg/migrator"`; needs the `pg` gem | [`migrations.md`](docs/guides/migrations.md) |
62
63
  | Auth and sessions | `Monk::Auth`: passwordless tokens, Bearer or cookie + CSRF | `require "monk/auth"`; needs the `pg` gem and a registered Postgres connection | [`auth.md`](docs/guides/auth.md) |
63
- | Email | `Monk::Mail`: text/HTML email over SMTP or a local relay, one `MAIL_URL`, sent from any worker Ractor, or from a background job with `deliver_later` | `require "monk/mail"` (+ `require "monk/mail/later"` for `deliver_later`, which needs background jobs); SMTP needs the `net-smtp` gem | [`mail.md`](docs/guides/mail.md) |
64
+ | Email | `Monk::Mail`: text/HTML email over SMTP or a local relay, one `MAIL_URL`, sent from any worker Ractor, or from a background job with `deliver_later` | `require "monk/mail"`; `deliver_later` is there once `monk/jobs` is loaded too; SMTP needs the `net-smtp` gem | [`mail.md`](docs/guides/mail.md) |
64
65
  | Background jobs | `Monk::Jobs`: a queue in Postgres, run by `bin/jobs` on worker Ractors; retries with backoff, scheduled jobs, enqueue inside the app's own transaction, `drain!` for tests | `require "monk/jobs"` (+ `require "monk/jobs/runtime"` in the job process); needs the `pg` gem and a registered Postgres connection | [`jobs.md`](docs/guides/jobs.md) |
65
66
  | WebSocket | `Monk::WebSocket`: RFC 6455 server as its own process, Redis or Postgres fan-out | `require "monk/websocket"`; fan-out: `require "monk/websocket/redis_fanout"` (+ the `redis` gem) or `require "monk/websocket/pg_fanout"` (+ the `pg` gem) | [`websocket.md`](docs/guides/websocket.md) |
66
67
  | Live updates | `Monk::Live`: server-rendered HTML patches pushed to open tabs | `require "monk/live"`; needs the WebSocket server and, across processes, Redis or Postgres | [`live.md`](docs/guides/live.md) |
67
- | Scaffolding | `monk new` and its flags; retrofitting Postgres, Auth, Redis or Jobs | — (the `monk` command; flags `--postgres`, `--auth`, `--mail`, `--jobs`, `--redis`, `--live`) | [`scaffolding.md`](docs/guides/scaffolding.md) |
68
+ | Scaffolding | `monk new` and `monk add`: one generator per module, for a new app or an existing one; text or `--json` output | — (the `monk` command: `monk new NAME --with ...`, `monk add MODULE`, `monk add --list`) | [`scaffolding.md`](docs/guides/scaffolding.md) |
68
69
 
69
70
  ## More documentation
70
71
 
@@ -87,6 +88,6 @@ More (server modes, Docker) in [`docs/development.md`](docs/development.md).
87
88
 
88
89
  ## Status
89
90
 
90
- Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). There's no open roadmap issue at the moment; new work is proposed and tracked as it comes up. Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24), the built-in mailer `Monk::Mail` (0.17, 2026-09-26, ADR 0012), and background jobs on Postgres, `Monk::Jobs`, with mail sent from jobs (0.18, 2026-09-29, ADRs 0013 and 0014). In 0.19 (2026-10-01): `monk new` lays the app out under `app/`, one directory per role (ADR 0015); a WebSocket fanout listens only in the process that holds the sockets (`#listen!`); `authenticate: :optional` lets visitors' sockets in anonymously, for Live's rules to decide; and the WebSocket server stops cleanly on `TERM` (`docs/design/websocket.md`). In 0.20 (2026-10-05): Monk recovers from a dropped database connection (a Postgres restart, a failover) without a process restart. `checkout` reconnects a dead connection before running its block, never retrying the block itself; both fanouts' listeners reconnect and then make every open page resync; and a route that raises logs why (`docs/history/plan-pg-reconnect.md`). Also new: connection pools for Ractors that shouldn't each hold a connection (`Monk::Persistence::Pg.pool`). The WebSocket server's session checks, Live's subscribe rules and `PgFanout`'s publisher can run through one, so open pages share a few connections instead of holding one each: 1,000 open sockets on 4 connections (`docs/guides/persistence.md`, "Pools"; `docs/history/plan-pg-pool.md`).
91
+ Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). Features to add are listed in [`docs/roadmap.md`](docs/roadmap.md). Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24), the built-in mailer `Monk::Mail` (0.17, 2026-09-26, ADR 0012), and background jobs on Postgres, `Monk::Jobs`, with mail sent from jobs (0.18, 2026-09-29, ADRs 0013 and 0014). In 0.19 (2026-10-01): `monk new` lays the app out under `app/`, one directory per role (ADR 0015); a WebSocket fanout listens only in the process that holds the sockets (`#listen!`); `authenticate: :optional` lets visitors' sockets in anonymously, for Live's rules to decide; and the WebSocket server stops cleanly on `TERM` (`docs/design/websocket.md`). In 0.20 (2026-10-05): Monk recovers from a dropped database connection (a Postgres restart, a failover) without a process restart. `checkout` reconnects a dead connection before running its block, never retrying the block itself; both fanouts' listeners reconnect and then make every open page resync; and a route that raises logs why (`docs/history/plan-pg-reconnect.md`). Also new: connection pools for Ractors that shouldn't each hold a connection (`Monk::Persistence::Pg.pool`). The WebSocket server's session checks, Live's subscribe rules and `PgFanout`'s publisher can run through one, so open pages share a few connections instead of holding one each: 1,000 open sockets on 4 connections (`docs/guides/persistence.md`, "Pools"; `docs/history/plan-pg-pool.md`). In 0.21 (2026-10-09): scaffolding by module. `monk new` makes the core app, and `monk add` adds a module (Postgres, Redis, mail, auth, jobs, WebSocket, Live) to any app, new or existing, with its wiring, commented examples, a test, and its sections of `SETUP.md` and an `AGENTS.md` for coding agents; `--json` for agents and scripts. Redis and WebSocket become modules of their own, the WebSocket transport is chosen once per app, and `monk new`'s module flags are gone (ADR 0017, `docs/guides/scaffolding.md`, `docs/history/plan-scaffold.md`).
91
92
 
92
93
  [Made with Love ❤️, Ruby 💎 and AI 🤖](docs/ai_usage_disclaimer.md)
data/exe/monk CHANGED
@@ -1,183 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
- require_relative "../lib/monk/scaffold"
2
+ # monk new / monk add / monk help: docs/guides/scaffolding.md.
3
+ require_relative "../lib/monk/cli"
3
4
 
4
- USAGE = "Usage: monk new APP_NAME [--postgres] [--auth] [--mail] [--redis] [--live] [--jobs]".freeze
5
-
6
- HELP = <<~TEXT.freeze
7
- #{USAGE}
8
-
9
- Creates a new Monk project at APP_NAME from a static skeleton -- refuses
10
- to run if APP_NAME already exists.
11
-
12
- Base skeleton: Gemfile, config.ru, .ruby-version, bin/server,
13
- bin/websocket_server, SETUP.md -- WebSocket support needs no external
14
- service, so unlike Postgres/Redis below it isn't gated behind a flag:
15
- bin/websocket_server auto-authenticates if --auth's config/auth.rb is
16
- present, and fans out across processes over Redis if --redis's
17
- REDIS_URL is set, adapting at boot either way. SETUP.md is generated
18
- for every flag combination, walking through dev-then-test setup for
19
- this exact project -- including, since monk new scaffolds no test
20
- framework itself, the minimum Minitest wiring to get a working
21
- `bundle exec rake test`.
22
-
23
- Options:
24
- --postgres Also scaffold Postgres persistence/migrations support:
25
- config/persistence.rb, bin/console, bin/setup_db,
26
- bin/migrate, db/migrate/. Also wires config.ru to
27
- require it, writes .env/.env.test/.env.example
28
- (DB_NAME defaulting to APP_NAME_development/_test,
29
- not the generic app_development fallback baked into
30
- config/persistence.rb itself), and uncomments
31
- gem "dotenv" in the Gemfile so .env actually loads
32
- (config/settings.rb's require "dotenv/load" is a
33
- no-op with it left commented). SETUP.md's dev/test
34
- steps grow to cover Postgres containers, database
35
- creation, and migrations.
36
- --auth Also scaffold Monk::Auth (passwordless token auth).
37
- Implies --postgres -- Auth is Postgres-only, so this
38
- always brings the persistence scaffold with it. Adds
39
- config/auth.rb and a migration creating the
40
- login_tokens/sessions tables it needs. config.ru
41
- requires config/auth instead of config/persistence
42
- (config/auth.rb itself requires persistence), and
43
- .env/.env.test/.env.example get an AUTH_SECRET line.
44
- Also implies --mail, below: config/auth.rb sends the
45
- magic link through Monk::Mail, with an HTML part in
46
- views/mail/magic_link.erb.
47
- --mail Also scaffold Monk::Mail (text/HTML email over SMTP),
48
- no Postgres needed: config/mail.rb, required by
49
- config.ru, plus gem "net-smtp" in the Gemfile.
50
- .env/.env.test/.env.example get MAIL_FROM, and
51
- MAIL_URL where it matters: log:// in .env.test, an
52
- smtp:// placeholder in .env.example. Unset in
53
- development, MAIL_URL means log:// (messages printed,
54
- not sent); anywhere else it must be set.
55
- --redis Add the redis gem to the Gemfile, for
56
- bin/websocket_server's cross-process fan-out. Fully
57
- independent -- doesn't imply, and isn't implied by,
58
- --postgres or --auth. Writes/extends .env and
59
- .env.example with a REDIS_URL line (deliberately
60
- never .env.test -- only a test that exercises
61
- RedisFanout needs it) and uncomments gem "dotenv"
62
- if it isn't already (same reasoning as --postgres
63
- above), same whether or not --postgres is also set.
64
-
65
- --live Also scaffold Monk::Live: a demo counter whose open tabs
66
- update by themselves, pushed as HTML over a WebSocket.
67
- Requires --redis or --postgres: bin/server and
68
- bin/websocket_server are always separate processes, so
69
- one of the two has to carry a publish between them --
70
- with --redis, the usual Redis wiring; with --postgres
71
- (and not --redis), Monk::WebSocket::PgFanout over
72
- LISTEN/NOTIFY instead, reusing --postgres's own DB_*
73
- settings, so there's no Redis to run. Passing both
74
- picks --redis; passing neither is an error, since
75
- there's no way to tell whether Postgres is available.
76
- Adds
77
- config/live.rb, views/live/_hits.erb and the browser
78
- runtime under public/js/monk_live/, replaces config.ru,
79
- views/index.erb and bin/websocket_server with live
80
- versions, and adds the runtime's <meta>/<script> tags
81
- to the layout.
82
- --jobs Also scaffold Monk::Jobs (background jobs on Postgres).
83
- Implies --postgres, where the queue lives. Adds
84
- config/jobs.rb (required by config.ru, after
85
- persistence/auth/mail), jobs/hello_job.rb (a demo
86
- job, enqueued by a POST /jobs/hello route added to
87
- config.ru), bin/jobs (the job process, run beside
88
- bin/server), and the migration creating the queue's
89
- tables. .env/.env.example get JOBS_WORKERS and
90
- JOBS_QUEUES (not .env.test: tests run jobs with
91
- Monk::Jobs.drain!).
92
-
93
- How flags combine -- a flag is implied when there's only one right
94
- answer, and required when there's a real choice:
95
-
96
- Flag Implies Requires
97
- --auth --postgres, --mail -
98
- --jobs --postgres -
99
- --live - --redis or --postgres (Live's
100
- transport: a choice, so it's yours)
101
- --postgres, --mail, --redis stand alone.
102
-
103
- Some pairs also change what gets generated:
104
-
105
- --auth + --jobs jobs/send_login_link.rb: login links sent from a job
106
- --mail + --jobs config/jobs.rb loads Monk::Mail.deliver_later;
107
- JOBS_QUEUES serves mailers first
108
- --live + --redis config/live.rb fans out over Redis
109
- --live + --postgres config/live.rb fans out over Postgres (without --redis)
110
-
111
- `monk new` prints which flags it turned on, and why, after creating the
112
- project.
113
-
114
- Commands:
115
- new APP_NAME Create a new project (see Options above)
116
- help Show this help (also --help, -h)
117
-
118
- After creating a project:
119
- cd APP_NAME
120
- bundle install
121
-
122
- See the generated SETUP.md for a full dev-then-test walkthrough, tailored
123
- to whichever flags were passed. With --postgres (or --auth, which implies
124
- it), short version, once a reachable Postgres exists (.env/.env.test have
125
- the connection details):
126
- bin/setup_db Apply pending migrations
127
- bin/migrate migrate | rollback [N] | status
128
- bin/console IRB with persistence loaded
129
-
130
- With --auth, .env/.env.test ship a placeholder AUTH_SECRET -- change it
131
- before relying on it; config/auth.rb reads it via ENV.fetch and raises
132
- if it's missing entirely.
133
-
134
- With --redis, .env ships a placeholder REDIS_URL to turn
135
- bin/websocket_server's cross-process fan-out on; unset (as in .env.test),
136
- it runs in-process only, same as without --redis.
137
- TEXT
138
-
139
- def usage_error!
140
- warn USAGE
141
- exit 1
142
- end
143
-
144
- command, *rest = ARGV
145
-
146
- case command
147
- when nil, "help", "--help", "-h"
148
- puts HELP
149
- when "new"
150
- app_name = rest.find { |arg| !arg.start_with?("-") }
151
- usage_error! if app_name.nil?
152
-
153
- # Flags as typed: Monk::Scaffold resolves what they imply, and reports it.
154
- auth = rest.include?("--auth")
155
- mail = rest.include?("--mail") || auth # for the next-steps lines below
156
- redis = rest.include?("--redis")
157
- live = rest.include?("--live")
158
- jobs = rest.include?("--jobs")
159
- scaffold = Monk::Scaffold.new(
160
- app_name, postgres: rest.include?("--postgres"), auth: auth, mail: rest.include?("--mail"), redis: redis,
161
- live: live, jobs: jobs,
162
- )
163
- scaffold.write!
164
-
165
- puts "Created #{app_name}."
166
- puts scaffold.summary
167
- puts "Next steps:"
168
- puts " cd #{app_name}"
169
- puts " bundle install"
170
- puts " see SETUP.md for the full dev-then-test walkthrough"
171
- puts " change the placeholder AUTH_SECRET in .env/.env.test before relying on it" if auth
172
- puts " set MAIL_URL and MAIL_FROM outside development (see config/mail.rb)" if mail
173
- puts " run bin/jobs beside bin/server to process background jobs (see SETUP.md)" if jobs
174
- if live
175
- if redis
176
- puts " Monk::Live needs Redis running, bin/server and bin/websocket_server (see SETUP.md)"
177
- else
178
- puts " Monk::Live needs bin/server and bin/websocket_server running, sharing Postgres (see SETUP.md)"
179
- end
180
- end
181
- else
182
- usage_error!
183
- end
5
+ exit Monk::CLI.new.run(ARGV)
data/lib/monk/assets.rb CHANGED
@@ -65,9 +65,28 @@ module Monk
65
65
  # hazard (Monk.env resolves this the same way, through Settings'
66
66
  # own frozen-at-Boot snapshot). `assets false` sets root to false
67
67
  # and disables serving.
68
- attr_reader :root, :manifest
68
+ attr_reader :root, :manifest, :mounts
69
69
  attr_writer :root
70
70
 
71
+ # Serves a module's own static files from inside the gem, under a
72
+ # /_monk/ prefix, next to the app's public/: Monk::Live mounts its
73
+ # browser client at /_monk/live, so no app has to copy it
74
+ # (docs/adr/0017). Called when the module is required, before Boot;
75
+ # mounted files go in the same manifest, so they're served, stamped
76
+ # and cached exactly like the app's own.
77
+ def mount(prefix, dir)
78
+ prefix = prefix.chomp("/")
79
+ raise ArgumentError, "Monk::Assets.mount prefix must start with /_monk/, got #{prefix.inspect}" unless
80
+ prefix.start_with?("/_monk/")
81
+
82
+ @mounts = mounts.merge(prefix.freeze => File.expand_path(dir).freeze)
83
+ end
84
+
85
+ # Test-only.
86
+ def unmount(prefix)
87
+ @mounts = mounts.except(prefix)
88
+ end
89
+
71
90
  def enabled?
72
91
  !!root
73
92
  end
@@ -117,6 +136,7 @@ module Monk
117
136
  # Called from Base#freeze! (Seam B), via Monk.freeze_hooks.
118
137
  def freeze_registry!
119
138
  @production = !Monk.env.development?
139
+ @mounts = Ractor.make_shareable(mounts)
120
140
  @manifest = Ractor.make_shareable(build_manifest)
121
141
  # #root is read on every request (#enabled?, and the disk lookup
122
142
  # in development). Left unfrozen, reading it from a worker Ractor
@@ -125,7 +145,7 @@ module Monk
125
145
  @root = root.freeze
126
146
  end
127
147
 
128
- # Test-only.
148
+ # Test-only. Mounts stay: a module mounts once, when it's required.
129
149
  def reset!
130
150
  @root = DEFAULT_ROOT
131
151
  @manifest = {}
@@ -142,29 +162,38 @@ module Monk
142
162
  private
143
163
 
144
164
  def build_manifest
145
- return {} unless enabled? && Dir.exist?(root)
165
+ return {} unless enabled?
166
+
167
+ manifest = Dir.exist?(root) ? files_under(root, "") : {}
168
+ mounts.each { |prefix, dir| manifest.merge!(files_under(dir, prefix)) }
169
+ manifest
170
+ end
146
171
 
147
- Dir.glob("**/*", base: root).each_with_object({}) do |relative, manifest|
148
- path = File.join(root, relative)
172
+ def files_under(dir, prefix)
173
+ Dir.glob("**/*", base: dir).each_with_object({}) do |relative, files|
174
+ path = File.join(dir, relative)
149
175
  next unless File.file?(path)
150
176
 
151
- manifest["/#{relative}"] = entry_for(relative, File.binread(path))
177
+ files["#{prefix}/#{relative}"] = entry_for(relative, File.binread(path))
152
178
  end
153
179
  end
154
180
 
155
181
  def lookup(path)
156
182
  return manifest[path] if production?
157
183
 
158
- disk_entry(path)
184
+ prefix, dir = mounts.find { |mount_prefix, _| path.start_with?("#{mount_prefix}/") }
185
+ return disk_entry(dir, path.delete_prefix(prefix)) if prefix
186
+
187
+ disk_entry(root, path)
159
188
  end
160
189
 
161
190
  # Development only. PATH_INFO is not un-escaped here on purpose: an
162
191
  # encoded "%2e%2e" stays a literal, nonexistent filename, and a
163
192
  # decoded ".." is caught by the containment check below.
164
- def disk_entry(path)
193
+ def disk_entry(dir, path)
165
194
  return nil if path.empty? || path.include?("\0")
166
195
 
167
- root_path = File.expand_path(root)
196
+ root_path = File.expand_path(dir)
168
197
  full = File.expand_path(File.join(root_path, path.delete_prefix("/")))
169
198
  return nil unless full.start_with?("#{root_path}#{File::SEPARATOR}")
170
199
  return nil unless File.file?(full)
@@ -190,6 +219,7 @@ module Monk
190
219
 
191
220
  @root = DEFAULT_ROOT
192
221
  @manifest = {}
222
+ @mounts = {}
193
223
  @production = false
194
224
 
195
225
  Monk.freeze_hooks << self
@@ -8,6 +8,9 @@ module Monk
8
8
  class MissingAuthConfigError < StandardError
9
9
  end
10
10
 
11
+ class InvalidAuthConfigError < StandardError
12
+ end
13
+
11
14
  class MissingAuthDeliveryError < StandardError
12
15
  end
13
16
  end
@@ -42,6 +42,17 @@ module Monk
42
42
  add_response_cookie("csrf_token", "", http_only: false, max_age: 0)
43
43
  end
44
44
 
45
+ # Ends the current session: revokes it, whether its token came as a
46
+ # Bearer header or the session cookie, and clears the cookies -- with
47
+ # or without a session to end. On a cookie-authenticated route, call
48
+ # require_csrf! first.
49
+ def log_out!
50
+ token = bearer_token || session_cookie_token
51
+ Monk::Auth.revoke(token) if token
52
+ clear_session_cookie
53
+ nil
54
+ end
55
+
45
56
  private
46
57
 
47
58
  def bearer_token
@@ -0,0 +1,36 @@
1
+ # Monk::Auth::SendLoginLink. Loaded at the end of whichever of monk/auth
2
+ # and monk/jobs is required second, once both are defined
3
+ # (docs/adr/0017), so it requires neither itself.
4
+
5
+ module Monk
6
+ module Auth
7
+ # Sends a magic link from bin/jobs instead of the login request. The
8
+ # token is created here, right before it's sent, so the raw token is
9
+ # never stored anywhere -- not even in the job queue, which pointing
10
+ # deliver: at Monk::Mail.deliver_later would do
11
+ # (docs/adr/0014-mail-from-jobs-and-login-links-created-in-the-job.md).
12
+ # Enqueue it from the app's login route, after its own per-email rate
13
+ # limit:
14
+ #
15
+ # post("/auth/request") do
16
+ # Monk::Auth::SendLoginLink.enqueue(params[:email])
17
+ # json(sent: true)
18
+ # end
19
+ #
20
+ # The link is Monk::Auth.login_link (public_url + callback_path:), and
21
+ # it's sent through configure's deliver:, synchronously, in the job.
22
+ # Subclass it to change the queue or the retries.
23
+ class SendLoginLink < Monk::Job
24
+ queue "mailers"
25
+ # About 50 seconds of retries: a login email minutes late, after the
26
+ # user has likely asked for another, is worse than none.
27
+ max_attempts 3
28
+ never_retry Monk::InvalidRedirectError
29
+
30
+ def self.perform(email, redirect_to = nil)
31
+ token = Monk::Auth.request_login(email, redirect_to: redirect_to)
32
+ Monk::Auth.deliver_link(email: email, link: Monk::Auth.login_link(token), token: token)
33
+ end
34
+ end
35
+ end
36
+ end
data/lib/monk/auth.rb CHANGED
@@ -34,13 +34,18 @@ module Monk
34
34
  freeze_rqrcode!
35
35
  end
36
36
 
37
+ # callback_path: is where the app's own callback route redeems a
38
+ # token (GET "#{callback_path}/:token"). Monk doesn't define that
39
+ # route, but builds links to it: login_link below, and so
40
+ # Monk::Auth::SendLoginLink.
37
41
  def configure(
38
42
  db_name: nil, secret: nil, login_ttl: nil, session_ttl: nil, redirect_allowlist: [], secure: true,
39
- deliver: nil
43
+ deliver: nil, callback_path: "/auth/callback"
40
44
  )
41
45
  config = {
42
46
  db_name: db_name, secret: secret, login_ttl: login_ttl, session_ttl: session_ttl,
43
47
  redirect_allowlist: redirect_allowlist, secure: secure, deliver: deliver,
48
+ callback_path: checked_callback_path(callback_path),
44
49
  }
45
50
 
46
51
  REQUIRED_CONFIG_KEYS.each do |key|
@@ -81,6 +86,17 @@ module Monk
81
86
  raw
82
87
  end
83
88
 
89
+ # The magic link for a raw token: the app's public_url
90
+ # (config/settings.rb, a trusted origin -- never request headers like
91
+ # X-Forwarded-Host, which any direct client can spoof), then
92
+ # callback_path:, then the token.
93
+ #
94
+ # Monk::Auth.login_link(token) # => "https://app.example/auth/callback/<token>"
95
+ def login_link(token)
96
+ ensure_configured!
97
+ "#{Monk::Settings[:public_url]}#{config[:callback_path]}/#{token}"
98
+ end
99
+
84
100
  # The single place an app's login-request handler calls to get a
85
101
  # magic link to its owner: uses the `deliver:` callable passed to
86
102
  # `configure` (e.g. wrapping a mailer or a provider's HTTP API) when
@@ -94,10 +110,8 @@ module Monk
94
110
  # that Monk::Mail exists (docs/adr/0012-minimal-built-in-mailer.md):
95
111
  # it's usually a one-line Monk::Mail.deliver, but can be any channel.
96
112
  #
97
- # `link` is the app's own job to build (Monk doesn't own routing, so
98
- # it can't know the callback path) -- from a trusted origin, e.g.
99
- # Monk::Settings[:public_url], never from request headers like
100
- # X-Forwarded-Proto/Host, which any direct client can spoof.
113
+ # `link` is usually login_link(token), above. An app may build its
114
+ # own instead, from a trusted origin, never from request headers.
101
115
  # `token` is passed through for a deliver: that wants it (an SMS
102
116
  # body instead of a link, say); most won't need it.
103
117
  def deliver_link(email:, link:, token: nil)
@@ -254,6 +268,17 @@ module Monk
254
268
  "Monk::Auth is not configured -- call Monk::Auth.configure first" unless @config
255
269
  end
256
270
 
271
+ # An absolute path, kept without a trailing slash so login_link
272
+ # adds exactly one before the token.
273
+ def checked_callback_path(path)
274
+ unless path.is_a?(String) && path.start_with?("/")
275
+ raise Monk::InvalidAuthConfigError,
276
+ "Monk::Auth.configure(callback_path:) must be an absolute path like \"/auth/callback\", got #{path.inspect}"
277
+ end
278
+
279
+ path.chomp("/").freeze
280
+ end
281
+
257
282
  def hash_token(raw)
258
283
  Digest::SHA256.hexdigest(raw)
259
284
  end
@@ -299,3 +324,7 @@ module Monk
299
324
  Monk.freeze_hooks << self
300
325
  end
301
326
  end
327
+
328
+ # Monk::Auth::SendLoginLink, when jobs are loaded too -- either order works
329
+ # (lib/monk/jobs.rb ends the same way, docs/adr/0017).
330
+ require_relative "auth/send_login_link" if Monk.const_defined?(:Jobs)