trmnl_preview 0.8.9 → 0.9.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04ad9007c6a3cc5b8d3c724ec349b204c977f861fc41cb637428fba48c715b89
4
- data.tar.gz: ebc4873c90c6effcfc356ff13e38eb9b63d7547af14b85dc62eb84bb48145893
3
+ metadata.gz: cbbc567f2937e7dd1aabc75991df616728f88fa858d797384a4a63453265be5c
4
+ data.tar.gz: 1b1e543aa9ea1dddbb456f061f1538c22cc0edd849fc6faced3d67fa5f682677
5
5
  SHA512:
6
- metadata.gz: c43c02691942bcea76563ce97d2b7e55f3327d207747a930374f750ad4edd257f8ed5b5edb94424ec008c3d0047735df33cb51399d44023a28522169613c6148
7
- data.tar.gz: 125935ffec1af3606b4a1f6e0d7687137349f25f2da0da1f03e989ad598c25b34cf56f23964ab5d16f448632307b858744bd3d7547c5babda94bb8e1bb828ba7
6
+ metadata.gz: 7b0ab9feab22239ce5e2eaa6c9b2fc7d1f889927e267c14d83eb6ec02e60a973acb9194fee59cae709ea7640c43741186788588b33e9d76ac023a5d5f09280d4
7
+ data.tar.gz: c18a319c45dec945b5c8f71d56c47f4478b8a82bceaed429b475cce4f57cb8a29541bcf7b1312e8fb9624513aebbd434d05c6e64f02d32322e9628cffc08553f
data/CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## 0.9.0
5
+
6
+ - Added OAuth2 support for local plugin preview (beta). Configure a provider with the flat `oauth_*` keys in `src/settings.yml` (authorize and token URLs, scopes, optional PKCE), which round-trip through `trmnlp push` and `pull`. Set `TRMNL_OAUTH_CLIENT_ID` and `TRMNL_OAUTH_CLIENT_SECRET` in your environment (credentials stay local and are never synced), and register `http://localhost:4567/oauth/callback` as the redirect URI in your OAuth app. `trmnlp serve` shows a Connect banner that runs the authorization code flow in the browser, stores the tokens in the cache directory (never in the project), and refreshes them before they expire. The access token is exposed to polling templates as `{{ oauth_access_token }}`, matching the hosted service. This is a beta feature; please report incorrect behaviour at https://github.com/usetrmnl/trmnlp/issues.
7
+
8
+ ## 0.8.10
9
+
10
+ - Fixed Python serverless transforms failing on Windows. The local subprocess backend hardcoded `python3`, which the python.org Windows installer does not put on PATH (it installs `python` and the `py` launcher), so every Python transform raised "interpreter not available". The interpreter is now resolved from a per-language list of command candidates (`python3`, then `python`, then the `py` launcher) using a cross-platform PATH lookup, leaving POSIX behavior unchanged. (#116)
11
+
4
12
  ## 0.8.9
5
13
 
6
14
  - Fixed `trmnlp serve` binding to localhost under Podman, which left the dev server unreachable through published ports. Container detection only looked for `/.dockerenv`, which Docker writes but Podman does not, so `serve` fell back to `127.0.0.1`. It now also checks for `/run/.containerenv`, which Podman writes, so the automatic `0.0.0.0` bind works for both runtimes. (#112)
data/README.md CHANGED
@@ -314,6 +314,63 @@ variables:
314
314
 
315
315
  ```
316
316
 
317
+ ## OAuth2
318
+
319
+ This feature is in beta. Please report incorrect behaviour at https://github.com/usetrmnl/trmnlp/issues.
320
+
321
+ Some private plugins fetch data from a third-party API that requires the user to authorize access first (the OAuth2 authorization code flow). `trmnlp` can run that flow locally so you can preview the plugin with a real token. It injects the token into your polling request the same way the hosted service does, so a plugin that works locally behaves the same once deployed.
322
+
323
+ ### 1. Configure the provider
324
+
325
+ Add the flat `oauth_*` keys to `src/settings.yml`. These are the provider definition, and they round-trip through `trmnlp push` and `pull` (the hosted service stores them on the plugin setting):
326
+
327
+ ```yaml
328
+ oauth_enabled: "true"
329
+ oauth_authorize_url: https://github.com/login/oauth/authorize
330
+ oauth_token_url: https://github.com/login/oauth/access_token
331
+ oauth_scopes: "read:user user:email"
332
+ oauth_pkce_enabled: "true" # optional, default false
333
+ # oauth_scope_separator: " " # optional; some providers use ","
334
+ # oauth_refresh_url: https://... # optional; defaults to oauth_token_url
335
+ ```
336
+
337
+ Your OAuth app credentials stay local and are never synced, so set them in your environment:
338
+
339
+ ```sh
340
+ export TRMNL_OAUTH_CLIENT_ID=your-oauth-app-client-id
341
+ export TRMNL_OAUTH_CLIENT_SECRET=your-oauth-app-client-secret
342
+ ```
343
+
344
+ PKCE-only providers do not need a client secret.
345
+
346
+ ### 2. Register the redirect URI
347
+
348
+ In your OAuth app on the provider's site, register this redirect URI:
349
+
350
+ ```
351
+ http://localhost:4567/oauth/callback
352
+ ```
353
+
354
+ Match the port if you run `trmnlp serve` on a different one.
355
+
356
+ ### 3. Connect
357
+
358
+ Run `trmnlp serve` and open the preview. When OAuth is configured but not yet connected, a **Connect account** banner appears. Click it to authorize in your browser. `trmnlp` stores the tokens in its cache directory (never in your project) and refreshes them automatically before they expire. Use the **Disconnect** link to reconnect after changing scopes.
359
+
360
+ ### 4. Use the token
361
+
362
+ Reference the token in your `src/settings.yml` polling configuration with the same variables the hosted service exposes:
363
+
364
+ - `{{ oauth_access_token }}`
365
+ - `{{ oauth_token_type }}` (defaults to `Bearer`)
366
+ - `{{ oauth_client_id }}`
367
+
368
+ For example, as a polling header:
369
+
370
+ ```
371
+ Authorization=Bearer {{ oauth_access_token }}
372
+ ```
373
+
317
374
  ## Serverless Transforms
318
375
 
319
376
  `trmnlp` can run a transform script (`python`, `ruby`, `php`, or `node`) against the polled API response before handing data to your Liquid templates — matching the hosted plugin service's behavior.
data/lib/trmnlp/app.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
3
4
  require 'sinatra'
4
5
  require 'sinatra/base'
5
6
 
@@ -33,6 +34,10 @@ module TRMNLP
33
34
  def h(text)
34
35
  Rack::Utils.escape_html(text.to_s)
35
36
  end
37
+
38
+ # Derived from the live request so it matches at authorize and exchange
39
+ # time; this is the single URI the developer registers with the provider.
40
+ def oauth_callback_uri = "#{request.base_url}/oauth/callback"
36
41
  end
37
42
 
38
43
  def initialize(*args)
@@ -44,6 +49,10 @@ module TRMNLP
44
49
  @user_data_assembler = @context.user_data_assembler
45
50
  @transform_pipeline = @context.transform_pipeline
46
51
  @watcher = @context.watcher
52
+ @oauth_session = @context.oauth_session
53
+ # Keyed by state. A shared hash (built once) survives Sinatra's
54
+ # per-request dup, like @live_reload_clients below.
55
+ @oauth_state = {}
47
56
  @screenshot = Screenshot.new(pool: settings.browser_pool)
48
57
 
49
58
  @poller.poll_data
@@ -118,6 +127,35 @@ module TRMNLP
118
127
  redirect back
119
128
  end
120
129
 
130
+ get '/oauth/connect' do
131
+ halt 400, 'OAuth is not configured. Add the oauth_* keys to src/settings.yml.' unless @oauth_session.configured?
132
+
133
+ state = SecureRandom.hex(16)
134
+ if @oauth_session.pkce?
135
+ verifier = OAuth::Pkce.verifier
136
+ challenge = OAuth::Pkce.challenge(verifier)
137
+ end
138
+ @oauth_state[state] = verifier
139
+ redirect @oauth_session.authorize_url(redirect_uri: oauth_callback_uri, state:, code_challenge: challenge)
140
+ end
141
+
142
+ get '/oauth/callback' do
143
+ halt 400, "OAuth provider returned an error: #{params[:error]}" if params[:error]
144
+ halt 400, 'OAuth state mismatch. Restart at /oauth/connect.' unless @oauth_state.key?(params[:state])
145
+
146
+ verifier = @oauth_state.delete(params[:state])
147
+ @oauth_session.complete(code: params[:code], redirect_uri: oauth_callback_uri, code_verifier: verifier)
148
+ @poller.poll_data
149
+ redirect '/'
150
+ rescue StandardError => e
151
+ halt 502, "OAuth token exchange failed: #{e.message}"
152
+ end
153
+
154
+ get '/oauth/disconnect' do
155
+ @oauth_session.disconnect
156
+ redirect '/'
157
+ end
158
+
121
159
  Screen.all.each do |screen|
122
160
  view = screen.name
123
161
  get "/#{view}" do
@@ -30,14 +30,14 @@ module TRMNLP
30
30
  def webhook? = strategy == 'webhook'
31
31
  def static? = strategy == 'static'
32
32
 
33
- def polling_urls
33
+ def polling_urls(extra_variables: {})
34
34
  # allow project-level config to override
35
35
  urls = project_config.user_data_overrides.dig('trmnl', 'plugin_settings',
36
36
  'polling_url') || @config['polling_url']
37
37
 
38
38
  return [] if urls.nil?
39
39
 
40
- with_custom_fields(urls).strip.split("\n")
40
+ with_custom_fields(urls, extra_variables:).strip.split("\n")
41
41
  end
42
42
 
43
43
  # for {{ trmnl }}
@@ -45,18 +45,18 @@ module TRMNLP
45
45
 
46
46
  def polling_verb = @config['polling_verb'] || 'GET'
47
47
 
48
- def polling_headers
48
+ def polling_headers(extra_variables: {})
49
49
  # NOTE: render Liquid across the full headers string first so {% if %} blocks
50
50
  # spanning multiple key=value pairs are preserved. Splitting on
51
51
  # '&' or '=' before rendering would shatter tags into multiple values.
52
- rendered = with_custom_fields(@config['polling_headers'] || '')
52
+ rendered = with_custom_fields(@config['polling_headers'] || '', extra_variables:)
53
53
  string_to_hash(rendered)
54
54
  end
55
55
 
56
56
  # for {{ trmnl }}
57
57
  def polling_headers_encoded = polling_headers.map { |k, v| "#{k}=#{v}" }.join('&')
58
58
 
59
- def polling_body = with_custom_fields(@config['polling_body'] || '')
59
+ def polling_body(extra_variables: {}) = with_custom_fields(@config['polling_body'] || '', extra_variables:)
60
60
 
61
61
  def dark_mode = @config['dark_mode'] || 'no'
62
62
 
@@ -107,7 +107,9 @@ module TRMNLP
107
107
 
108
108
  attr_reader :paths, :project_config
109
109
 
110
- def with_custom_fields(value) = project_config.with_custom_fields(value)
110
+ def with_custom_fields(value, extra_variables: {})
111
+ project_config.with_custom_fields(value, extra_variables:)
112
+ end
111
113
 
112
114
  def string_to_hash(str, delimiter: '=')
113
115
  str.split('&').map do |k_v|
@@ -38,10 +38,11 @@ module TRMNLP
38
38
 
39
39
  def user_data_overrides = @config['variables'] || {}
40
40
 
41
- # for interpolating custom_fields into polling_* options
42
- def with_custom_fields(value)
41
+ # extra_variables lets the poller inject live values (e.g.
42
+ # oauth_access_token) into the same custom-field render.
43
+ def with_custom_fields(value, extra_variables: {})
43
44
  custom_fields_with_env = custom_fields.transform_values { |v| with_env(v) }
44
- parse_liquid(value).render(custom_fields_with_env)
45
+ parse_liquid(value).render(custom_fields_with_env.merge(extra_variables))
45
46
  end
46
47
 
47
48
  def time_zone = @config['time_zone'] || 'UTC'
@@ -22,7 +22,17 @@ module TRMNLP
22
22
  # Context is the composition root: it wires and memoizes the runtime
23
23
  # object graph. Callers take the collaborator they need and talk to it
24
24
  # directly — Context does not forward methods on their behalf.
25
- def poller = @poller ||= Poller.new(config:, paths:, reporter:)
25
+ def poller = @poller ||= Poller.new(config:, paths:, oauth_session:, reporter:)
26
+
27
+ def oauth_session
28
+ @oauth_session ||= begin
29
+ provider = OAuth::Provider.new(config.plugin.settings)
30
+ OAuth::Session.new(provider:,
31
+ token_store: OAuth::TokenStore.new(paths.oauth_tokens),
32
+ client: OAuth::Client.new(provider))
33
+ end
34
+ end
35
+
26
36
  def transform_pipeline = @transform_pipeline ||= TransformPipeline.new(config:, paths:, reporter:)
27
37
  def user_data_assembler = @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:)
28
38
  def renderer = @renderer ||= Renderer.new(config:, paths:, user_data_assembler:)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oauth2'
4
+ require 'uri'
5
+
6
+ module TRMNLP
7
+ module OAuth
8
+ class Client
9
+ def initialize(provider)
10
+ @provider = provider
11
+ end
12
+
13
+ def authorize_url(redirect_uri:, state:, code_challenge: nil)
14
+ params = { redirect_uri:, scope: provider.scopes, state: }
15
+ params.merge!(code_challenge:, code_challenge_method: 'S256') if code_challenge
16
+ oauth_client(provider.token_url).auth_code.authorize_url(**params)
17
+ end
18
+
19
+ def exchange_code(code:, redirect_uri:, code_verifier: nil)
20
+ params = { redirect_uri: }
21
+ params[:code_verifier] = code_verifier if code_verifier
22
+ bundle oauth_client(provider.token_url).auth_code.get_token(code, params)
23
+ end
24
+
25
+ def refresh(refresh_token:)
26
+ client = oauth_client(provider.refresh_url)
27
+ bundle OAuth2::AccessToken.new(client, nil, refresh_token:).refresh!
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :provider
33
+
34
+ def oauth_client(token_url)
35
+ OAuth2::Client.new(
36
+ provider.client_id, provider.client_secret,
37
+ site: origin(token_url), authorize_url: provider.authorize_url,
38
+ token_url:, auth_scheme:
39
+ )
40
+ end
41
+
42
+ # A PKCE public client has no secret, so its client_id must travel in the
43
+ # request body; a confidential client authenticates with HTTP Basic.
44
+ def auth_scheme = provider.client_secret ? :basic_auth : :request_body
45
+
46
+ # oauth2 posts tokens through a connection built from the site, so give it
47
+ # the endpoint's origin even though the full URL is absolute.
48
+ def origin(url)
49
+ uri = URI(url)
50
+ port = uri.port == uri.default_port ? nil : ":#{uri.port}"
51
+ "#{uri.scheme}://#{uri.host}#{port}"
52
+ end
53
+
54
+ def bundle(token)
55
+ TokenBundle.new(
56
+ access_token: token.token,
57
+ refresh_token: token.refresh_token,
58
+ expires_at: token.expires_at,
59
+ token_type: token.params['token_type'] || 'Bearer'
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+ require 'base64'
6
+
7
+ module TRMNLP
8
+ module OAuth
9
+ # Proof Key for Code Exchange (RFC 7636, S256).
10
+ module Pkce
11
+ module_function
12
+
13
+ def verifier = SecureRandom.urlsafe_base64(64)
14
+
15
+ def challenge(verifier)
16
+ Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ module OAuth
5
+ # Provider definition from the flat oauth_* keys in src/settings.yml (which
6
+ # round-trip through push/pull). Credentials stay local and env-first so
7
+ # they are never synced or committed.
8
+ class Provider
9
+ ENV_CLIENT_ID = 'TRMNL_OAUTH_CLIENT_ID'
10
+ ENV_CLIENT_SECRET = 'TRMNL_OAUTH_CLIENT_SECRET'
11
+
12
+ def initialize(settings, env: ENV)
13
+ @settings = settings || {}
14
+ @env = env
15
+ end
16
+
17
+ def authorize_url = settings['oauth_authorize_url']
18
+ def token_url = settings['oauth_token_url']
19
+ def refresh_url = settings['oauth_refresh_url'] || token_url
20
+ def scopes = settings['oauth_scopes']
21
+ def scope_separator = settings['oauth_scope_separator'] || ' '
22
+ def pkce? = truthy?(settings['oauth_pkce_enabled'])
23
+ def enabled? = truthy?(settings['oauth_enabled'])
24
+ def client_id = env_first(ENV_CLIENT_ID, settings['oauth_client_id'])
25
+ def client_secret = env_first(ENV_CLIENT_SECRET, settings['oauth_client_secret'])
26
+
27
+ def configured?
28
+ return false unless enabled? && authorize_url && token_url && client_id
29
+
30
+ pkce? || present?(client_secret)
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :settings, :env
36
+
37
+ def truthy?(value) = [true, 'true'].include?(value)
38
+
39
+ def env_first(key, fallback)
40
+ value = env[key]
41
+ present?(value) ? value : fallback
42
+ end
43
+
44
+ def present?(value) = !value.nil? && !value.empty?
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ module OAuth
5
+ class RefreshError < StandardError; end
6
+
7
+ class Session
8
+ def initialize(provider:, token_store:, client:)
9
+ @provider = provider
10
+ @token_store = token_store
11
+ @client = client
12
+ end
13
+
14
+ def configured? = provider.configured?
15
+
16
+ def pkce? = provider.pkce?
17
+
18
+ def connected?
19
+ return false unless configured?
20
+
21
+ stored = token_store.read
22
+ !stored.nil? && !stored.access_token.nil?
23
+ end
24
+
25
+ def access_token = current_bundle&.access_token
26
+
27
+ def liquid_variables
28
+ return {} unless connected?
29
+
30
+ bundle = current_bundle
31
+ {
32
+ 'oauth_access_token' => bundle.access_token,
33
+ 'oauth_token_type' => bundle.token_type,
34
+ 'oauth_client_id' => provider.client_id
35
+ }
36
+ end
37
+
38
+ def authorize_url(redirect_uri:, state:, code_challenge: nil)
39
+ client.authorize_url(redirect_uri:, state:, code_challenge:)
40
+ end
41
+
42
+ def complete(code:, redirect_uri:, code_verifier: nil)
43
+ token_store.write(client.exchange_code(code:, redirect_uri:, code_verifier:))
44
+ end
45
+
46
+ def disconnect = token_store.clear
47
+
48
+ private
49
+
50
+ attr_reader :provider, :token_store, :client
51
+
52
+ # Returns the stored token, transparently refreshing and re-persisting it
53
+ # when it is near expiry. nil when nothing is connected yet.
54
+ def current_bundle
55
+ stored = token_store.read
56
+ return nil if stored.nil? || stored.access_token.nil?
57
+
58
+ stored.expired? ? refreshed(stored) : stored
59
+ end
60
+
61
+ def refreshed(stale)
62
+ token_store.write stale.merge_refresh(client.refresh(refresh_token: stale.refresh_token))
63
+ rescue StandardError => e
64
+ raise RefreshError, "OAuth token refresh failed: #{e.message}. Reconnect at /oauth/connect."
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ module OAuth
5
+ TokenBundle = Data.define(:access_token, :refresh_token, :expires_at, :token_type) do
6
+ def self.from_h(hash)
7
+ new(
8
+ access_token: hash['access_token'],
9
+ refresh_token: hash['refresh_token'],
10
+ expires_at: hash['expires_at'],
11
+ token_type: hash['token_type'] || 'Bearer'
12
+ )
13
+ end
14
+
15
+ def expired?
16
+ return false unless expires_at
17
+
18
+ # Treat as expired 5 minutes early so a token never dies mid-request.
19
+ expires_at.to_i - 300 <= Time.now.to_i
20
+ end
21
+
22
+ # A refresh response often omits the rotated refresh_token (and sometimes
23
+ # other fields); the prior values remain valid, so carry them forward.
24
+ def merge_refresh(fresh)
25
+ with(
26
+ access_token: fresh.access_token,
27
+ refresh_token: fresh.refresh_token || refresh_token,
28
+ expires_at: fresh.expires_at || expires_at,
29
+ token_type: fresh.token_type || token_type
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module TRMNLP
6
+ module OAuth
7
+ class TokenStore
8
+ def initialize(path)
9
+ @path = path
10
+ end
11
+
12
+ def read
13
+ return nil unless path.exist?
14
+
15
+ TokenBundle.from_h(JSON.parse(path.read))
16
+ end
17
+
18
+ def write(bundle)
19
+ path.dirname.mkpath
20
+ path.write(JSON.generate(bundle.to_h))
21
+ path.chmod(0o600)
22
+ bundle
23
+ end
24
+
25
+ def clear
26
+ path.delete if path.exist?
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :path
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'oauth/pkce'
4
+ require_relative 'oauth/provider'
5
+ require_relative 'oauth/token_bundle'
6
+ require_relative 'oauth/token_store'
7
+ require_relative 'oauth/client'
8
+ require_relative 'oauth/session'
data/lib/trmnlp/paths.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
3
4
  require 'xdg'
4
5
 
5
6
  module TRMNLP
@@ -45,6 +46,10 @@ module TRMNLP
45
46
 
46
47
  def user_data = cache_dir.join('data.json')
47
48
 
49
+ # OAuth tokens are keyed per project root so working on two OAuth plugins
50
+ # does not clobber a shared token file.
51
+ def oauth_tokens = cache_dir.join('oauth', "#{Digest::SHA256.hexdigest(root_dir.to_s)[0, 16]}.json")
52
+
48
53
  def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
49
54
 
50
55
  def src_files = src_dir.glob('*').select(&:file?)
data/lib/trmnlp/poller.rb CHANGED
@@ -8,9 +8,10 @@ require_relative 'reporter'
8
8
 
9
9
  module TRMNLP
10
10
  class Poller
11
- def initialize(config:, paths:, reporter: Reporter.new)
11
+ def initialize(config:, paths:, oauth_session:, reporter: Reporter.new)
12
12
  @config = config
13
13
  @paths = paths
14
+ @oauth_session = oauth_session
14
15
  @reporter = reporter
15
16
  end
16
17
 
@@ -39,25 +40,33 @@ module TRMNLP
39
40
 
40
41
  private
41
42
 
42
- attr_reader :config, :paths, :reporter
43
+ attr_reader :config, :paths, :oauth_session, :reporter
43
44
 
44
45
  def aggregate_responses
45
- responses = config.plugin.polling_urls.map { |url| fetch_one(url) }
46
+ # Resolve once per poll (it refreshes as a side effect), then share across
47
+ # every URL, header, and body render.
48
+ oauth_variables = oauth_session.liquid_variables
49
+ urls = config.plugin.polling_urls(extra_variables: oauth_variables)
50
+ responses = urls.map { |url| fetch_one(url, oauth_variables) }
46
51
  return responses.first if responses.size == 1
47
52
 
48
53
  responses.each_with_index.with_object({}) { |(r, i), h| h["IDX_#{i}"] = r }
49
54
  end
50
55
 
51
- def fetch_one(url)
56
+ def fetch_one(url, oauth_variables)
52
57
  verb = config.plugin.polling_verb.upcase
53
- response = perform_request(url, verb)
58
+ response = perform_request(url, verb, oauth_variables)
54
59
  reporter.info("#{verb} #{url} — received #{response.body.length} bytes (#{response.status} status)")
55
60
  parse_response(response)
56
61
  end
57
62
 
58
- def perform_request(url, verb)
59
- conn = Faraday.new(url:, headers: config.plugin.polling_headers)
60
- verb == 'POST' ? conn.post { |req| req.body = config.plugin.polling_body } : conn.get
63
+ def perform_request(url, verb, oauth_variables)
64
+ conn = Faraday.new(url:, headers: config.plugin.polling_headers(extra_variables: oauth_variables))
65
+ if verb == 'POST'
66
+ conn.post { |req| req.body = config.plugin.polling_body(extra_variables: oauth_variables) }
67
+ else
68
+ conn.get
69
+ end
61
70
  end
62
71
 
63
72
  def parse_response(response)
@@ -4,6 +4,7 @@ require 'open3'
4
4
  require 'tmpdir'
5
5
 
6
6
  require_relative '../transform_client'
7
+ require_relative 'which'
7
8
  require_relative 'wrapper'
8
9
 
9
10
  module TRMNLP
@@ -19,11 +20,16 @@ module TRMNLP
19
20
  # Seconds a TERM'd process is given to exit before escalating to KILL.
20
21
  GRACE_PERIOD = 0.1
21
22
 
23
+ # Candidate commands per language, highest priority first. Windows is
24
+ # why a language needs more than one: its python.org installer provides
25
+ # `python` and the `py` launcher but no `python3`. `py` ranks last yet
26
+ # is the surest Windows hit — it stays on PATH even when the installer's
27
+ # optional "Add to PATH" step is skipped.
22
28
  INTERPRETERS = {
23
- 'python' => { cmd: 'python3', ext: 'py' },
24
- 'ruby' => { cmd: 'ruby', ext: 'rb' },
25
- 'node' => { cmd: 'node', ext: 'js' },
26
- 'php' => { cmd: 'php', ext: 'php' }
29
+ 'python' => { cmds: %w[python3 python py], ext: 'py' },
30
+ 'ruby' => { cmds: %w[ruby], ext: 'rb' },
31
+ 'node' => { cmds: %w[node], ext: 'js' },
32
+ 'php' => { cmds: %w[php], ext: 'php' }
27
33
  }.freeze
28
34
 
29
35
  def execute(code:, language:, stdin: '', timeout_seconds: DEFAULT_TIMEOUT)
@@ -40,7 +46,7 @@ module TRMNLP
40
46
  output_path = File.join(dir, 'output.json')
41
47
  src_path = File.join(dir, "transform.#{spec[:ext]}")
42
48
  File.write(src_path, Wrapper.for(language, code, sink_for(language, output_path)))
43
- run_process(spec[:cmd], src_path, stdin, timeout_seconds, output_path)
49
+ run_process(Which.resolve(spec[:cmds]), src_path, stdin, timeout_seconds, output_path)
44
50
  end
45
51
  rescue Errno::ENOENT, Errno::EACCES => e
46
52
  failure("interpreter not available: #{e.message}")
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ module TransformBackend
5
+ # Resolves a command to the interpreter actually present on PATH, so one
6
+ # transform runs unchanged on platforms that name an interpreter
7
+ # differently — most pressingly Windows, which ships no `python3`.
8
+ module Which
9
+ module_function
10
+
11
+ # Falls back to the first candidate when none resolve, leaving the
12
+ # caller to surface the usual "interpreter not available" ENOENT.
13
+ def resolve(candidates, path: ENV.fetch('PATH', ''), pathext: ENV.fetch('PATHEXT', ''))
14
+ candidates.find { |cmd| locate(cmd, path: path, pathext: pathext) } || candidates.first
15
+ end
16
+
17
+ # Windows marks executables by extension, enumerated in PATHEXT
18
+ # (.EXE/.BAT/...); POSIX leaves PATHEXT unset and relies on the exec bit.
19
+ def locate(cmd, path: ENV.fetch('PATH', ''), pathext: ENV.fetch('PATHEXT', ''))
20
+ suffixes = pathext.split(File::PATH_SEPARATOR)
21
+ suffixes = [''] if suffixes.empty?
22
+ path.split(File::PATH_SEPARATOR)
23
+ .product(suffixes)
24
+ .map { |dir, suffix| File.join(dir, "#{cmd}#{suffix}") }
25
+ .find { |candidate| File.file?(candidate) && File.executable?(candidate) }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.8.9'
4
+ VERSION = '0.9.0'
5
5
  end
data/lib/trmnlp.rb CHANGED
@@ -6,6 +6,7 @@ require 'trmnl/liquid'
6
6
  Oj.mimic_JSON
7
7
  TRMNL::Liquid::RailsHelpers = Module.new unless defined?(TRMNL::Liquid::RailsHelpers)
8
8
  require_relative 'trmnlp/errors'
9
+ require_relative 'trmnlp/oauth'
9
10
  require_relative 'trmnlp/config'
10
11
  require_relative 'trmnlp/context'
11
12
  require_relative 'trmnlp/screen'
@@ -54,6 +54,9 @@ Gem::Specification.new do |spec|
54
54
  spec.add_dependency 'mini_magick', '~> 5.3'
55
55
  spec.add_dependency 'selenium-webdriver', '~> 4.44'
56
56
 
57
+ # OAuth2 (local plugin preview auth flow)
58
+ spec.add_dependency 'oauth2', '~> 2.0'
59
+
57
60
  # Utilities
58
61
  spec.add_dependency 'cgi', '~> 0.5'
59
62
  spec.add_dependency 'faraday', '~> 2.1'
data/web/views/index.erb CHANGED
@@ -60,6 +60,12 @@
60
60
  </div>
61
61
  </menu>
62
62
 
63
+ <% if @oauth_session&.connected? %>
64
+ <div class="oauth-banner" style="padding: 0.5em 0.75em; margin: 0.5em 0; background: #efe; border: 1px solid #3a3; border-radius: 4px; color: #060;">OAuth account connected (beta). <a href="/oauth/disconnect">Disconnect</a> · <a href="https://github.com/usetrmnl/trmnlp/issues" target="_blank" rel="noopener">Report an issue</a></div>
65
+ <% elsif @oauth_session&.configured? %>
66
+ <div class="oauth-banner" style="padding: 0.5em 0.75em; margin: 0.5em 0; background: #eef; border: 1px solid #66c; border-radius: 4px; color: #234;"><a href="/oauth/connect">Connect account</a> to authorize this plugin's OAuth requests. OAuth is in beta. <a href="https://github.com/usetrmnl/trmnlp/issues" target="_blank" rel="noopener">Report an issue</a>.</div>
67
+ <% end %>
68
+
63
69
  <% if @transform_error %>
64
70
  <div class="transform-error" style="padding: 0.5em 0.75em; margin: 0.5em 0; background: #fee; border: 1px solid #c33; border-radius: 4px; color: #900; font-family: monospace; white-space: pre-wrap;">Transform error: <%= h @transform_error %></div>
65
71
  <% end %>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trmnl_preview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.9
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rockwell Schrock
@@ -107,6 +107,20 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '4.44'
110
+ - !ruby/object:Gem::Dependency
111
+ name: oauth2
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '2.0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '2.0'
110
124
  - !ruby/object:Gem::Dependency
111
125
  name: cgi
112
126
  requirement: !ruby/object:Gem::Requirement
@@ -304,6 +318,13 @@ files:
304
318
  - lib/trmnlp/lint/checks/title_length.rb
305
319
  - lib/trmnlp/lint/checks/waits_for_dom_load.rb
306
320
  - lib/trmnlp/lint/source.rb
321
+ - lib/trmnlp/oauth.rb
322
+ - lib/trmnlp/oauth/client.rb
323
+ - lib/trmnlp/oauth/pkce.rb
324
+ - lib/trmnlp/oauth/provider.rb
325
+ - lib/trmnlp/oauth/session.rb
326
+ - lib/trmnlp/oauth/token_bundle.rb
327
+ - lib/trmnlp/oauth/token_store.rb
307
328
  - lib/trmnlp/paths.rb
308
329
  - lib/trmnlp/poller.rb
309
330
  - lib/trmnlp/renderer.rb
@@ -313,6 +334,7 @@ files:
313
334
  - lib/trmnlp/screenshot.rb
314
335
  - lib/trmnlp/transform_backend/http.rb
315
336
  - lib/trmnlp/transform_backend/subprocess.rb
337
+ - lib/trmnlp/transform_backend/which.rb
316
338
  - lib/trmnlp/transform_backend/wrapper.rb
317
339
  - lib/trmnlp/transform_client.rb
318
340
  - lib/trmnlp/transform_pipeline.rb