Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 79 additions & 4 deletions .github/workflows/cross-platform-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,27 @@ on:
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

defaults:
run:
shell: bash

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-22.04, macos-latest, macos-15-intel, windows-latest]
python-version: ['3.10', '3.11', '3.12']
# oldest and newest Python on every OS, the ones in between on Linux only
os: [ubuntu-24.04, macos-latest, macos-15-intel, windows-latest]
python-version: ['3.10', '3.14']
include:
- {os: ubuntu-24.04, python-version: '3.11'}
- {os: ubuntu-24.04, python-version: '3.12'}
- {os: ubuntu-24.04, python-version: '3.13'}

steps:
- uses: actions/checkout@v4
Expand All @@ -26,7 +39,7 @@ jobs:
- name: Install package
run: |
python -m pip install --upgrade pip
pip install .
pip install . pytest

- name: Verify installed version matches source
run: |
Expand All @@ -41,7 +54,6 @@ jobs:
print(f'Installed: {installed}')
assert expected == installed, f'Version mismatch: {expected} != {installed}'
"
shell: bash

- name: Test CLI help
run: modcli --help
Expand All @@ -54,3 +66,66 @@ jobs:

- name: Test config commands
run: modcli config --help

# Outside the checkout, so the tests import the installed package (and catch files it doesn't ship)
- name: Run tests
run: |
cp -r tests pyproject.toml "$RUNNER_TEMP/"
cd "$RUNNER_TEMP" && pytest

uv-install:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-latest, macos-15-intel, windows-latest]

steps:
- uses: actions/checkout@v4

- name: Set up uv
uses: astral-sh/setup-uv@v6

- name: Install with uv
run: uv tool install .

- name: Test CLI help
run: modcli --help

- name: Test auth commands
run: modcli auth --help

- name: Test bundle commands
run: modcli bundle --help

- name: Test config commands
run: modcli config --help

# Outside the checkout, so the tests import the package as uv builds and installs it
- name: Run tests
run: |
cp -r tests pyproject.toml "$RUNNER_TEMP/"
cd "$RUNNER_TEMP" && uvx --with "$GITHUB_WORKSPACE" pytest

# The oldest versions pyproject.toml allows (click>=8.0) on the oldest Python
lowest-deps:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

- name: Set up uv
uses: astral-sh/setup-uv@v6

- name: Run tests
run: uv run --resolution lowest-direct --python 3.10 pytest

lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

- name: Set up uv
uses: astral-sh/setup-uv@v6

- name: Run pylint
run: uv run pylint --errors-only modcli tests
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,22 @@ Commands:
bundle LV2 bundle commands
config Configuration commands
```

## Development

```
uv sync # install with the dev group (pylint, pytest)
uv run pytest # run the tests
uv run pylint --errors-only modcli tests # lint
```

## Authentication

```
modcli auth login # username and password
modcli auth login-forum # MOD Forum account (login-sso still works)
modcli auth login-google # Google account
modcli auth login-magiclink # sign-in link sent by email
```

`login-forum` and `login-google` open the browser, and `login-magiclink` emails a sign-in link. Once you sign in, the browser hands the token back to the CLI automatically. When the browser runs on another machine (e.g. the CLI runs over SSH), paste the token the browser shows into the terminal instead.
90 changes: 17 additions & 73 deletions modcli/auth.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import socket
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from importlib.metadata import version
from urllib import parse

import click
from click import Abort

from modcli import http


Expand All @@ -21,74 +15,24 @@ def login(username: str, password: str, api_url: str):
return result.json()['message'].strip()


def get_open_port():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('', 0))
s.listen(1)
port = s.getsockname()[1]
s.close()
return port


def login_sso_detached(api_url: str):
click.echo('Running in detached mode...')
click.echo(f'1) Open this url in any browser: {api_url}/users/tokens_sso')
click.echo('2) The URL will automatically redirect to MOD Forum (https://forum.moddevices.com)')
click.echo('3) Once MOD Forum page loads, if asked, enter your credentials or register a new user')
click.echo('4) A JWT token will be displayed in your browser')
try:
token = click.prompt('Copy the token value and paste it here, then press ENTER')
return token.strip()
except Abort:
sys.exit(1)


def login_sso(api_url: str):
server_host = 'localhost'
server_port = get_open_port()
local_server = f'http://{server_host}:{server_port}'

class SSORequestHandler(BaseHTTPRequestHandler):
token = ''

def do_HEAD(self):
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
def forum_url(api_url: str, local_url: str, state: str):
query = parse.urlencode({'local_url': local_url, 'state': state})
return f'{api_url}/users/tokens_modforum?{query}'

def do_GET(self):
response = self.handle_http(200)
_, _, _, query, _ = parse.urlsplit(self.path)
result = parse.parse_qs(query)
tokens = result.get('token', None)
SSORequestHandler.token = tokens[0] if len(tokens) > 0 else None
self.wfile.write(response)

def handle_http(self, status_code):
self.send_response(status_code)
self.send_header('Content-type', 'text/html')
self.end_headers()
content = '''
<html><head><title>modcli - success</title></head>
<body>Authentication successful! This browser window can be closed.</body></html>
'''
return bytes(content, 'UTF-8')
def google_url(api_url: str, local_url: str, state: str):
query = parse.urlencode({'aud': 'api', 'local_url': local_url, 'state': state})
return f'{api_url}/users/tokens_google?{query}'

def log_message(self, fmt, *args):
pass

httpd = HTTPServer((server_host, server_port), SSORequestHandler)
httpd.timeout = 30

click.echo('Open this URL in your browser to authenticate:')
click.echo(f'{api_url}/users/tokens_sso?local_url={local_server}')

try:
httpd.handle_request()
except KeyboardInterrupt:
pass

token = SSORequestHandler.token
if not token:
raise Exception('Authentication failed!')
return token
def send_magic_link(api_url: str, email: str, local_url: str, state: str) -> int:
'''Returns how long the link lives, in seconds.'''
result = http.post(f'{api_url}/users/tokens_magiclink', json_data={
'email': email,
'aud': 'api',
'local_url': local_url,
'state': state,
})
if result.status_code != 200:
raise Exception(f'Error: {result.json()["error-message"]}')
return result.json()['expires_in']
113 changes: 95 additions & 18 deletions modcli/cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import secrets
import sys
import webbrowser

import click
from importlib.metadata import version

from modcli import context, auth, bundle
from modcli import context, auth, bundle, token_receiver

_sso_disclaimer = '''SSO login requires you have a valid account in MOD Forum (https://forum.moddevices.com).
If your browser has an active session the credentials will be used for this login. Confirm?'''
# seconds to wait for the token from the browser; a magic link's wait is the link's validity, from the API
_FORUM_TIMEOUT = 5 * 60
_GOOGLE_TIMEOUT = 5 * 60

# -y no longer does anything; kept so existing scripts passing it keep working
_ignored_confirm_all = click.option('-y', '--confirm-all', is_flag=True, hidden=True, expose_value=False)


def _normalize_command_name(name):
Expand Down Expand Up @@ -39,28 +45,57 @@ def config_group():
pass


@click.command(help='Authenticate user with SSO (MOD Forum)')
@click.option('-s', '--show-token', type=bool, help='Print the JWT token obtained', is_flag=True)
@click.option('-o', '--one-time', type=bool, help='Only print token once (do not store it)', is_flag=True)
@click.option('-y', '--confirm-all', type=bool, help='Confirm all operations', is_flag=True)
@click.option('-d', '--detached-mode', type=bool, help='Run process without opening a local browser', is_flag=True)
@click.option('-e', '--env-name', type=str, help='Switch to environment before authenticating')
def login_sso(show_token: bool, one_time: bool, confirm_all: bool, detached_mode: bool, env_name: str):
def _open_browser(url: str):
click.echo('Sign in in the browser. If it doesn\'t open, visit:')
click.echo(url)
webbrowser.open(url)


def _start_forum(api_url: str, local_url: str):
state = secrets.token_urlsafe(16)
click.echo('Sign in with your MOD Forum account (https://forum.mod.audio); an existing browser session is used.')
_open_browser(auth.forum_url(api_url, local_url, state))
return state, _FORUM_TIMEOUT


def _start_google(api_url: str, local_url: str):
state = secrets.token_urlsafe(16)
_open_browser(auth.google_url(api_url, local_url, state))
return state, _GOOGLE_TIMEOUT


def _start_magiclink(email: str):
def start(api_url: str, local_url: str):
state = secrets.token_urlsafe(16)
expires_in = auth.send_magic_link(api_url, email, local_url, state)
click.echo(f'A sign-in link was sent to {email}. Open it and confirm.')
click.echo('No email? Only one link is sent per minute, so wait a minute before trying again.')
return state, expires_in
return start


def _receive_token(api_url: str, start):
# start() kicks off the sign-in and returns the state the redirect must carry back, and how long to wait
port = token_receiver.get_open_port()
state, timeout = start(api_url, token_receiver.local_url(port))
click.echo('Finish in the browser, or paste the token it shows here and press ENTER: ', nl=False)
token, source = token_receiver.receive_token(port, timeout, state)
if source != 'paste':
click.echo()
if not token:
raise Exception('Authentication failed!')
return token


def _browser_login(env_name: str, show_token: bool, one_time: bool, start):
if env_name:
context.set_active_env(env_name)
env = context.current_env()
if not confirm_all:
response = click.confirm(_sso_disclaimer)
if not response:
sys.exit(1)
if not one_time:
click.echo(f'Logging in to [{env.name}]...')

try:
if detached_mode:
token = auth.login_sso_detached(env.api_url)
else:
token = auth.login_sso(env.api_url)
token = _receive_token(env.api_url, start)
except Exception as ex:
click.echo(click.style(str(ex), fg='red'), err=True)
sys.exit(1)
Expand All @@ -76,12 +111,51 @@ def login_sso(show_token: bool, one_time: bool, confirm_all: bool, detached_mode
click.echo(click.style(f'You\'re now logged in as [{env.username}] in [{env.name}].', fg='green'))


def _browser_login_options(command):
options = [
click.option('-s', '--show-token', type=bool, help='Print the JWT token obtained', is_flag=True),
click.option('-o', '--one-time', type=bool, help='Only print token once (do not store it)', is_flag=True),
click.option('-e', '--env-name', type=str, help='Switch to environment before authenticating'),
_ignored_confirm_all,
]
for option in reversed(options):
command = option(command)
return command


@click.command(help='Authenticate user with a MOD Forum account')
@_browser_login_options
def login_forum(show_token: bool, one_time: bool, env_name: str):
_browser_login(env_name, show_token, one_time, _start_forum)


# The command's former name, kept for scripts
@click.command(name='login-sso', hidden=True, help='Same as login-forum')
@_browser_login_options
def login_sso(show_token: bool, one_time: bool, env_name: str):
_browser_login(env_name, show_token, one_time, _start_forum)


@click.command(help='Authenticate user with Google')
@_browser_login_options
def login_google(show_token: bool, one_time: bool, env_name: str):
_browser_login(env_name, show_token, one_time, _start_google)


@click.command(help='Authenticate user with a sign-in link sent by email')
@click.option('--email', type=str, prompt=True, help='Email address of the user')
@_browser_login_options
def login_magiclink(email: str, show_token: bool, one_time: bool, env_name: str):
_browser_login(env_name, show_token, one_time, _start_magiclink(email))


@click.command(help='Authenticate user')
@click.option('-u', '--username', type=str, prompt=True, help='User ID')
@click.option('-p', '--password', type=str, prompt=True, hide_input=True, help='User password')
@click.option('-s', '--show-token', type=bool, help='Print the JWT token obtained', is_flag=True)
@click.option('-o', '--one-time', type=bool, help='Only print token once (do not store it)', is_flag=True)
@click.option('-e', '--env-name', type=str, help='Switch to environment before authenticating')
@_ignored_confirm_all
def login(username: str, password: str, show_token: bool, one_time: bool, env_name: str):
if env_name:
context.set_active_env(env_name)
Expand Down Expand Up @@ -192,7 +266,10 @@ def publish(project_file: str, packages_path: str, show_result: bool, keep_envir

auth_group.add_command(active_token)
auth_group.add_command(login)
auth_group.add_command(login_forum)
auth_group.add_command(login_sso)
auth_group.add_command(login_google)
auth_group.add_command(login_magiclink)
bundle_group.add_command(publish)
config_group.add_command(add_env)
config_group.add_command(set_active_env)
Expand Down
Loading
Loading