{"id":20812157,"url":"https://github.com/brightsparklabs/appcli","last_synced_at":"2025-05-07T10:23:24.153Z","repository":{"id":35242174,"uuid":"203926409","full_name":"brightsparklabs/appcli","owner":"brightsparklabs","description":"A library for adding CLI interfaces to applications in the brightSPARK Labs style","archived":false,"fork":false,"pushed_at":"2025-04-28T06:33:30.000Z","size":1414,"stargazers_count":3,"open_issues_count":35,"forks_count":4,"subscribers_count":5,"default_branch":"develop","last_synced_at":"2025-04-28T07:24:19.779Z","etag":null,"topics":["appcli","backups","cli","cli-application","click","configuration-management","docker","python3","remote-backup-strategies"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/brightsparklabs.png","metadata":{"files":{"readme":"README.adoc","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2019-08-23T04:54:48.000Z","updated_at":"2025-04-14T07:26:37.000Z","dependencies_parsed_at":"2023-02-18T20:00:46.680Z","dependency_job_id":"9662ccc6-a714-4052-b5ee-e1636ba03f9e","html_url":"https://github.com/brightsparklabs/appcli","commit_stats":null,"previous_names":[],"tags_count":44,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brightsparklabs%2Fappcli","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brightsparklabs%2Fappcli/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brightsparklabs%2Fappcli/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/brightsparklabs%2Fappcli/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/brightsparklabs","download_url":"https://codeload.github.com/brightsparklabs/appcli/tar.gz/refs/heads/develop","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":252857080,"owners_count":21814937,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["appcli","backups","cli","cli-application","click","configuration-management","docker","python3","remote-backup-strategies"],"created_at":"2024-11-17T20:50:49.836Z","updated_at":"2025-05-07T10:23:24.139Z","avatar_url":"https://github.com/brightsparklabs.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"= BSL Application CLI Library\n:toc: left\n:toclevels: 4\n:sectnums:\n\nhttps://badge.fury.io/py/bsl-appcli[image:https://badge.fury.io/py/bsl-appcli.svg[PyPI version]]\nimage:https://github.com/brightsparklabs/appcli/actions/workflows/build_python.yml/badge.svg[Test\nPython]\n\nA library for adding CLI interfaces to applications in the brightSPARK Labs style.\n\n== Overview\n\nThis library can be leveraged to add a standardised CLI capability to applications to:\n\n* Handle system lifecycle events for services (`service [start|shutdown]`).\n* Allow running arbitrary short-lived tasks (`task run`).\n* Manage configuration (`configure`).\n* Upgrade to a newer version of the application (`upgrade|migrate`).\n* And more.\n\nThe CLI is designed to run within a Docker container and launch other Docker containers (i.e.\nDocker-in-Docker). This is generally managed via a `docker-compose.yml` file.\n\n=== Environment Variables\n\nThe library exposes the following environment variables to the `docker-compose.yml` file:\n\n[horizontal]\n`APP_VERSION`:: The version of containers to launch.\n`\u003cAPP_NAME\u003e_CONFIG_DIR`:: The directory containing configuration files.\n`\u003cAPP_NAME\u003e_DATA_DIR`:: The directory containing data produced/consumed by the system.\n`\u003cAPP_NAME\u003e_GENERATED_CONFIG_DIR`:: The directory containing configuration files generated from the\ntemplates in `\u003cAPP_NAME\u003e_CONFIG_DIR`.\n`\u003cAPP_NAME\u003e_BACKUP_DIR`:: The directory to use for system backups.\n`\u003cAPP_NAME\u003e_ENVIRONMENT`:: The deployment environment the system is running in. For example\n`production` or `staging`. This allows multiple instances of the application to run on the same\nDocker daemon. Defaults to `production`.\n\nNOTE: The `APP_NAME` variable is derived from the `app_name` passed in to the `Configuration`\nobject in the main python entrypoint to the application. In order for the application to work, the\n`app_name` is forced to conform with the shell variable name standard: `[a-zA-Z_][a-zA-Z_0-9]*`.\nAny characters that do not fit this regex will be replaced with `_`. See\nhttps://unix.stackexchange.com/questions/428880/list-of-acceptable-initial-characters-for-a-bash-variable[here]\nor https://linuxhint.com/bash-variable-name-rules-legal-illegal/[here] for details.\n\nThe `docker-compose.yml` can be templated by renaming to `docker-compose.yml.j2`, and setting\nvariables within the `settings.yml` file as described in the Installation section.\n\nStack variables can be set within the `stack-settings.yml` file as described in the\n`Build configuration template directories` section.\n\n=== Volume Mounts\n\nThe following directories are mounted from the host system into the container:\n\n[source,bash]\n----\n--volume \"${INSTALL_DIR}/\u003cenvironment\u003e/data/cli/home:/root\"\n--volume \"${INSTALL_DIR}/\u003cenvironment\u003e/conf:/opt/brightsparklabs/\u003cmy_app\u003e/\u003cenvironment\u003e/conf\"\n--volume \"${INSTALL_DIR}/\u003cenvironment\u003e/conf/.generated:/opt/brightsparklabs/\u003cmy_app\u003e/\u003cenvironment\u003e/conf/.generated\"\n--volume \"${INSTALL_DIR}/\u003cenvironment\u003e/data:/opt/brightsparklabs/\u003cmy_app\u003e/\u003cenvironment\u003e/data\"\n--volume \"${INSTALL_DIR}/\u003cenvironment\u003e/backup:/opt/brightsparklabs/\u003cmy_app\u003e/\u003cenvironment\u003e/backup\"\n----\n\n=== Migration from appcli version \u0026lt;=1.3.6 to version \u003e1.3.6\n\nAs a result of supporting application context files, all references to settings in template files\nhave moved.\n\nAll settings in `settings.yml` used in templating are now namespaced under `settings`. All\ntemplates will need to change their references to use this new namespacing scheme. For example, in\ntemplates that refer to settings, change the references like so:\n\n[source]\n----\nmy_app.server.hostname   -\u003e  settings.my_app.server.hostname\nmy_app.server.http.port  -\u003e  settings.my_app.server.http.port\n----\n\n== Quick Start\n\nRefer to the link:quickstart.md[quick start guide] to get a basic application running.\n\nOtherwise refer to the Installation section below to see all options.\n\n== Installation\n\n=== Add the library to your python CLI application\n\n[source,bash]\n----\npip install git+https://github.com/brightsparklabs/appcli.git@\u003cVERSION\u003e\n----\n\n=== Define the CLI for your application `myapp`\n\n[source,python]\n----\n# filename: myapp.py\n\n#!/usr/bin/env python3\n# # -*- coding: utf-8 -*-\n\n# standard libraries\nfrom pathlib import Path\n\n# vendor libraries\nfrom appcli.cli_builder import create_cli\nfrom appcli.models.configuration import Configuration\nfrom appcli.orchestrators import DockerComposeOrchestrator\n\n# ------------------------------------------------------------------------------\n# CONSTANTS\n# ------------------------------------------------------------------------------\n\n# directory containing this script\nBASE_DIR = Path(__file__).parent\n\n# ------------------------------------------------------------------------------\n# PRIVATE METHODS\n# ------------------------------------------------------------------------------\n\ndef main():\n    configuration = Configuration(\n        app_name='myapp',\n        docker_image='brightsparklabs/myapp',\n        seed_app_configuration_file=BASE_DIR / 'resources/settings.yml',\n        application_context_files_dir=BASE_DIR / 'resources/templates/appcli/context',\n        stack_configuration_file=BASE_DIR / 'resources/stack-settings.yml',\n        baseline_templates_dir=BASE_DIR / 'resources/templates/baseline',\n        configurable_templates_dir=BASE_DIR / 'resources/templates/configurable',\n        orchestrator=DockerComposeOrchestrator(\n            # NOTE: These paths are relative to 'resources/templates/baseline'.\n            docker_compose_file = Path('docker-compose.yml'),\n            docker_compose_override_directory = Path('docker-compose.override.d/'),\n            docker_compose_task_file = Path('docker-compose.tasks.yml'),\n            docker_compose_task_override_directory = Path( 'docker-compose.tasks.override.d/'),\n        ),\n        mandatory_additional_data_dirs=['EXTRA_DATA',],\n        mandatory_additional_env_variables=['ENV_VAR_2',],\n    )\n    cli = create_cli(configuration)\n    cli()\n\n# ------------------------------------------------------------------------------\n# ENTRYPOINT\n# ------------------------------------------------------------------------------\n\nif __name__ == '__main__':\n    main()\n----\n\nMost fields in the appcli constructor can be defaulted, resulting in less code.\n\n[source,python]\n----\ndef main():\n    configuration = Configuration(\n        app_name='myapp',\n        docker_image='brightsparklabs/myapp',\n    )\n    cli = create_cli(configuration)\n    cli()\n----\n\n=== Choose orchestrator\n\n==== DockerComposeOrchestrator\n\nThis is the default orchestrator. It is designed for launching services via a `docker-compose.yml`\nfile.\n\n==== NullOrchestrator\n\nFor applications with no services to orchestrate, the `NullOrchestrator` can be used. This is\nuseful for appcli applications which consist only of the launcher container containing various\nadditional CLI command groups. The `NullOrchestrator` disables commands related to managing\nservices.\n\n[source,python]\n----\nfrom appcli.orchestrators import NullOrchestrator\n\norchestrator = NullOrchestrator()\n----\n\n==== HelmOrchestrator\n\nThe project also includes a https://helm.sh/docs/intro/quickstart/[helm] orchestrator for deploying\ncharts to https://kubernetes.io/[kubernetes] clusters.\n\nCreate a new `resources` directory as follows:\n\n[source,bash]\n----\nresources/\n├── settings.yml\n└── templates/\n   ├── baseline/\n   │  └── cli/\n   │     └── helm/\n   │        ├── set-files/\n   │        │  ├── baz/\n   │        │  │  ├── foo.json\n   │        │  │  └── qux.waldo.txt\n   │        │  └── thud.bang.yml\n   │        ├── set-values/\n   │        │  ├── foo.yml\n   │        │  └── bar.txt\n   │        └── mychart.tgz\n   └── configurable/\n      └── cli/\n         └── home/\n            └── .kube/\n               └── config  # Overwrite this with a cluster specific config file. ie `~/.kube/config`.\n----\n\nYou can then configure the orchestrator as folows:\n\n[source,python]\n----\nfrom appcli.orchestrators import HelmOrchestrator\n\norchestrator = HelmOrchestrator(\n    # Chart archive path (relative to `conf/templates/`).\n    # [Optional] Default is `cli/helm/chart`\n    chart_location=\"cli/helm/mychart.tgz\",\n\n    # The directory containing all main `values.yaml` files (relative to `conf/templates/`).\n    # [Optional] Default is `cli/helm/set-values`\n    helm_set_values_dir=\"cli/helm/set-values\"\n\n    # The directory containing all key-specific files (relative to `conf/templates/`).\n    # [Optional] Default is `cli/helm/set-files`\n    helm_set_files_dir=\"cli/helm/set-files\"\n)\n----\n\n===== Values\n\nValues can be supplied either:\n\n[arabic]\n. For a set key by placing files in `set-files` directory.\n* The name of the key to set is derived from the directory structure and the name of the file (up to\nthe first dot encountered in the filename).\n. Globally for any files dumped in the `set-values` directory.\n\nFor example, given the following `cli/helm/` directory structure:\n\n[source,bash]\n----\ncli/helm/\n├── set-files/\n│  ├── baz/\n│  │  ├── foo.json\n│  │  └── qux.waldo.txt\n│  └── thud.bang.yml\n└── set-values/\n   ├── foo.yml\n   └── bar.txt\n----\n\nThis would result in the following arguments being passed to helm:\n\n[source,bash]\n----\n--set-file baz.foo=cli/helm/set-files/baz/foo.json\n--set-file baz.qux=cli/helm/set-files/baz/qux.waldo.yml    # NOTE: Key is `qux` not `qux.waldo`.\n--set-file thud=cli/helm/set-files/thud.bang.yml           # NOTE: Key is `thud` not `thud.bang`.\n--values cli/helm/set-values/foo.yml\n--values cli/helm/set-values/bar.yml\n----\n\n===== Dev Mode Chart\n\nDuring development it would be slow to require packaging up the chart for any changes. Appcli\nprovides a way to speed up development by allow for the chart to deployed directly from source. This\nis done by specifying the dev chart as an environment variable.\n\n[source,bash]\n----\nMYAPP_DEV_MODE=true MYAPP_DEV_MODE_HELM_CHART=/path/to/mychart python3 -m myapp service start\n----\n\n===== Kubeconfig\n\nA custom `kubeconfig` file can be used by specifying the `KUBECONFIG` environment variable.\n\n[source,bash]\n----\nKUBECONFIG=/opt/brightsparklabs/myapp/conf/.generated/config ./myapp ...\n----\n\nNOTE: The `KUBECONFIG` file must be at a location which is mounted into the launch container. Refer\nto link:#volume-mounts[Volume Mounts] for details on what volumes are mounted into the launch\ncontainer.\n\n=== Add configuration files\n\nAny configuration files used by your services can be templated using the Jinja2 templating engine.\n\n* Store any Jinja2 variable definitions you wish to use in your configuration template files in\n`resources/settings.yml`.\n* Store any application context files in `resources/templates/appcli/context/`.\n* Store any appcli stack specific keys in `resources/stack-settings.yml`.\n* Store your `docker-compose.yml`/`docker-compose.yml.j2` file in\n`resources/templates/baseline/`.\n* Configuration files (Jinja2 compatible templates or otherwise) can be stored in one of two\nlocations:\n** `resources/templates/baseline` - for templates which the end user *is not* expected to modify.\n** `resources/templates/configurable` - for templates which the end user is expected to modify.\n\n==== Application context files\n\nTemplate files are templated with Jinja2. The '`data`' passed into the templating engine is a\ncombination of the `settings.yml` and all application context files (stored in\n`resources/templates/appcli/context`, and referenced in the `Configuration` object as\n`application_context_files_dir`). Application context files that have the extension `.j2` are\ntemplated using the settings from `settings.yml`.\n\nThese are combined to make the data for templating as follows:\n\n[source,json]\n----\n{\n  \"settings\": {\n    ... all settings from `settings.yml`\n  },\n  \"application\": {\n    \u003capp_context_file_1\u003e: {\n      ... settings from `app_context_file_1.yml`, optionally jinja2 templated using settings from `settings.yml`\n    },\n    ... additional app_context_files\n  }\n}\n----\n\nAs a minimal example with the following YAML files:\n\n[source,yaml]\n----\n# ./settings.yml\nmain_settings:\n  abc: 123\n\n# ./resources/templates/appcli/context/app_constants.yml\nother_settings:\n  hello: world\n\n# ./resources/templates/appcli/context/app_variables.yml.j2\nvariables:\n  main_abc_setting: {{ settings.main_settings.abc }}\n----\n\nThe data for Jinja2 templating engine will be:\n\n[source,json]\n----\n{\n  \"settings\": {\n    \"main_settings\": {\n      \"abc\": 123\n    }\n  },\n  \"application\": {\n    \"app_constants\": {\n      \"other_settings\": {\n        \"hello\": \"world\"\n      }\n    },\n    \"app_variables\": {\n      \"variables\": {\n        \"main_abc_setting\": 123\n      }\n    }\n  }\n}\n----\n\n==== Schema validation\n\nConfiguration files will be automatically validated against provided schema files whenever\n`configure apply` is run. Validation is done with https://json-schema.org/[jsonschema] and is only\navailable for `yaml/yml` and `json/jsn` files. The JSON schema file must match the name of the\nfile to validate with a suffix of `.schema.json.`. It must be placed in the same directory as the\nfile to validate, The `settings.yml`, `stack_settings.yml` file, and any files in the\n`resource/templates` or `resources/overrides` directory can be validated.\n\n[source,yaml]\n----\n# resources/templates/configurable/my-config.yml\nfoobar: 5\n----\n\n[source,json]\n----\n# resources/templates/configurable/my-config.yml.schema.json\n{\n    \"$schema\": \"http://json-schema.org/schema\",\n    \"type\": \"object\",\n    \"properties\" : {\n        \"foobar\" : {\"type\": \"number\"}\n    }\n}\n----\n\nTo stop a schema file from being copied across to the `generated` config directory, add\n`.appcli` as an infix.\n\n[source,bash]\n----\n$ ls -1\nbar.json                     # -\u003e Config-file ; Copy-on-apply\nbar.json.schema.json         # -\u003e Schema-file ; Copy-on-apply\nfoo.yaml                     # -\u003e Config-file ; Copy-on-apply\nfoo.yaml.appcli.schema.json  # -\u003e Schema-file ; Ignore-on-apply\n----\n\n==== Secrets management\n\nIMPORTANT: Currently only supported for the `DockerComposeOrchestrator`. Secret management is\ncurrently not available for the `HelmOrchestrator`. Any secret objects should be pre-loaded in the\nKubernetes cluster.\n\nSensitive values can be encrypted inside the `settings.yml` file and then decrypted during\ndeployment within the `docker-compose.yml`.\n\n[source,bash]\n----\n# Automatically encrypt and set (spaces to prevent shell history retention).\n$   ./myapp configure set -e 'path.to.field' 'my-secret-value'\n\n# Manually encrypt and set (spaces to prevent shell history retention).\n$  ./myapp encrypt 'my-secret-value'\nenc:id=X:...\n\n# Set the above value to the field.\n./myapp configure set 'path.to.field' 'enc:id=X:...'\n----\n\nOn template generation, the encrypted values from the `settings.yaml` file are used verbatim in\nthe generated files (i.e. the generated files will contain `enc:id=X:...`). Thus, any encrypted\nvalue comes through verbatim in the file present on disk (i.e. remains encrypted on disk).\n\nIn the appcli container, the `DockerComposeOrchestrator` has special handling when it processes\nthe `docker-compose.yml` file:\n\n. The `docker-compose.yml` file (and any override files) are decrypted and written to a temporary\nfile WITHIN the container.\n. These decrypted files and then used in the context of any `docker-compose` commands to manage\nthe stack.\n. So relevant env vars / secrets will go through into any containers as defined in the\n`docker-compose.yml` file.\n. The decrypted docker compose file disappears when the container shuts down.\n\nIMPORTANT: The secrets are only decrypted in the `docker-compose.yml` (and overrides) files. If\nthey are used in any other configuration file, they will not be decrypted.\n\nThe pattern is to pass secret values into required containers using the `docker-compose.yml` file\nvia environment variables:\n\n[source,bash]\n----\n$ cat docker-compose.yml\n\n...\n\nservices:\n    postgres:\n        image: postgres:lastest\n        environment:\n            - POSTGRES_DB=mydatbase\n            - POSTGRES_USER=myuser\n            - POSTGRES_PASSWORD={{ myapp.postgres.password }}\n        ...\n\n\n$  ./myapp configure set -e 'myapp.postgres.password' 'my-secret-value'\n----\n\nThere might be some use cases where secrets need to be printed to the terminal (development for example).\n`appcli` provides a logging function to accomodate this, which provides the following benefits.\n\n- The secret value is encoded in base 64.\n- It will not be written to a log file, even if a handler is attached.\n\n[source,python]\n----\nfrom appcli.logger import logger\n\nsome_password = os.getenv(\"SOME_PASSWORD\")\nlogger.sensitive(\"Password\", some_password)\n----\n\nThis will print the following message to `stderr`:\n\n[source,bash]\n----\n$ ./myapp log-secret\n[SENSITIVE] Password: \"cEBzc3dvcmQxMjM=\"  # \"p@ssword123\" encoded as Base64\n----\n\nIMPORTANT: This function will not protect against Linux shell redirects.\n\n[source,bash]\n----\n./myapp log-secret 2\u003e file.log  # Secret value will be written to file!!!\n----\n\n=== Configure application backup\n\nAppcli’s `backup` command creates backups of configuration and data of an application, stored\nlocally in the backup directory. The settings for backups are configured through entries in a\n`backups` block in `stack-settings.yml`.\n\nThe available keys for entries in the `backups` block are:\n\n[horizontal]\nname:: The name of the backup. Must be unique between backup definitions and use `kebab-case`.\nbackup_limit:: The number of local backups to keep. Set to `0` to disable rolling deletion.\nfile_filter:: The file_filter contains lists of glob patterns used to specify what files to include\nor exclude from the backup.\nfrequency:: The cron-like frequency at which backups will execute.\n+\nIMPORTANT: The `minute` and `hour` portions of the cron expression are omitted, as that level of\ngranularity is not supported. Refer to \u003c\u003cFrequency\u003e\u003e for details.\nremote_backups:: The list of remote backup strategies.\n\n\n[source,yaml]\n----\n# filename: stack-settings.yml\n\nbackups:\n  - name: \"full\"\n    backup_limit: 0\n    file_filter:\n      data_dir:\n        include_list:\n        exclude_list:\n      conf_dir:\n        include_list:\n        exclude_list:\n    frequency: \"* * *\"\n    remote_backups:\n----\n\n==== Backup name\n\nThe backup `name` is a short descriptive name for the backup definition. To avoid problems, we\n_highly_ recommend `name` be:\n\n* Unique between items in the `backups` list.\n* Use `kebab-case`.\n\nExamples of good names:\n\n* `full`\n* `conf-only`\n* `audit-logs`\n\nWithout a unique `name`, backups from different items in `backups` will overwrite each other\nwithout warning.\n\nUsing `kebab-case` is necessary to avoid some issues with `click` and filesystem naming issues.\n\nWhen using the `backup` command, you are able to supply the name of the backup to run. If you have\na backup `name` with a space in it, the `click` library cannot interpret the name as a whole\nstring (even with quotes), so you will be unable to run the backup individually.\n\nIf the backup `name` doesn’t use `kebab-case`, it may use some characters that are incompatible\nwith file and directory naming conventions. Appcli will automatically slugify the name to something\ncompatible, but this may cause collisions in the folder names of backups to be taken which will lead\nto backups being overwritten. e.g. `s3#1` and `s3\u00261` will both translate internally to `s3-1`.\n\n==== Backup limit\n\nA rolling deletion strategy is used to remove local backups, in order to keep `backup_limit`\nnumber of backups.\n\nIf more than `backup_limit` number of backups exist in the backup directory, the oldest backups\nwill be deleted.\n\nSet this value to `0` to keep all backups.\n\n==== File filter\n\nThe `file_filter` block enables filtering of files to backup from `conf` and `data`\ndirectories. For more details including examples, see link:README_BACKUP_FILE_FILTER.adoc[here].\n\n[source,yaml]\n----\n# filename: stack-settings.yml\n\n# Includes all log files from data dir only.\nbackups:\n  - name: \"full\"\n    backup_limit: 0\n    file_filter:\n      data_dir:\n        include_list:\n          - \"**/*.log\"\n        exclude_list:\n        conf_dir:\n          include_list:\n          exclude_list:\n            - \"**/*\"\n    frequency: \"* * *\"\n    remote_backups:\n----\n\n==== Frequency\n\nAppcli supports limiting individual backups to run on only specific days using a cron-like frequency\nfilter.\n\nWhen the `backup` command is run, each backup strategy will check if the `frequency` pattern\nmatches today’s date. Only strategies whose `frequency` pattern match today’s date will execute.\n\nThe input pattern `pattern` is prefixed with `\"* * \"` and is used as a standard cron expression\nto check for a match. i.e. `\"* * $pattern\"`. This is because `minute` and `hour` granularity are not\nconfigurable.\n\nExamples:\n\n* `\"* * *\"` (cron equivalent `\"* * * * *\"`) will always run.\n* `\"* * 0\"` (cron equivalent `\"* * * * 0\"`) will only run on Sunday.\n* `\"1 */3 *\"` (cron equivalent `\"* * 1 */3 *\"`) will only run on the first day-of-month of every\n3rd month.\n\n==== Remote backup\n\nAppcli supports pushing local backups to remote storage. The list of strategies for pushing to\nremote storage are defined within the `remote_backups` block.\n\nThe available keys for every remote backup strategy are:\n\n[horizontal]\nname:: A short name or description used to describe this backup.\nstrategy_type:: The type of this backup, must match an implemented remote backup strategy.\nfrequency:: The cron-like frequency at which remote backups will execute. Behaves the same as local\nbackup `frequency`.\nconfiguration:: Custom configuration block that is specific to each remote backup strategy.\n\nIMPORTANT:: Remote backups will only run for a local backup that has run. Therefore the `frequency`\nof the local backup will apply first, followed by the `frequency` of the remote backup. This means\nthat it’s possible to write a remote backup frequency that will never execute. e.g. Local `* * 0`\nand remote `* * 1`.\n\n===== Strategies\n\n====== AWS S3 remote strategy\n\nTo use S3 remote backup, set `strategy_type` to `S3`. The available configuration keys for an S3\nbackup are:\n\n[horizontal]\nbucket_name:: The name of the bucket to upload to.\naccess_key:: The AWS Access key ID for the account to upload with.\nsecret_key:: The AWS Secret access key for the account to upload with. The value _must_ be encrypted\nusing the appcli `encrypt` command.\nbucket_path:: The path in the S3 bucket to upload to. Set this to an empty string to upload to the\nroot of the bucket.\ntags:: Key value pairs of tags to set on the backup object.\n\n[source,yaml]\n----\n# filename: stack-settings.yml\n\nbackups:\n  - name: \"full_backup\"\n    backup_limit: 0\n    remote_backups:\n    - name: \"weekly_S3\"\n      strategy_type: \"S3\"\n      frequency: \"* * 0\"\n      configuration:\n        bucket_name: \"aws.s3.bucket\"\n        access_key: \"aws_access_key\"\n        secret_key: \"enc:id=1:encrypted_text:end\"\n        bucket_path: \"bucket/path\"\n        tags:\n          frequency: \"weekly\"\n          type: \"data\"\n----\n\n==== Restoring a remote backup\n\nTo restore from a remote backup:\n\n. Acquire the remote backup (`.tgz` file) that you wish to restore. For S3 this can be done by\ndownloading the backup from the specified bucket.\n. Place the backup `myapp_date.tgz` file in the backup directory. By default this will be\n`/opt/brightsparklabs/${APP_NAME}/production/backup/`\n. Confirm that appcli can access the backup by running the `view-backups` command\n. Run the restore command `./myapp restore BACKUP_FILE.tgz` e.g.\n`./myapp restore APP_2021-02-02T10:55:48+00:00.tgz`. The restore process will trigger a backup.\n\n=== (Optional) Define Custom Commands\n\nYou can specify some custom top-level commands by adding click commands or command groups to the\nconfiguration object. Assuming '`web`' is the name of the service in the docker-compose.yml file\nwhich you wish to exec against, we can create three custom commands in the following example:\n\n* `myapp ls-root` which lists the contents of the root directory within the `web` service\ncontainer and prints it out.\n* `myapp ls-root-to-file` which lists the contents of the root directory within the `web`\nservice container and dumps to file within the container.\n* `myapp tee-file` which takes some text and `tee`s it into another file the `web` service\ncontainer.\n\n[source,python]\n----\ndef get_ls_root_command(orchestrator: DockerComposeOrchestrator):\n    @click.command(\n        help=\"List files in the root directory\",\n    )\n    @click.pass_context\n    def ls_root(ctx: click.Context):\n\n        # Equivalent command within the container:\n        # `ls -alh`\n        cli_context: CliContext = ctx.obj\n        output: CompletedProcess = orchestrator.exec(cli_context, \"web\", [\"ls\", \"-alh\", \"/\"])\n        print(output.stdout.decode())\n\n    return ls_root\n\ndef get_tee_file_command(orchestrator: DockerComposeOrchestrator):\n    @click.command(\n        help=\"Tee some text into a file\",\n    )\n    @click.pass_context\n    def tee_file(ctx: click.Context):\n\n        # Equivalent command within the container:\n        # `echo \"Some data to tee into the custom file\" | tee /ls-root.txt`\n        cli_context: CliContext = ctx.obj\n        output: CompletedProcess = orchestrator.exec(cli_context, \"web\", [\"tee\", \"/my_custom_file.txt\"], stdin_input=\"Some data to tee into the custom file\")\n\n    return tee_file\n\ndef get_ls_root_to_file_command(orchestrator: DockerComposeOrchestrator):\n    @click.command(\n        help=\"List files in the root directory and tee to file\",\n    )\n    @click.pass_context\n    def ls_root_to_file(ctx: click.Context):\n\n        # Equivalent command within the container:\n        # `ls -alh | tee /ls-root.txt`\n        cli_context: CliContext = ctx.obj\n        output: CompletedProcess = orchestrator.exec(cli_context, \"web\", [\"ls\", \"-alh\", \"/\"])\n        data = output.stdout.decode()\n        orchestrator.exec(cli_context, \"web\", [\"tee\", \"/ls-root.txt\"], stdin_input=data)\n\n    return ls_root_to_file\n\ndef main():\n    orchestrator = DockerComposeOrchestrator(Path(\"docker-compose.yml\"))\n    configuration = Configuration(\n        app_name=\"appcli_nginx\",\n        docker_image=\"thomas-anderson-bsl/appcli-nginx\",\n        seed_app_configuration_file=Path(BASE_DIR, \"resources/settings.yml\"),\n        stack_configuration_file=Path(BASE_DIR, \"resources/stack-settings.yml\"),\n        baseline_templates_dir=Path(BASE_DIR, \"resources/templates/baseline\"),\n        configurable_templates_dir=Path(BASE_DIR, \"resources/templates/configurable\"),\n        orchestrator=orchestrator,\n        custom_commands={get_tee_file_command(orchestrator),get_ls_root_command(orchestrator),get_ls_root_to_file_command(orchestrator)}\n    )\n    cli = create_cli(configuration)\n    cli()\n----\n\n=== (Optional) Define hooks\n\nCustom logic can be inserted into the lifecycle by defining the `hooks` parameter in the\n`Configuration` object:\n\n[source,python]\n----\nfrom secrets import token_urlsafe\nfrom appcli.models.configuration import Hooks\n\n\ndef get_hooks() -\u003e Hooks:\n    def post_configure_init(ctx: click.Context):\n    \"\"\"Automatically generate random passwords after `configure init` runs.\"\"\"\n\n    cli_context = ctx.obj\n    configure_cli = cli_context.commands[\"configure\"]\n\n    for setting in [\n        \"myapp.services.api.password\",\n        \"myapp.services.database.password\",\n        \"myapp.services.cache.password\",\n    ]:\n        logger.info(f\"Generating random password for: {setting}\")\n        ctx.invoke(\n            configure_cli.commands[\"set\"],\n            type=\"str\",\n            encrypted=True,\n            setting=setting,\n            value=token_urlsafe(20),\n        )\n\n    def migrate_variables(\n        cli_context: CliContext,\n        current_variables: Dict[str, Any],\n        previous_version: str,\n        clean_new_version_variables: Dict[str, Any],\n    ) -\u003e Dict[str, Any]:\n        logger.info(\n            f\"Migrating myapp `{previous_version}` to `{cli_context.app_version}` ...\"\n        )\n\n        # Handle migration from schema v1 to v2.\n        if current_variables['metadata']['schema_version'] == 1:\n            current_variables['metadata']['schema_version'] = 2\n            # `proxy` key was added in v2.\n            current_variables['myapp']['proxy'] = clean_new_version_variables['myapp']['proxy']\n\n        return current_variables\n\n    ...\n\n    return Hooks(\n        post_configure_init=post_configure_init,\n        migrate_variables=migrate_variables,\n        ...\n    )\n\n...\n\ndef main():\n    configuration = Configuration(\n        app_name=\"myapp\",\n        docker_image=\"brightsparklabs/myapp',\n        hooks=get_hooks()\n    )\n    cli = create_cli(configuration)\n    cli()\n----\n\nThe various hooks are documented in the `Hooks` class within\nlink:appcli/models/configuration.py[the configuration.py] file.\n\nThey generally allow for code to be run pre/post various lifecycle steps. E.g.\n`pre_configure_init` would run the hook prior to the `configure init` stage.\n\nTwo hooks of note are:\n\n. `migrate_variables` - Used to handle schema migrations of the `settings.yml` file.\n. `is_valid_variables` - Used to validate whether a current `settings.yml` file can be used by\nthe current version of the system.\n\n=== (Optional) Preset Configurations\n\nThe `configure init` command initialises the install location with the configuration templates\nfrom the `configurable_templates_dir`.\n\nIn some instances, it is useful to be able to tweak these files for various preset scenarios. E.g.\nIf a system is deployed on-premise it might enable a set of local services which are not needed if\nthe system if deployed to a cloud environment.\n\n`appcli` support defining `presets` to support this use case. This is done by having configuring\nthe `PresetConfiguration` block of the project.\n\n[source,python]\n----\nconfiguration = Configuration(\n    ...\n    auto_configure_on_install=False,\n    presets=PresetsConfiguration(\n        is_mandatory=True,  # [Optional] Whether to support/enforce presets.\n        templates_directory=\"resources/templates/presets\",  # [Optional] Path to the preset dirs.  \n        default_preset=\"onprem\", # [Optional] The preset to apply when not is specified.\n    ),\n)\n----\n\nThe `templates_directory` must contain a directory for each preset, and contain any files which\nshould be overriden from the default `configurable` directory. E.g. the below would ensure the\nvarious presets all override the default `environment.txt` file.\n\n[source,bash]\n----\nresources/templates/\n├── baseline/\n├── configurable/\n│  ├── basefile.yml\n│  └── environment.txt\n└── presets/\n   ├── aws/\n   │  ├── additional_dir/\n   │  │  └── nested_file.yml\n   │  ├── additional_file.yml\n   │  └── environment.txt\n   ├── azure/\n   │  └── environment.txt\n   └── onprem/\n      └── environment.txt\n----\n\nThe `preset` can the be specified when initialising the configuration directory:\n\n[source,bash]\n----\n./myapp configure init --preset aws\n----\n\nThis will do the following:\n\n[arabic]\n. All the files in the `configurable_templates_dir` (e.g. `resources/templates/configurable/`)\nwill be copied to the installation directory as per usual.\n. All files from the `aws` preset will be copied over to the installation directory, overwriting\nany existing files with the same name.\n\n[source,bash]\n----\n/opt/brightsparklabs/myapp/production/conf/templates/\n├── basefile.yml           # Comes from `configurable/`.\n├── additional_dir/        # Comes from `presets/aws/`.\n│  └── nested_file.yml     # Comes from `presets/aws/`.\n├── additional_file.yml    # Comes from `presets/aws/`.\n└── environment.txt        # Comes from `presets/aws/`.\n----\n\n==== Configure Init Hooks\n\nAny `{pre,post}_configure_init` hooks will inherit the profile parameter supplied at runtime.\n\n[source,python]\n----\ndef post_configure_init_hook(ctx: click.Context, preset: Optional[str]):\n  # `preset` will be `--preset \u003cvalue\u003e` or `None` if no parameter was supplied.\n  pass\n----\n\n=== Define a container for your CLI application\n\n[source]\n----\n# filename: Dockerfile\n\nFROM brightsparklabs/appcli\n\nENTRYPOINT [\"./myapp.py\"]\nWORKDIR /app\n\n# install compose if using it as the orchestrator\nRUN pip install docker-compose\n\nCOPY requirements.txt .\nRUN pip install --requirement requirements.txt\nCOPY src .\n\nARG APP_VERSION=latest\nENV APP_VERSION=${APP_VERSION}\n----\n\n==== Build the container\n\n[source,bash]\n----\n# sh\ndocker build -t brightsparklabs/myapp --build-arg APP_VERSION=latest .\n----\n\n==== (Optional) Login to private Docker registries and pass through credentials\n\nIt is possible to login to private Docker registries on the host, and pass through credentials to\nthe CLI container run by the launcher script. This enables pulling and running Docker images from\nprivate Docker registries.\n\nLogin using:\n\n[source,bash]\n----\ndocker login ${REGISTRY_URL}\n----\n\nThe credentials file path can be passed as an option via `--docker-credentials-file` or `-p` to\nthe `myapp` container.\n\n==== View the installer script\n\n[source,bash]\n----\n# sh\ndocker run --rm brightsparklabs/myapp:\u003cversion\u003e install\n\n# or if using a private registry for images\ndocker run --rm brightsparklabs/myapp:\u003cversion\u003e \\\n  --docker-credentials-file ~/.docker/config.json \\\n  install\n----\n\nWhile it is not mandatory to view the script before running, it is highly recommended.\n\n==== Run the installer script\n\n[source,bash]\n----\n# sh\ndocker run --rm brightsparklabs/myapp:\u003cversion\u003e install | sudo bash\n----\n\nThe above will use the following defaults:\n\n* `environment` =\u003e `production`.\n* `install-dir` =\u003e `/opt/brightsparklabs/${APP_NAME}/production/`.\n* `configuration-dir` =\u003e `/opt/brightsparklabs/${APP_NAME}/production/conf/`.\n* `data-dir` =\u003e `/opt/brightsparklabs/${APP_NAME}/production/data/`.\n* `backup-dir` =\u003e `/opt/brightsparklabs/${APP_NAME}/production/backup/`.\n\nYou can modify any of the above if desired. E.g.\n\n[source,bash]\n----\n# sh\ndocker run --rm brightsparklabs/myapp:\u003cversion\u003e \\\n    --environment \"uat\" \\\n    --configuration-dir /etc/myapp \\\n    --data-dir /mnt/data/myapp \\\n    install --install-dir ${HOME}/apps/myapp \\\n| sudo bash\n----\n\nWhere::\n--environment::: defines the environment name for the deployment. This allows multiple instances\nof the application to be present on the same host. Defaults to `production`.\n--install-dir::: defines the base path under which each environment is deployed. It will contain\na directory for each `environment` installed on the system (see above). Each environment directory\nwill contain the launcher, configuration directory and data directory (unless overridden, see\nbelow). Defaults to `/opt/brightsparklabs/${APP_NAME}/`.\n--configuration-dir::: defines the path to the configuration directory. Defaults to\n`${INSTALL_DIR}/\u003cenvironment\u003e/conf/` (`${INSTALL_DIR}` is defined by `--install-dir` above).\n--data-dir::: defines the path to the data directory. Defaults to\n`${INSTALL_DIR}/\u003cenvironment\u003e/data/` (`${INSTALL_DIR}` is defined by `--install-dir` above).\n\nThe installation script will generate a launcher script for controlling the application. The script\nlocation will be printed out when running the install script. This script should now be used as the\nmain entrypoint to all appcli functions for managing your application.\n\n== Usage\n\nThis section details what commands and options are available.\n\n=== Top-level Commands\n\nTo be used in conjunction with your application `./myapp \u003ccommand\u003e`\n\nE.g. `./myapp configure init`\n\nCommands::\n--\n[horizontal]\nbackup::: Create a backup of application data and configuration.\nconfigure::: Configures the application.\nencrypt::: Encrypts the specified string.\ninit::: Initialises the application.\nlauncher::: Outputs an appropriate launcher bash script.\nmigrate::: Migrates the configuration of the application to a newer version.\norchestrator::: Perform docker orchestration\nrestore::: Restore a backup of application data and configuration.\nservice::: Lifecycle management commands for application services.\ntask::: Commands for application tasks.\nversion::: Fetches the version of the app being managed with appcli.\nview-backups::: View a list of locally-available backups.\n--\n\nOptions::\n--\n-–debug::: Enables debug level logging.\n-c, -–configuration-dir PATH::: Directory containing configuration files. [This is required unless\nsubcommand is one of: `install`.\n-d, -–data-dir PATH::: Directory containing data produced/consumed by the system. This is required\nunless subcommand is one of: `install`.\n-t, -–environment TEXT::: Deployment environment the system is running in. Defaults to `production`.\n-p, -–docker-credentials-file PATH::: Path to the Docker credentials file (config.json) on the host\nfor connecting to private Docker registries.\n-a, -–additional-data-dir TEXT::: Additional data directory to expose to launcher container. Can be\nspecified multiple times.\n-e, -–additional-env-var TEXT::: Additional environment variables to expose to launcher container.\nCan be specified multiple times.\n-–help::: Show the help message and exit.\n--\n\n==== Command: `backup`\n\nCreates a backup `.tgz` file in the backup directory that contains files from the configuration\nand data directory, as configured in `stack-settings.yml`. After the backup is taken, remote\nbackup strategies will be executed (if applicable).\n\nUsage: `./myapp backup [OPTIONS] [ARGS]`\n\nOptions::\n-–pre-stop-services/-–no-pre-stop-services::: Whether to stop services before performing backup.\n-–post-start-services/-–no-post-start-services::: Whether to start services after performing backup.\n-–help::: Show the help message and exit.\n\nThe `backup` command optionally takes an argument corresponding to the `name` of the backup to\nrun. If no `name` is provided, all backups will attempt to run.\n\n==== Command Group: `configure`\n\nConfigures the application.\n\nUsage: `./myapp configure [OPTIONS] COMMAND [ARGS]`\n\nCommands::\napply::: Applies the settings from the configuration.\ndiff::: Get the differences between current and default configuration settings.\nget::: Reads a setting from the configuration.\nget-secure::: Reads a setting from the configuration, decrypting if it is encrypted. This will\nprompt for the setting key.\ninit::: Initialises the configuration directory.\nset::: Saves a setting to the configuration. Allows setting the type of value with option `--type`,\nand defaults to string type. Use `-e` to encrypt the value when setting.\ntemplate::: Configures the baseline templates.\nedit::: Open the settings file for editing with vim-tiny.\n\nOptions::\n-–help::: Show the help message and exit.\n\n==== Command: `encrypt`\n\nEncrypts the specified string.\n\nUsage: `./myapp encrypt [OPTIONS] TEXT`\n\nOptions::\n-–help::: Show the help message and exit.\n\n==== Command Group: `init`\n\nInitialises the application.\n\nUsage: `./myapp init [OPTIONS] COMMAND [ARGS]`\n\nCommands::\nkeycloak::: Initialises a Keycloak instance with BSL-specific initial configuration.\n\nOptions::\n-–help::: Show the help message and exit.\n\n==== Command: `launcher`\n\nOutputs an appropriate launcher bash script to stdout.\n\nUsage: `./myapp launcher [OPTIONS]`\n\nOptions::\n-–help::: Show the help message and exit.\n\n==== Command: `migrate`\n\nMigrates the application configuration to work with the current application version.\n\nUsage: `./myapp migrate [OPTIONS]`\n\nOptions::\n-–help::: Show the help message and exit.\n\n==== Command Group: `orchestrator`\n\nPerform tasks defined by the orchestrator.\n\nUsage: `./myapp orchestrator [OPTIONS] COMMAND [ARGS]`\n\nAll commands are defined within the orchestrators themselves. Run `./myapp orchestrator` to list\navailable commands.\n\nFor example, the following commands are available to docker-compose:\n\nCommands::\nps::: List containers for the appcli project, with current status and exposed ports.\ncompose::: Run a docker compose command. See\nhttps://docs.docker.com/engine/reference/commandline/compose/[docker compose].\n\nOptions::\n-–help::: Show the help message and exit\n\n==== Command: `restore`\n\nRestores a specified backup `.tgz` file from the configured backup folder.\n\nUsage: `./myapp restore BACKUP_FILE`\n\nOptions::\n-–help::: Show the help message and exit\n\n==== Command Group: `service`\n\nRuns application services. These are the long-running services which should only exit on command.\n\nUsage: `./myapp service [OPTIONS] COMMAND [ARGS]`\n\nCommands::\nlogs::: Prints logs from all services.\nshutdown::: Shuts down the system. If one or more service names are provided, shuts down the\nspecified service(s) only.\nstart::: Starts the system. If one or more service names are provided, starts the specified\nservice(s) only.\nrestart::: Restarts service(s) (`shutdown` followed by `start`). Optionally run a `configure apply`\nduring the restart with the `--apply` flag. If one or more service names are provided, restarts the\nspecified service(s) only.\nstatus::: Lists all containers for the appcli project, with current status and exposed ports. If one\nor more service names are provided, lists the status and exposed ports of the specified service(s)\nonly.\n\nOptions::\n-–help::: Show the help message and exit\n\n==== Command Group: `task`\n\nRuns application tasks. These are short-lived services which should exit when the task is complete.\n\nUsage: `./myapp task [OPTIONS] COMMAND [ARGS]`\n\nCommands::\nrun::: Runs a specified application task. Optionally run in the background with `-d/--detach` flag.\n\nOptions::\n-–help::: Show the help message and exit\n\n==== Command: `version`\n\nFetches the version of the app being managed with appcli.\n\nUsage: `./myapp version`\n\n==== Command: `view-backups`\n\nView a list of all backups in the configured backup folder.\n\nUsage: `./myapp view-backups`\n\nOptions::\n-–help::: Show the help message and exit\n\n=== Usage within scripts and cron\n\nBy default, the generated `appcli` launcher script will run the CLI container with a virtual\nterminal session (tty). This may interfere with crontab entries or scripts that use the appcli\nlauncher.\n\nTo disable tty when running the launcher script, set `NO_TTY` environment variable to `true`.\n\n[source,bash]\n----\nNO_TTY=true ./myapp [...]\n----\n\nor\n\n[source,bash]\n----\nexport NO_TTY=true\n./myapp [...]\n----\n\nIf required, you can also disable interactive mode with the `NO_INTERACTIVE` environment variable.\n\n[source,bash]\n----\nNO_INTERACTIVE=true ./myapp [...]\n----\n\nor\n\n[source,bash]\n----\nexport NO_INTERACTIVE=true\n./myapp [...]\n----\n\n== Development\n\nThis section details how to build/test/run/debug the system in a development environment.\n\n=== Prerequisites\n\nAll tooling required is defined by the requirements in the `devbox.json` file which relies on\nhttps://www.jetify.com/docs/devbox/[Devbox].\nMake sure you have it installed by following the instructions\nhttps://www.jetify.com/docs/devbox/installing_devbox/#install-devbox[here].\n\nYou can then build the environment and run a shell inside it.\n\n[source,bash]\n----\ncd \u003cappcli-dir\u003e\ndevbox update\ndevbox shell\n# NOTE: It may take a few minutes to build this for the first time.\n----\n\n=== Build\n\n[source,bash]\n----\nmake all\n----\n\n=== Install\n\n[source,bash]\n----\npip install -e .\n----\n\n=== Running unit tests\n\n[source,bash]\n----\nmake test\n----\n\n=== Usage while developing your CLI application\n\nWhile developing, it may be preferable to run your python script directly rather than having to\nrebuild a container each time you update it.\n\nNOTE: The following assumes your app name uppercase slug is `MYAPP`.\n\n* Ensure docker is installed (more specifically a docker socket at `/var/run/docker.sock`).\n* Set the environment variables which the CLI usually sets for you:\n+\n[source,bash]\n----\nexport MYAPP_DEV_MODE=true\n\n# The above is equivalent to:\nexport \\\n  MYAPP_CLI_DEBUG=true \\\n  MYAPP_INSTALL_INSTALL_DIR=/tmp/myapp \\\n  MYAPP_DATA_DIR=/tmp/myapp/local-dev/data \\\n  MYAPP_CONFIG_DIR=/tmp/myapp/local-dev/config \\\n  MYAPP_BACKUP_DIR=/tmp/myapp/local-dev/backup \\\n  MYAPP_ENVIRONMENT=local-dev\n----\n* Run your CLI application:\n+\n[source,bash]\n----\n./src/myapp.py\n----\n\n== Contributing\n\nCurrent issues can be found at https://github.com/brightsparklabs/appcli/issues\n\nCode formatting standards are defined by https://docs.astral.sh/ruff/[ruff].\nUnit tests are created in https://docs.pytest.org/en/latest/[pytest].\nBoth of these are enforced by https://pre-commit.com/[pre-commit].\n\nThis will be validated on each commit, however it can also be manually run with `make precommit`.\n\n== Licenses\n\nRefer to the `LICENSE` file for details.\n\nThis project makes use of several libraries and frameworks. Refer to the `LICENSES` folder for\ndetails.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbrightsparklabs%2Fappcli","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbrightsparklabs%2Fappcli","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbrightsparklabs%2Fappcli/lists"}