6.0.0a1: Token-centric release

See the changelog for more information!
This commit is contained in:
SporeStack 2022-04-01 01:08:12 +00:00
parent 48e15d876a
commit 2af37624f7
7 changed files with 179 additions and 164 deletions

View File

@ -23,6 +23,7 @@ pipeline:
image: python:3.9 image: python:3.9
commands: commands:
- pip install pipenv==2022.1.8 pre-commit==2.17.0 - pip install pipenv==2022.1.8 pre-commit==2.17.0
- pre-commit run --all-files
- pipenv install --dev --deploy - pipenv install --dev --deploy
- pipenv run almake test - pipenv run almake test
- pipenv run almake build-dist - pipenv run almake build-dist
@ -33,6 +34,7 @@ pipeline:
image: python:3.10 image: python:3.10
commands: commands:
- pip install pipenv==2022.1.8 pre-commit==2.17.0 - pip install pipenv==2022.1.8 pre-commit==2.17.0
- pre-commit run --all-files
- pipenv install --dev --deploy - pipenv install --dev --deploy
- pipenv run almake test - pipenv run almake test
- pipenv run almake build-dist - pipenv run almake build-dist

View File

@ -15,6 +15,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Nothing yet. - Nothing yet.
## [6.0.0a1 - 2022-03-31]
Remember to backup your ~/.sporestack folder as any tokens you generate will be stored there!
### Changed
- Now token-centric. You can only use `sporestack` to launch or topup servers from a token.
- `sporestack launch/info/topup`, etc, moved to `sporestack server launch/info/topup`, etc.
- `--token` argument takes the name of the token, and not the key. Defaults to `primary`.
- `--ssh-key-file` now defaults to `~/.ssh/id_rsa.pub`.
- Import generated tokens from the key with: `sporestack token import (token reference name, default is primary) --key (the token key in hex format)`
### Added
- New token commands: `sporestack token create/list`
## [5.2.3 - 2022-03-30] ## [5.2.3 - 2022-03-30]
### Added ### Added

View File

@ -1,5 +1,4 @@
test: test:
pre-commit run --all-files
python -m pflake8 . python -m pflake8 .
python -m mypy --strict . python -m mypy --strict .
$(MAKE) test-pytest $(MAKE) test-pytest

View File

@ -7,45 +7,37 @@
## Installation ## Installation
* `pip install sporestack` * `pip install sporestack`
* Recommended: Create a virtual environment, first. Can use `pipenv`, as well. * Recommended: Create a virtual environment, first, and use it inside there.
## Running without installing (preferred) ## Running without installing
* Make sure `pipx` is installed. * Make sure `pipx` is installed.
* `pipx run sporestack` * `pipx run sporestack`
* Make sure you're on the latest version with `sporestack version`. * Make sure you're on the latest stable version comparing `sporestack version` with git tags in this repository, or releases on [PyPI](https://pypi.org/project/sporestack/).
## Screenshot
![sporestack CLI screenshot](https://sporestack.com/static/sporestackv2-screenshot.png)
## Usage ## Usage
* `sporestack launch SomeHostname --flavor vps-1vcpu-1gb --days 7 --ssh-key ~/.ssh/id_rsa.pub --operating-system debian-10 --currency btc` * `sporestack token create --dollars 20 --currency xmr # Can use btc as well.`
* `sporestack topup SomeHostname --days 3 --currency xmr` * `sporestack token list`
* `sporestack launch SomeOtherHostname --flavor vps-1vcpu-2gb --days 7 --ssh-key ~/.ssh/id_rsa.pub --operating-system debian-11 --currency btc` * `sporestack token balance`
* `sporestack stop SomeHostname` * `sporestack server launch SomeHostname --operating-system debian-11 --days 1 # Will use ~/.ssh/id_rsa.pub as your SSH key, by default`
* `sporestack start SomeHostname` (You may also want to consider passing `--region` to have a non-random region. This will use the "primary" token by default, which is the default when you run `sporestack token create`.)
* `sporestack list` * `sporestack server stop SomeHostname`
* `sporestack remove SomeHostname # If expired` * `sporestack server start SomeHostname`
* `sporestack settlement-token-generate` * `sporestack server list`
* `sporestack settlement-token-enable (token) --dollars 10 --currency xmr` * `sporestack server remove SomeHostname # If expired`
* `sporestack settlement-token-add (token) --dollars 25 --currency btc`
* `sporestack settlement-token-balance (token)`
More examples on the [website](https://sporestack.com).
## Notes ## Notes
* You can use `--settlement-token` if you don't want to pay with QR codes all the time. * If you want to communicate with SporeStack APIs using Tor, set this environment variable: `SPORESTACK_USE_TOR_ENDPOINT=1`
* If using a .onion API endpoint, will try to use a local Tor proxy if connecting to a .onion URL. (127.0.0.1:9050)
## Developing ## Developing
* `pip install pipenv pre-commit` * `pip install pipenv pre-commit`
* `pre-commit install`
* `pipenv install --deploy --dev` * `pipenv install --deploy --dev`
* `pipenv run make test` (If you don't have `make`, use `almake`) * `pipenv run make test` (If you don't have `make`, use `almake`)
* Hint: `pre-commit run` is a faster way to run some of the tests/autofixers. * `pre-commit run --all-files` (To format code, or wait for `git commit`)
## Licence ## Licence

View File

@ -1,6 +1,6 @@
[metadata] [metadata]
name = sporestack name = sporestack
version = 5.2.3 version = 6.0.0a1
description = SporeStack.com library and client. Launch servers with Monero or Bitcoin. description = SporeStack.com library and client. Launch servers with Monero or Bitcoin.
long_description = file: README.md long_description = file: README.md
long_description_content_type = text/markdown long_description_content_type = text/markdown

View File

@ -137,7 +137,7 @@ def launch(
ssh_key: str, ssh_key: str,
api_endpoint: str = API_ENDPOINT, api_endpoint: str = API_ENDPOINT,
region: Optional[str] = None, region: Optional[str] = None,
settlement_token: Optional[str] = None, token: Optional[str] = None,
retry: bool = False, retry: bool = False,
affiliate_amount: Optional[int] = None, affiliate_amount: Optional[int] = None,
affiliate_token: Optional[str] = None, affiliate_token: Optional[str] = None,
@ -146,7 +146,7 @@ def launch(
machine_id=machine_id, machine_id=machine_id,
days=days, days=days,
currency=currency, currency=currency,
settlement_token=settlement_token, settlement_token=token,
affiliate_amount=affiliate_amount, affiliate_amount=affiliate_amount,
affiliate_token=affiliate_token, affiliate_token=affiliate_token,
flavor=flavor, flavor=flavor,
@ -166,7 +166,7 @@ def topup(
days: int, days: int,
currency: str, currency: str,
api_endpoint: str = API_ENDPOINT, api_endpoint: str = API_ENDPOINT,
settlement_token: Optional[str] = None, token: Optional[str] = None,
retry: bool = False, retry: bool = False,
affiliate_amount: Optional[int] = None, affiliate_amount: Optional[int] = None,
affiliate_token: Optional[str] = None, affiliate_token: Optional[str] = None,
@ -178,7 +178,7 @@ def topup(
machine_id=machine_id, machine_id=machine_id,
days=days, days=days,
currency=currency, currency=currency,
settlement_token=settlement_token, settlement_token=token,
affiliate_amount=affiliate_amount, affiliate_amount=affiliate_amount,
affiliate_token=affiliate_token, affiliate_token=affiliate_token,
) )

View File

@ -53,11 +53,29 @@ SPORESTACK_USE_TOR_ENDPOINT
TOR_PROXY (defaults to socks5h://127.0.0.1:9050 which is fine for most) TOR_PROXY (defaults to socks5h://127.0.0.1:9050 which is fine for most)
""" """
_home = os.getenv("HOME", None)
assert _home is not None, "Unable to detect $HOME environment variable?"
HOME = Path(_home)
SPORESTACK_DIR = HOME / ".sporestack"
cli = typer.Typer(help=HELP) cli = typer.Typer(help=HELP)
token_cli = typer.Typer()
HOME = Path(_home)
cli.add_typer(token_cli, name="token")
server_cli = typer.Typer()
cli.add_typer(server_cli, name="server")
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
DEFAULT_TOKEN = "primary"
DEFAULT_FLAVOR = "vps-1vcpu-1gb" DEFAULT_FLAVOR = "vps-1vcpu-1gb"
# Users may have a different key file, but this is the most common.
DEFAULT_SSH_KEY_FILE = HOME / ".ssh" / "id_rsa.pub"
# On disk format
TOKEN_VERSION = 1
WAITING_PAYMENT_TO_PROCESS = "Waiting for payment to process..." WAITING_PAYMENT_TO_PROCESS = "Waiting for payment to process..."
@ -86,15 +104,14 @@ Press ctrl+c to abort."""
input("[Press enter once you have made payment.]") input("[Press enter once you have made payment.]")
@cli.command() @server_cli.command()
def launch( def launch(
hostname: str, hostname: str,
days: int = typer.Option(...), days: int = typer.Option(...),
ssh_key_file: Path = typer.Option(...),
operating_system: str = typer.Option(...), operating_system: str = typer.Option(...),
ssh_key_file: Path = DEFAULT_SSH_KEY_FILE,
flavor: str = DEFAULT_FLAVOR, flavor: str = DEFAULT_FLAVOR,
currency: Optional[str] = None, token: str = DEFAULT_TOKEN,
settlement_token: Optional[str] = None,
region: Optional[str] = None, region: Optional[str] = None,
) -> None: ) -> None:
""" """
@ -103,70 +120,37 @@ def launch(
from . import utils from . import utils
if settlement_token is not None: typer.echo(f"Launching server with token {token}...", err=True)
if currency is None or currency == "settlement": _token = load_token(token)
currency = "settlement"
else:
msg = "Cannot use non-settlement --currency with --settlement-token"
typer.echo(msg, err=True)
raise typer.Exit(code=2)
if currency is None:
typer.echo("--currency must be set.", err=True)
raise typer.Exit(code=2)
if machine_exists(hostname): if machine_exists(hostname):
typer.echo(f"{hostname} already created.") typer.echo(f"{hostname} already created.")
raise typer.Exit(code=1) raise typer.Exit(code=1)
typer.echo(f"Loading SSH key from {ssh_key_file}...")
if not ssh_key_file.exists():
msg = f"{ssh_key_file} does not exist. "
msg += "You can try generating a key file with `ssh-keygen`"
typer.echo(msg, err=True)
raise typer.Exit(code=1)
ssh_key = ssh_key_file.read_text() ssh_key = ssh_key_file.read_text()
machine_id = utils.random_machine_id() machine_id = utils.random_machine_id()
assert currency is not None
response = api_client.launch( response = api_client.launch(
machine_id=machine_id, machine_id=machine_id,
days=days, days=days,
flavor=flavor, flavor=flavor,
operating_system=operating_system, operating_system=operating_system,
ssh_key=ssh_key, ssh_key=ssh_key,
currency=currency, currency="settlement",
region=region, region=region,
settlement_token=settlement_token, token=_token,
api_endpoint=get_api_endpoint(), api_endpoint=get_api_endpoint(),
retry=True, retry=True,
) )
# This will be false at least the first time if paying with BTC or BCH.
if response.payment.paid is False:
assert response.payment.uri is not None
make_payment(
currency=currency,
uri=response.payment.uri,
usd=response.payment.usd,
)
tries = 360
while tries > 0:
tries = tries - 1
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
# FIXME: Wait one hour in a smarter way.
# Waiting for payment to set in.
time.sleep(10)
response = api_client.launch(
machine_id=machine_id,
days=days,
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
currency=currency,
region=region,
settlement_token=settlement_token,
api_endpoint=get_api_endpoint(),
retry=True,
)
if response.payment.paid is True:
break
if response.created is False: if response.created is False:
tries = 360 tries = 360
while tries > 0: while tries > 0:
@ -180,9 +164,9 @@ def launch(
flavor=flavor, flavor=flavor,
operating_system=operating_system, operating_system=operating_system,
ssh_key=ssh_key, ssh_key=ssh_key,
currency=currency, currency="settlement",
region=region, region=region,
settlement_token=settlement_token, token=_token,
api_endpoint=get_api_endpoint(), api_endpoint=get_api_endpoint(),
retry=True, retry=True,
) )
@ -200,103 +184,69 @@ def launch(
typer.echo(json.dumps(created_dict, indent=4)) typer.echo(json.dumps(created_dict, indent=4))
@cli.command() @server_cli.command()
def topup( def topup(
hostname: str, hostname: str,
days: int = typer.Option(...), days: int = typer.Option(...),
currency: Optional[str] = None, token: str = DEFAULT_TOKEN,
settlement_token: Optional[str] = None,
) -> None: ) -> None:
""" """
tops up an existing vm. tops up an existing vm.
""" """
if settlement_token is not None:
if currency is None or currency == "settlement":
currency = "settlement"
else:
msg = "Cannot use non-settlement --currency with --settlement-token"
typer.echo(msg, err=True)
raise typer.Exit(code=2)
if currency is None:
typer.echo("--currency must be set.", err=True)
raise typer.Exit(code=2)
if not machine_exists(hostname): if not machine_exists(hostname):
typer.echo(f"{hostname} does not exist.") typer.echo(f"{hostname} does not exist.")
raise typer.Exit(code=1) raise typer.Exit(code=1)
_token = load_token(token)
machine_info = get_machine_info(hostname) machine_info = get_machine_info(hostname)
machine_id = machine_info["machine_id"] machine_id = machine_info["machine_id"]
assert currency is not None
response = api_client.topup( response = api_client.topup(
machine_id=machine_id, machine_id=machine_id,
days=days, days=days,
currency=currency, currency="settlement",
api_endpoint=get_api_endpoint(), api_endpoint=get_api_endpoint(),
settlement_token=settlement_token, token=_token,
retry=True, retry=True,
) )
# This will be false at least the first time if paying with anything
# but settlement.
if response.payment.paid is False:
assert response.payment.uri is not None
make_payment(
currency=currency,
uri=response.payment.uri,
usd=response.payment.usd,
)
tries = 360
while tries > 0:
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
tries = tries - 1
# FIXME: Wait one hour in a smarter way.
# Waiting for payment to set in.
time.sleep(10)
response = api_client.topup(
machine_id=machine_id,
days=days,
currency=currency,
api_endpoint=get_api_endpoint(),
settlement_token=settlement_token,
retry=True,
)
if response.payment.paid is True:
break
machine_info["expiration"] = response.expiration machine_info["expiration"] = response.expiration
save_machine_info(machine_info, overwrite=True) save_machine_info(machine_info, overwrite=True)
typer.echo(machine_info["expiration"]) typer.echo(machine_info["expiration"])
def server_info_path() -> Path: def server_info_path() -> Path:
home = os.getenv("HOME")
assert home is not None, "Unable to detect $HOME environment variable?"
sporestack_dir = Path(home, ".sporestack")
# Put servers in a subdirectory # Put servers in a subdirectory
servers_dir = sporestack_dir.joinpath("servers") servers_dir = SPORESTACK_DIR / "servers"
# Migrate existing server.json files into servers subdirectory # Migrate existing server.json files into servers subdirectory
if sporestack_dir.exists() and not servers_dir.exists(): if SPORESTACK_DIR.exists() and not servers_dir.exists():
typer.echo( typer.echo(
f"Migrating server profiles found in {sporestack_dir} to {servers_dir}.", f"Migrating server profiles found in {SPORESTACK_DIR} to {servers_dir}.",
err=True, err=True,
) )
servers_dir.mkdir() servers_dir.mkdir()
for json_file in sporestack_dir.glob("*.json"): for json_file in SPORESTACK_DIR.glob("*.json"):
json_file.rename(servers_dir.joinpath(json_file.name)) json_file.rename(servers_dir / json_file.name)
# Make it, if it doesn't exist already. # Make it, if it doesn't exist already.
sporestack_dir.mkdir(exist_ok=True) SPORESTACK_DIR.mkdir(exist_ok=True)
servers_dir.mkdir(exist_ok=True) servers_dir.mkdir(exist_ok=True)
return servers_dir return servers_dir
def token_path() -> Path:
token_dir = SPORESTACK_DIR / "tokens"
# Make it, if it doesn't exist already.
token_dir.mkdir(exist_ok=True, parents=True)
return token_dir
def save_machine_info(machine_info: Dict[str, Any], overwrite: bool = False) -> None: def save_machine_info(machine_info: Dict[str, Any], overwrite: bool = False) -> None:
""" """
Save info to disk. Save info to disk.
@ -304,7 +254,7 @@ def save_machine_info(machine_info: Dict[str, Any], overwrite: bool = False) ->
os.umask(0o0077) os.umask(0o0077)
directory = server_info_path() directory = server_info_path()
hostname = machine_info["vm_hostname"] hostname = machine_info["vm_hostname"]
json_file = directory.joinpath(f"{hostname}.json") json_file = directory / f"{hostname}.json"
if overwrite is False: if overwrite is False:
assert json_file.exists() is False, f"{json_file} already exists." assert json_file.exists() is False, f"{json_file} already exists."
json_file.write_text(json.dumps(machine_info)) json_file.write_text(json.dumps(machine_info))
@ -315,7 +265,7 @@ def get_machine_info(hostname: str) -> Dict[str, Any]:
Get info from disk. Get info from disk.
""" """
directory = server_info_path() directory = server_info_path()
json_file = directory.joinpath(f"{hostname}.json") json_file = directory / f"{hostname}.json"
if not json_file.exists(): if not json_file.exists():
raise ValueError(f"{hostname} does not exist in {directory} as {json_file}") raise ValueError(f"{hostname} does not exist in {directory} as {json_file}")
machine_info = json.loads(json_file.read_bytes()) machine_info = json.loads(json_file.read_bytes())
@ -343,8 +293,8 @@ def pretty_machine_info(info: Dict[str, Any]) -> str:
return msg return msg
@cli.command() @server_cli.command(name="list")
def list() -> None: def server_list() -> None:
""" """
List all locally known servers. List all locally known servers.
""" """
@ -386,7 +336,7 @@ def machine_exists(hostname: str) -> bool:
return server_info_path().joinpath(f"{hostname}.json").exists() return server_info_path().joinpath(f"{hostname}.json").exists()
@cli.command() @server_cli.command()
def get_attribute(hostname: str, attribute: str) -> None: def get_attribute(hostname: str, attribute: str) -> None:
""" """
Returns an attribute about the VM. Returns an attribute about the VM.
@ -395,7 +345,7 @@ def get_attribute(hostname: str, attribute: str) -> None:
typer.echo(machine_info[attribute]) typer.echo(machine_info[attribute])
@cli.command() @server_cli.command()
def info(hostname: str) -> None: def info(hostname: str) -> None:
""" """
Info on the VM Info on the VM
@ -407,7 +357,7 @@ def info(hostname: str) -> None:
) )
@cli.command() @server_cli.command()
def start(hostname: str) -> None: def start(hostname: str) -> None:
""" """
Boots the VM. Boots the VM.
@ -418,7 +368,7 @@ def start(hostname: str) -> None:
typer.echo(f"{hostname} started.") typer.echo(f"{hostname} started.")
@cli.command() @server_cli.command()
def stop(hostname: str) -> None: def stop(hostname: str) -> None:
""" """
Immediately kills the VM. Immediately kills the VM.
@ -429,7 +379,7 @@ def stop(hostname: str) -> None:
typer.echo(f"{hostname} stopped.") typer.echo(f"{hostname} stopped.")
@cli.command() @server_cli.command()
def delete(hostname: str) -> None: def delete(hostname: str) -> None:
""" """
Deletes the VM before expiration (no refunds/credits) Deletes the VM before expiration (no refunds/credits)
@ -442,7 +392,7 @@ def delete(hostname: str) -> None:
typer.echo(f"{hostname} was deleted.") typer.echo(f"{hostname} was deleted.")
@cli.command() @server_cli.command()
def rebuild(hostname: str) -> None: def rebuild(hostname: str) -> None:
""" """
Rebuilds the VM with the operating system and SSH key given at launch time. Rebuilds the VM with the operating system and SSH key given at launch time.
@ -455,9 +405,38 @@ def rebuild(hostname: str) -> None:
typer.echo(f"{hostname} rebuilding.") typer.echo(f"{hostname} rebuilding.")
@cli.command() def load_token(token: str) -> str:
def settlement_token_enable( token_file = token_path().joinpath(f"{token}.json")
token: str, if not token_file.exists():
msg = f"Token '{token}' ({token_file}) does not exist. Create it with:\n"
msg += f"sporestack token create {token} --dollars 20 --currency xmr\n"
msg += "(Can do more than $20, or a different currency, like btc.)\n"
msg += (
"With the token credited, you can launch servers, renew existing ones, etc."
)
typer.echo(msg, err=True)
raise typer.Exit(code=1)
token_data = json.loads(token_file.read_text())
assert token_data["version"] == 1
assert isinstance(token_data["key"], str)
return token_data["key"]
def save_token(token: str, key: str) -> None:
token_file = token_path().joinpath(f"{token}.json")
if token_file.exists():
msg = "Token '{token}' already exists in {token_file}. Aborting!"
typer.echo(msg, err=True)
raise typer.Exit(code=1)
token_data = {"version": TOKEN_VERSION, "name": token, "key": key}
token_file.write_text(json.dumps(token_data))
@token_cli.command(name="create")
def token_create(
token: str = typer.Argument(DEFAULT_TOKEN),
dollars: int = typer.Option(...), dollars: int = typer.Option(...),
currency: str = typer.Option(...), currency: str = typer.Option(...),
) -> None: ) -> None:
@ -466,9 +445,18 @@ def settlement_token_enable(
Dollars is starting balance. Dollars is starting balance.
""" """
from . import utils
_token = utils.random_token()
typer.echo(f"Generated key {_token} for use with token {token}", err=True)
if Path(SPORESTACK_DIR / "tokens" / f"{token}.json").exists():
typer.echo("Token already created! Did you mean to `topup`?", err=True)
raise typer.Exit(1)
response = api_client.token_enable( response = api_client.token_enable(
token=token, token=_token,
dollars=dollars, dollars=dollars,
currency=currency, currency=currency,
api_endpoint=get_api_endpoint(), api_endpoint=get_api_endpoint(),
@ -489,29 +477,42 @@ def settlement_token_enable(
# Waiting for payment to set in. # Waiting for payment to set in.
time.sleep(10) time.sleep(10)
response = api_client.token_enable( response = api_client.token_enable(
token=token, token=_token,
dollars=dollars, dollars=dollars,
currency=currency, currency=currency,
api_endpoint=get_api_endpoint(), api_endpoint=get_api_endpoint(),
retry=True, retry=True,
) )
if response.payment.paid is True: if response.payment.paid is True:
typer.echo( typer.echo(f"{token} has been enabled with ${dollars}.")
f"{token} has been enabled with ${dollars}. Save it and don't lose it!" typer.echo(f"{token}'s key is {_token}.")
) typer.echo("Save it, don't share it, and don't lose it!")
save_token(token, _token)
return return
raise ValueError(f"{token} did not get enabled in time.") raise ValueError(f"{token} did not get enabled in time.")
@cli.command() @token_cli.command(name="import")
def settlement_token_add( def token_import(
token: str, name: str = typer.Argument(DEFAULT_TOKEN),
key: str = typer.Option(...),
) -> None:
"""
Imports a token from a key.
"""
save_token(name, key)
@token_cli.command(name="topup")
def token_topup(
token: str = typer.Argument(DEFAULT_TOKEN),
dollars: int = typer.Option(...), dollars: int = typer.Option(...),
currency: str = typer.Option(...), currency: str = typer.Option(...),
) -> None: ) -> None:
""" """
Adds balance to an existing settlement token. Adds balance to an existing settlement token.
""" """
token = load_token(token)
response = api_client.token_add( response = api_client.token_add(
token, token,
@ -547,25 +548,30 @@ def settlement_token_add(
raise ValueError(f"{token} did not get enabled in time.") raise ValueError(f"{token} did not get enabled in time.")
@cli.command() @token_cli.command()
def settlement_token_balance(token: str) -> None: def balance(token: str = typer.Argument(DEFAULT_TOKEN)) -> None:
""" """
Gets balance for a settlement token. Gets balance for a settlement token.
""" """
_token = load_token(token)
typer.echo( typer.echo(
api_client.token_balance(token=token, api_endpoint=get_api_endpoint()).usd api_client.token_balance(token=_token, api_endpoint=get_api_endpoint()).usd
) )
@cli.command() @token_cli.command(name="list")
def settlement_token_generate() -> None: def token_list() -> None:
""" """
Generates a settlement token that can be enabled. Gets balance for a settlement token.
""" """
from . import utils token_dir = token_path()
typer.echo(f"SporeStack tokens present in {token_dir}:", err=True)
typer.echo(utils.random_token()) typer.echo("(Name): (Key)", err=True)
for token_file in token_dir.glob("*.json"):
token = token_file.stem
key = load_token(token)
typer.echo(f"{token}: {key}")
@cli.command() @cli.command()