mirror of
https://github.com/asciinema/asciinema.git
synced 2025-12-16 11:48:13 +01:00
Keep UUID out of config file and call it "install ID"
We referred to this locally generated UUID as "API token" which was wrong, because it doesn't have any special API powers, and is only used to link local machine with uploaded recordings, so they can later be associated with asciinema.org account. It's more of device token or installation ID in nature, so let's call it "install ID" for short, and keep it at ~/.config/asciinema/install-id. Keeping it in automatically created config file also turned out to be not the best idea - the config was mixing user preferences with local, device specific state, preventing easy publishing of the config (for example in a public dotfiles repository).
This commit is contained in:
103
README.md
103
README.md
@@ -253,19 +253,29 @@ publishing it on asciinema.org.
|
||||
|
||||
### `auth`
|
||||
|
||||
__Manage recordings on asciinema.org account.__
|
||||
__Link your install ID with your asciinema.org user account.__
|
||||
|
||||
If you want to manage your recordings on asciinema.org (set title/description,
|
||||
delete etc) you need to authenticate. This command displays the URL you should
|
||||
open in your web browser to do that.
|
||||
If you want to manage your recordings (change title/theme, delete) at
|
||||
asciinema.org you need to link your "install ID" with asciinema.org user
|
||||
account.
|
||||
|
||||
On every machine you run asciinema recorder, you get a new, unique API token. If
|
||||
you're already logged in on asciinema.org website and you run `asciinema auth`
|
||||
from a new computer then this new device will be linked to your account.
|
||||
This command displays the URL to open in a web browser to do that. You may be
|
||||
asked to log in first.
|
||||
|
||||
You can synchronize your config file (which keeps the API token) across the
|
||||
machines so all of them use the same token, but that's not necessary. You can
|
||||
assign new tokens to your account from as many machines as you want.
|
||||
Install ID is a random ID ([UUID
|
||||
v4](https://en.wikipedia.org/wiki/Universally_unique_identifier)) generated
|
||||
locally when you run asciinema for the first time, and saved at
|
||||
`$HOME/.config/asciinema/install-id`. It's purpose is to connect local machine
|
||||
with uploaded recordings, so they can later be associated with asciinema.org
|
||||
account. This way we decouple uploading from account creation, allowing them to
|
||||
happen in any order.
|
||||
|
||||
> A new install ID is generated on each machine and system user account you use
|
||||
> asciinema on, so in order to keep all recordings under a single asciinema.org
|
||||
> account you need to run `asciinema auth` on all of those machines.
|
||||
|
||||
> asciinema versions prior to 2.0 confusingly referred to install ID as "API
|
||||
> token".
|
||||
|
||||
## Hosting the recordings on the web
|
||||
|
||||
@@ -287,87 +297,64 @@ If you prefer to host the recordings yourself, you can do so by either:
|
||||
|
||||
## Configuration file
|
||||
|
||||
asciinema uses a config file to keep API token and user settings. In most cases
|
||||
the location of this file is `$HOME/.config/asciinema/config`.
|
||||
You can configure asciinema by creating config file at
|
||||
`$HOME/.config/asciinema/config`.
|
||||
|
||||
*NOTE: When you first run asciinema, local API token is generated (UUID) and
|
||||
saved in the file (unless the file already exists or you have set
|
||||
`ASCIINEMA_API_TOKEN` environment variable).*
|
||||
|
||||
The auto-generated, minimal config file looks like this:
|
||||
|
||||
```ini
|
||||
[api]
|
||||
token = <your-api-token-here>
|
||||
```
|
||||
|
||||
There are several options you can set in this file. Here's a config with all
|
||||
available options set:
|
||||
Configuration is split into sections (`[api]`, `[record]`, `[play]`). Here's a
|
||||
list of all available options for each section:
|
||||
|
||||
```ini
|
||||
[api]
|
||||
|
||||
; API server URL, default: https://asciinema.org
|
||||
; If you run your own instance of asciinema-server then set its address here
|
||||
; It can also be overriden by setting ASCIINEMA_API_URL environment variable
|
||||
url = https://asciinema.example.com
|
||||
|
||||
; API token, autogenerated when uploading a recording for the first time
|
||||
token = <your-api-token-here>
|
||||
|
||||
[record]
|
||||
|
||||
; command to record, default: $SHELL
|
||||
; Command to record, default: $SHELL
|
||||
command = /bin/bash -l
|
||||
|
||||
; enable stdin (keyboard) recording, default: no
|
||||
; Enable stdin (keyboard) recording, default: no
|
||||
stdin = yes
|
||||
|
||||
; list of environment variables to capture, default: SHELL,TERM
|
||||
; List of environment variables to capture, default: SHELL,TERM
|
||||
env = SHELL,TERM,USER
|
||||
|
||||
; limit recorded terminal inactivity to max n seconds, default: off
|
||||
; Limit recorded terminal inactivity to max n seconds, default: off
|
||||
idle_time_limit = 2
|
||||
|
||||
; answer "yes" to all interactive prompts, default: no
|
||||
; Answer "yes" to all interactive prompts, default: no
|
||||
yes = true
|
||||
|
||||
; be quiet, suppress all notices/warnings, default: no
|
||||
; Be quiet, suppress all notices/warnings, default: no
|
||||
quiet = true
|
||||
|
||||
[play]
|
||||
|
||||
; playback speed (can be fractional), default: 1
|
||||
; Playback speed (can be fractional), default: 1
|
||||
speed = 2
|
||||
|
||||
; limit replayed terminal inactivity to max n seconds, default: off
|
||||
; Limit replayed terminal inactivity to max n seconds, default: off
|
||||
idle_time_limit = 1
|
||||
```
|
||||
|
||||
The options in `[api]` section are related to API location and authentication.
|
||||
To tell asciinema recorder to use your own asciinema server instance rather than
|
||||
the default one (asciinema.org), you can set `url` option. API URL can also be
|
||||
passed via `ASCIINEMA_API_URL` environment variable, and API token via
|
||||
`ASCIINEMA_API_TOKEN` environment variable.
|
||||
A very minimal config file could look like that:
|
||||
|
||||
The options in `[record]` and `[play]` sections have the same meaning as the
|
||||
options you pass to `asciinema rec`/`asciinema play` command. If you happen to
|
||||
often use either `-c`, `-i` or `-y` with these commands then consider saving it
|
||||
as a default in the config file.
|
||||
```ini
|
||||
[record]
|
||||
idle_time_limit = 2
|
||||
```
|
||||
|
||||
> If you want to publish your asciinema config file (in public dotfiles
|
||||
> repository) you __should__ remove `token = ...` line from the file and use
|
||||
> `ASCIINEMA_API_TOKEN` environment variable instead.
|
||||
Config file location can be changed by setting `$ASCIINEMA_CONFIG_HOME`
|
||||
environment variable, which should point to a directory.
|
||||
|
||||
### Configuration file locations
|
||||
If `$XDG_CONFIG_HOME` is set on Linux then asciinema uses
|
||||
`$XDG_CONFIG_HOME/asciinema/config` instead of `$HOME/.config/asciinema/config`.
|
||||
|
||||
In fact, the following locations are checked for the presence of the config
|
||||
file (in the given order):
|
||||
|
||||
* `$ASCIINEMA_CONFIG_HOME/config` - if you have set `$ASCIINEMA_CONFIG_HOME`
|
||||
* `$XDG_CONFIG_HOME/asciinema/config` - on Linux, `$XDG_CONFIG_HOME` usually points to `$HOME/.config/`
|
||||
* `$HOME/.config/asciinema/config` - in most cases it's here
|
||||
* `$HOME/.asciinema/config` - created by asciinema versions prior to 1.1
|
||||
|
||||
The first one found is used.
|
||||
> asciinema versions prior to 1.1 used `$HOME/.asciinema/config`. If you have it
|
||||
> there you should `mv $HOME/.asciinema $HOME/.config/asciinema`.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def positive_float(value):
|
||||
|
||||
|
||||
def rec_command(args, config):
|
||||
api = Api(config.api_url, os.environ.get("USER"), config.api_token)
|
||||
api = Api(config.api_url, os.environ.get("USER"), config.install_id)
|
||||
return RecordCommand(api, args)
|
||||
|
||||
|
||||
@@ -35,12 +35,12 @@ def cat_command(args, config):
|
||||
|
||||
|
||||
def upload_command(args, config):
|
||||
api = Api(config.api_url, os.environ.get("USER"), config.api_token)
|
||||
api = Api(config.api_url, os.environ.get("USER"), config.install_id)
|
||||
return UploadCommand(api, args.filename)
|
||||
|
||||
|
||||
def auth_command(args, config):
|
||||
api = Api(config.api_url, os.environ.get("USER"), config.api_token)
|
||||
api = Api(config.api_url, os.environ.get("USER"), config.install_id)
|
||||
return AuthCommand(api)
|
||||
|
||||
|
||||
@@ -54,7 +54,11 @@ def main():
|
||||
print("asciinema needs a UTF-8 native locale to run. Check the output of `locale` command.")
|
||||
sys.exit(1)
|
||||
|
||||
cfg = config.load()
|
||||
try:
|
||||
cfg = config.load()
|
||||
except config.ConfigError as e:
|
||||
sys.stderr.write(str(e) + '\n')
|
||||
sys.exit(1)
|
||||
|
||||
# create the top-level parser
|
||||
parser = argparse.ArgumentParser(
|
||||
|
||||
@@ -13,17 +13,17 @@ class APIError(Exception):
|
||||
|
||||
class Api:
|
||||
|
||||
def __init__(self, url, user, token, http_adapter=None):
|
||||
def __init__(self, url, user, install_id, http_adapter=None):
|
||||
self.url = url
|
||||
self.user = user
|
||||
self.token = token
|
||||
self.install_id = install_id
|
||||
self.http_adapter = http_adapter if http_adapter is not None else URLLibHttpAdapter()
|
||||
|
||||
def hostname(self):
|
||||
return urlparse(self.url).hostname
|
||||
|
||||
def auth_url(self):
|
||||
return "{}/connect/{}".format(self.url, self.token)
|
||||
return "{}/connect/{}".format(self.url, self.install_id)
|
||||
|
||||
def upload_url(self):
|
||||
return "{}/api/asciicasts".format(self.url)
|
||||
@@ -36,7 +36,7 @@ class Api:
|
||||
files={"asciicast": ("ascii.cast", f)},
|
||||
headers=self._headers(),
|
||||
username=self.user,
|
||||
password=self.token
|
||||
password=self.install_id
|
||||
)
|
||||
except HTTPConnectionError as e:
|
||||
raise APIError(str(e))
|
||||
@@ -61,7 +61,7 @@ class Api:
|
||||
def _handle_error(self, status, body):
|
||||
errors = {
|
||||
400: "Invalid request: %s" % body,
|
||||
401: "Invalid or revoked recorder token",
|
||||
401: "Invalid or revoked install ID",
|
||||
404: "API endpoint not found. This asciinema version may no longer be supported. Please upgrade to the latest version.",
|
||||
413: "Sorry, your asciicast is too big.",
|
||||
422: "Invalid asciicast: %s" % body,
|
||||
|
||||
@@ -8,6 +8,10 @@ class AuthCommand(Command):
|
||||
self.api = api
|
||||
|
||||
def execute(self):
|
||||
self.print('Open the following URL in a browser to register your API token\n'
|
||||
'and assign any recorded asciicasts to your %s profile.\n\n'
|
||||
'%s\n' % (self.api.hostname(), self.api.auth_url()))
|
||||
self.print('Open the following URL in a web browser to link your '
|
||||
'install ID with your %s user account:\n\n'
|
||||
'%s\n\n'
|
||||
'This will associate all recordings uploaded from this machine '
|
||||
'(past and future ones) to your account, '
|
||||
'and allow you to manage them (change title/theme, delete) at %s.'
|
||||
% (self.api.hostname(), self.api.auth_url(), self.api.hostname()))
|
||||
|
||||
@@ -15,10 +15,68 @@ DEFAULT_RECORD_ENV = 'SHELL,TERM'
|
||||
|
||||
class Config:
|
||||
|
||||
def __init__(self, config, env=None):
|
||||
self.config = config
|
||||
def __init__(self, config_home, env=None):
|
||||
self.config_home = config_home
|
||||
self.config_file_path = path.join(config_home, "config")
|
||||
self.install_id_path = path.join(self.config_home, 'install-id')
|
||||
self.config = configparser.ConfigParser()
|
||||
self.config.read(self.config_file_path)
|
||||
self.env = env if env is not None else os.environ
|
||||
|
||||
def upgrade(self):
|
||||
try:
|
||||
self.install_id
|
||||
except ConfigError:
|
||||
id = self.__api_token() or self.__user_token() or self.__gen_install_id()
|
||||
self.__save_install_id(id)
|
||||
|
||||
items = {name: dict(section) for (name, section) in self.config.items()}
|
||||
if items == {'DEFAULT': {}, 'api': {'token': id}} or items == {'DEFAULT': {}, 'user': {'token': id}}:
|
||||
os.remove(self.config_file_path)
|
||||
|
||||
if self.env.get('ASCIINEMA_API_TOKEN'):
|
||||
raise ConfigError('ASCIINEMA_API_TOKEN variable is no longer supported, please use ASCIINEMA_INSTALL_ID instead')
|
||||
|
||||
def __read_install_id(self):
|
||||
p = self.install_id_path
|
||||
if path.isfile(p):
|
||||
with open(p, 'r') as f:
|
||||
return f.read().strip()
|
||||
|
||||
def __gen_install_id(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def __save_install_id(self, id):
|
||||
self.__create_config_home()
|
||||
|
||||
with open(self.install_id_path, 'w') as f:
|
||||
f.write(id)
|
||||
|
||||
def __create_config_home(self):
|
||||
if not path.exists(self.config_home):
|
||||
os.makedirs(self.config_home)
|
||||
|
||||
def __api_token(self):
|
||||
try:
|
||||
return self.config.get('api', 'token')
|
||||
except (configparser.NoOptionError, configparser.NoSectionError):
|
||||
pass
|
||||
|
||||
def __user_token(self):
|
||||
try:
|
||||
return self.config.get('user', 'token')
|
||||
except (configparser.NoOptionError, configparser.NoSectionError):
|
||||
pass
|
||||
|
||||
@property
|
||||
def install_id(self):
|
||||
id = self.env.get('ASCIINEMA_INSTALL_ID') or self.__read_install_id()
|
||||
|
||||
if id:
|
||||
return id
|
||||
else:
|
||||
raise ConfigError('no install ID found')
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
return self.env.get(
|
||||
@@ -26,16 +84,6 @@ class Config:
|
||||
self.config.get('api', 'url', fallback=DEFAULT_API_URL)
|
||||
)
|
||||
|
||||
@property
|
||||
def api_token(self):
|
||||
try:
|
||||
return self.env.get('ASCIINEMA_API_TOKEN') or self.config.get('api', 'token')
|
||||
except (configparser.NoOptionError, configparser.NoSectionError):
|
||||
try:
|
||||
return self.config.get('user', 'token')
|
||||
except (configparser.NoOptionError, configparser.NoSectionError):
|
||||
raise ConfigError('no API token found in config file, and ASCIINEMA_API_TOKEN is unset')
|
||||
|
||||
@property
|
||||
def record_stdin(self):
|
||||
return self.config.getboolean('record', 'stdin', fallback=False)
|
||||
@@ -71,45 +119,29 @@ class Config:
|
||||
return self.config.getfloat('play', 'speed', fallback=1.0)
|
||||
|
||||
|
||||
def load_file(paths):
|
||||
config = configparser.ConfigParser()
|
||||
read_paths = config.read(paths)
|
||||
def get_config_home(env=os.environ):
|
||||
env_asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME")
|
||||
env_xdg_config_home = env.get("XDG_CONFIG_HOME")
|
||||
env_home = env.get("HOME")
|
||||
|
||||
if read_paths:
|
||||
return config
|
||||
config_home = None
|
||||
|
||||
if env_asciinema_config_home:
|
||||
config_home = env_asciinema_config_home
|
||||
elif env_xdg_config_home:
|
||||
config_home = path.join(env_xdg_config_home, "asciinema")
|
||||
elif env_home:
|
||||
if path.isfile(path.join(env_home, ".config", "asciinema", "config")):
|
||||
config_home = path.join(env_home, ".config", "asciinema")
|
||||
else:
|
||||
config_home = path.join(env_home, ".asciinema") # location for versions < 1.1
|
||||
else:
|
||||
raise Exception("need $HOME or $XDG_CONFIG_HOME or $ASCIINEMA_CONFIG_HOME")
|
||||
|
||||
def create_file(filename):
|
||||
config = configparser.ConfigParser()
|
||||
config['api'] = {}
|
||||
config['api']['token'] = str(uuid.uuid4())
|
||||
|
||||
if not path.exists(path.dirname(filename)):
|
||||
os.makedirs(path.dirname(filename))
|
||||
|
||||
with open(filename, 'w') as f:
|
||||
config.write(f)
|
||||
|
||||
return config
|
||||
return config_home
|
||||
|
||||
|
||||
def load(env=os.environ):
|
||||
paths = []
|
||||
|
||||
asciinema_config_home = env.get("ASCIINEMA_CONFIG_HOME")
|
||||
xdg_config_home = env.get("XDG_CONFIG_HOME")
|
||||
home = env.get("HOME")
|
||||
|
||||
if asciinema_config_home:
|
||||
paths.append(path.join(asciinema_config_home, "config"))
|
||||
elif xdg_config_home:
|
||||
paths.append(path.join(xdg_config_home, "asciinema", "config"))
|
||||
elif home:
|
||||
paths.append(path.join(home, ".asciinema", "config"))
|
||||
paths.append(path.join(home, ".config", "asciinema", "config"))
|
||||
else:
|
||||
raise Exception("need $ASCIINEMA_CONFIG_HOME or $XDG_CONFIG_HOME or $HOME")
|
||||
|
||||
config = load_file(paths) or create_file(paths[-1])
|
||||
|
||||
return Config(config, env)
|
||||
config = Config(get_config_home(env), env)
|
||||
config.upgrade()
|
||||
return config
|
||||
|
||||
@@ -1,33 +1,97 @@
|
||||
from nose.tools import assert_equal, assert_raises
|
||||
|
||||
import os
|
||||
import os.path as path
|
||||
import tempfile
|
||||
import re
|
||||
|
||||
import asciinema.config as cfg
|
||||
|
||||
|
||||
def create_config(content='', env={}):
|
||||
def create_config(content=None, env={}):
|
||||
dir = tempfile.mkdtemp()
|
||||
path = dir + '/config'
|
||||
|
||||
with open(path, 'w') as f:
|
||||
f.write(content)
|
||||
if content:
|
||||
path = dir + '/config'
|
||||
with open(path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
return cfg.Config(cfg.load_file([path]), env)
|
||||
return cfg.Config(dir, env)
|
||||
|
||||
|
||||
def test_load_config():
|
||||
with tempfile.TemporaryDirectory() as dir:
|
||||
config = cfg.load({'ASCIINEMA_CONFIG_HOME': dir + '/foo/bar'})
|
||||
assert re.match('^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}', config.api_token)
|
||||
def read_install_id(install_id_path):
|
||||
with open(install_id_path, 'r') as f:
|
||||
return f.read().strip()
|
||||
|
||||
with open(dir + '/config', 'w') as f:
|
||||
token = 'foo-bar-baz-qux-quux'
|
||||
f.write("[api]\ntoken = %s" % token)
|
||||
|
||||
config = cfg.load({'ASCIINEMA_CONFIG_HOME': dir})
|
||||
assert_equal(token, config.api_token)
|
||||
def test_upgrade_no_config_file():
|
||||
config = create_config()
|
||||
config.upgrade()
|
||||
install_id = read_install_id(config.install_id_path)
|
||||
|
||||
assert re.match('^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}', install_id)
|
||||
assert_equal(install_id, config.install_id)
|
||||
assert not path.exists(config.config_file_path)
|
||||
|
||||
# it must not change after another upgrade
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), install_id)
|
||||
|
||||
|
||||
def test_upgrade_config_file_with_api_token():
|
||||
config = create_config("[api]\ntoken = foo-bar-baz")
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert_equal(config.install_id, 'foo-bar-baz')
|
||||
assert not path.exists(config.config_file_path)
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
|
||||
|
||||
def test_upgrade_config_file_with_api_token_and_more():
|
||||
config = create_config("[api]\ntoken = foo-bar-baz\nurl = http://example.com")
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert_equal(config.install_id, 'foo-bar-baz')
|
||||
assert_equal(config.api_url, 'http://example.com')
|
||||
assert path.exists(config.config_file_path)
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
|
||||
|
||||
def test_upgrade_config_file_with_user_token():
|
||||
config = create_config("[user]\ntoken = foo-bar-baz")
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert_equal(config.install_id, 'foo-bar-baz')
|
||||
assert not path.exists(config.config_file_path)
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
|
||||
|
||||
def test_upgrade_config_file_with_user_token_and_more():
|
||||
config = create_config("[user]\ntoken = foo-bar-baz\n[api]\nurl = http://example.com")
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
assert_equal(config.install_id, 'foo-bar-baz')
|
||||
assert_equal(config.api_url, 'http://example.com')
|
||||
assert path.exists(config.config_file_path)
|
||||
|
||||
config.upgrade()
|
||||
|
||||
assert_equal(read_install_id(config.install_id_path), 'foo-bar-baz')
|
||||
|
||||
|
||||
def test_default_api_url():
|
||||
@@ -81,31 +145,6 @@ def test_api_url_when_override_set():
|
||||
assert_equal('http://the/url2', config.api_url)
|
||||
|
||||
|
||||
def test_api_token():
|
||||
token = 'foo-bar-baz'
|
||||
config = create_config("[api]\ntoken = %s" % token)
|
||||
assert re.match(token, config.api_token)
|
||||
|
||||
|
||||
def test_api_token_when_no_api_token_set():
|
||||
config = create_config('')
|
||||
with assert_raises(Exception):
|
||||
config.api_token
|
||||
|
||||
|
||||
def test_api_token_when_user_token_set():
|
||||
token = 'foo-bar-baz'
|
||||
config = create_config("[user]\ntoken = %s" % token)
|
||||
assert re.match(token, config.api_token)
|
||||
|
||||
|
||||
def test_api_token_when_api_token_set_and_user_token_set():
|
||||
user_token = 'foo'
|
||||
api_token = 'bar'
|
||||
config = create_config("[user]\ntoken = %s\n[api]\ntoken = %s" % (user_token, api_token))
|
||||
assert re.match(api_token, config.api_token)
|
||||
|
||||
|
||||
def test_record_command():
|
||||
command = 'bash -l'
|
||||
config = create_config("[record]\ncommand = %s" % command)
|
||||
|
||||
Reference in New Issue
Block a user