Showing preview only (253K chars total). Download the full file or copy to clipboard to get everything.
Repository: marmelab/comfygure
Branch: master
Commit: c2f6c82045f2
Files: 145
Total size: 222.2 KB
Directory structure:
gitextract_ij6k_m60/
├── .github/
│ ├── CONTRIBUTING.md
│ ├── ISSUE_TEMPLATE.md
│ └── SECURITY.md
├── .gitignore
├── .travis.yml
├── LICENSE
├── Makefile
├── README.md
├── api/
│ ├── .dockerignore
│ ├── .gitignore
│ ├── Dockerfile
│ ├── babel.config.js
│ ├── makefile
│ ├── migrations/
│ │ ├── 20170524101600-initialisation.js
│ │ ├── 20170524124810-unique-tag.js
│ │ └── 20200325133153-add-token-table.js
│ ├── now.json
│ ├── package.json
│ ├── serverless.yml
│ ├── src/
│ │ ├── config.js
│ │ ├── domain/
│ │ │ ├── common/
│ │ │ │ ├── formats.js
│ │ │ │ └── states.js
│ │ │ ├── configurations/
│ │ │ │ ├── __mocks__/
│ │ │ │ │ └── tag.js
│ │ │ │ ├── add.js
│ │ │ │ ├── get.js
│ │ │ │ ├── history.js
│ │ │ │ ├── index.js
│ │ │ │ ├── tag.js
│ │ │ │ ├── update.js
│ │ │ │ └── version.js
│ │ │ ├── environments/
│ │ │ │ ├── __mocks__/
│ │ │ │ │ └── get.js
│ │ │ │ ├── add.js
│ │ │ │ ├── add.spec.js
│ │ │ │ ├── get.js
│ │ │ │ ├── get.spec.js
│ │ │ │ ├── index.js
│ │ │ │ ├── remove.js
│ │ │ │ ├── remove.spec.js
│ │ │ │ ├── rename.js
│ │ │ │ └── rename.spec.js
│ │ │ ├── errors.js
│ │ │ ├── permissions.js
│ │ │ ├── projects/
│ │ │ │ ├── __mocks__/
│ │ │ │ │ └── get.js
│ │ │ │ ├── add.js
│ │ │ │ ├── get.js
│ │ │ │ ├── get.spec.js
│ │ │ │ ├── index.js
│ │ │ │ ├── remove.js
│ │ │ │ └── rename.js
│ │ │ ├── tags/
│ │ │ │ ├── add.js
│ │ │ │ ├── move.js
│ │ │ │ ├── remove.js
│ │ │ │ └── validator.js
│ │ │ ├── tokens/
│ │ │ │ ├── add.js
│ │ │ │ ├── generateRandomString.js
│ │ │ │ ├── get.js
│ │ │ │ └── remove.js
│ │ │ └── validation.js
│ │ ├── handlers/
│ │ │ ├── configurations.js
│ │ │ ├── environments.js
│ │ │ ├── projects.js
│ │ │ ├── tags.js
│ │ │ ├── tokens.js
│ │ │ └── utils/
│ │ │ ├── authorization.js
│ │ │ ├── errors.js
│ │ │ └── λ.js
│ │ ├── index.js
│ │ ├── launcher.js
│ │ ├── logger.js
│ │ ├── mocks.js
│ │ └── queries/
│ │ ├── __mocks__/
│ │ │ ├── configurations.js
│ │ │ ├── environments.js
│ │ │ ├── projects.js
│ │ │ └── versions.js
│ │ ├── configurations.js
│ │ ├── entries.js
│ │ ├── environments.js
│ │ ├── knex.js
│ │ ├── projects.js
│ │ ├── tags.js
│ │ ├── tokens.js
│ │ └── versions.js
│ ├── var/
│ │ └── schema.sql
│ └── webpack.config.babel.js
├── cli/
│ ├── .eslintrc
│ ├── .npmignore
│ ├── Makefile
│ ├── README.md
│ ├── bin/
│ │ └── comfy.js
│ ├── package.json
│ └── src/
│ ├── client.js
│ ├── commands/
│ │ ├── admin.js
│ │ ├── diff.js
│ │ ├── env.js
│ │ ├── get.js
│ │ ├── help.js
│ │ ├── init.js
│ │ ├── log.js
│ │ ├── project.js
│ │ ├── set.js
│ │ ├── setall.js
│ │ ├── tag.js
│ │ ├── token.js
│ │ └── version.js
│ ├── crypto/
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ ├── serialization.js
│ │ ├── serialization.spec.js
│ │ └── signature.js
│ ├── domain/
│ │ ├── config.js
│ │ ├── constants.js
│ │ ├── environment.js
│ │ ├── printVersion.js
│ │ ├── project.js
│ │ ├── project.spec.js
│ │ ├── tag.js
│ │ └── token.js
│ ├── format/
│ │ ├── constants.js
│ │ ├── guessFormat.js
│ │ ├── guessFormat.spec.js
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── toFlat.js
│ ├── index.js
│ └── ui/
│ └── console.js
├── docs/
│ ├── HostYourOwn.md
│ ├── HowItWorks.md
│ ├── Usage.md
│ ├── _config.yml
│ ├── _layouts/
│ │ └── default.html
│ ├── css/
│ │ ├── normalize.css
│ │ ├── style.css
│ │ └── syntax.css
│ └── index.md
├── package.json
└── test/
├── Makefile
├── cli.js
├── package.json
├── pm2_configuration.json
├── setup.js
└── specs/
├── basicUsage.js
├── commands.js
├── formats.js
├── init.js
└── scenarios.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/CONTRIBUTING.md
================================================
Want to open a PR on comfy? Thank you! Here are a few things you need to know.
# Project organisation
This repository is splitted into a few parts.
- The serverless API
- The console client
- The utils & tests
They all contain their own `makefile` and `package.json`.
```bash
.
├── api # The serverless API (https://comfy.marmelab.com)
├── cli # The console client (comfygure, that you can install from npm)
├── docs # Built website served by GitHub pages (https://marmelab.com/comfygure)
└── test # E2E tests for the API & client
```
# Installation
```bash
git clone git@github.com:marmelab/comfygure.git
cd comfygure/
make install # Install the dependencies of all the projects
make -C api install-db # Create a database into a docker container on port 5432
```
# Run the project
```bash
make -C api run # Run comfy server API on port 3000
./cli/bin/comfy.js init --origin http://localhost:3000 # Initialize a project on the local API
```
Use `./cli/bin/comfy.js` instead of the global `comfy` command.
# Testing
No PR will be merged if the tests don't pass.
```bash
make test # Run ALL the tests (⌐■_■)
make -C api test # Run API unit tests only
make -C cli test # Run cli unit tests only
make -C test test # Run E2E tests only
```
================================================
FILE: .github/ISSUE_TEMPLATE.md
================================================
Is this issue a question ? Please ask it on StackOverflow with the tag "comfy"
https://stackoverflow.com/questions/tagged/comfy/
### Description
[Description of the bug or feature]
### Steps to Reproduce
1. [First Step]
2. [Second Step]
3. [and so on...]
**Expected behavior:** [What you expected to happen]
**Actual behavior:** [What actually happened]
**Do this issue happen with the default server (https://comfy.marmelab.com)?:** Yes / No
**If yes, here are my project informations to help you find the logs:**
- Project ID:
- Exact date when the issue happened:
================================================
FILE: .github/SECURITY.md
================================================
# Security Policy
## Supported Versions
All versions of comfygure are supported by the security policy.
## Reporting a Vulnerability
To report a vulnerability, you can :
- send an plain email to kevin@marmelab.com
- send an encrypted message on keybase https://keybase.io/kmaschta (then, ping me by mail to be sure I'll read it)
Please provide all the informations you can, especially the version of comfygure impacted and when the issue happened.
No bug bounty program is open.
================================================
FILE: .gitignore
================================================
cli/node_modules/
test/node_modules/
test/.pm2/
test/.env/
docs/_site/
docs/.jekyll-metadata
.comfy/config
sonar-project.properties
.scannerwork/
.vscode/
================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
- '8.10'
- '9'
- '10'
- '11'
- '12'
- '13'
services:
- postgresql
addons:
postgresql: '9.4'
before_install:
- psql -c 'create database comfy;' -U postgres
- psql -U postgres comfy < api/var/schema.sql
install: make install
script: make test
cache:
directories:
- api/node_modules
- cli/node_modules
- admin/node_modules
- test/node_modules
branches:
only:
- master
- next
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2017 marmelab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Makefile
================================================
.PHONY: test
PWD = $(shell pwd)
install:
make -C api install
make -C cli install
make -C test install
run:
-make -C api start-db
make -C api run
test:
make -C api test
make -C cli test
make -C test test
deploy:
cd api && NODE_ENV=production make deploy
publish-cli:
npm publish ./cli
serve-documentation:
docker run -it --rm \
-p 4000:4000 \
-v "${PWD}/docs:/usr/src/app" \
starefossen/github-pages:onbuild \
jekyll serve \
--host=0.0.0.0 \
--incremental
================================================
FILE: README.md
================================================
[](https://badge.fury.io/js/comfygure)   [](http://npmjs.com/comfygure) [](https://hub.docker.com/r/marmelab/comfygure) [](https://travis-ci.org/marmelab/comfygure)
# comfygure
Encrypted and versioned configuration storage built with collaboration in mind.
[Source](https://github.com/marmelab/comfygure) - [Releases](https://github.com/marmelab/comfygure/releases) - [Stack Overflow](https://stackoverflow.com/questions/tagged/comfy/)
[](https://asciinema.org/a/137703)
## Features
- Simple CLI
- End-to-end AES-256 encryption
- Multiple formats support (JSON, YAML, environment variables)
- Git-like Versioning
- Easy to host on your own
Comfygure is great to manage application configurations for multiple environments, toggle feature flags quickly, manage A/B testing based on configuration files.
It is not a [Secret Management Tool](https://gist.github.com/maxvt/bb49a6c7243163b8120625fc8ae3f3cd), it focus on configurations files, their history, and how teams collaborate with them.
## Get Started
On every server that needs access to the settings of an app, install the `comfy` CLI using `npm`:
```bash
npm install -g comfygure
comfy help
```
## Usage
Initialize comfygure in a project directory with `comfy init`:
```bash
> cd myproject
> comfy init
Initializing project configuration...
Project created on comfy server https://comfy.marmelab.com
Configuration saved locally in .comfy/config
comfy project successfully created
```
This creates a unique key to access the settings for `myproject`, and stores the key in `.comfy/config`. You can copy this file to share the credentials with co-workers or other computers.
**Note**: By default, the `comfy` command stores encrypted settings in the `comfy.marmelab.com` server. To host your own comfy server, see [the related documentation](https://marmelab.com/comfygure/HostYourOwn.html).
Import an existing settings file to comfygure using `comfy setall`:
```bash
> echo '{"login": "admin", "password": "S3cr3T"}' > config.json
> comfy setall development config.json
Great! Your configuration was successfully saved.
```
From any computer sharing the same credentials, grab these settings using `comfy get`:
```bash
> comfy get development
{"login": "admin", "password": "S3cr3T"}
> comfy get development --envvars
export LOGIN='admin';
export PASSWORD='S3cr3T';
```
To turn settings grabbed from comfygure into environment variables, use the following:
```bash
> comfy get development --envvars | source /dev/stdin
> echo $LOGIN
admin
```
See the [documentation](https://marmelab.com/comfygure/) to know more about how it works and the remote usage.
## License
Comfygure is licensed under the [MIT License](https://github.com/marmelab/comfygure/blob/master/LICENSE), sponsored and supported by [marmelab](http://marmelab.com).
================================================
FILE: api/.dockerignore
================================================
node_modules/
================================================
FILE: api/.gitignore
================================================
node_modules
.webpack
.serverless
config/*.js
!config/database.js
build/
.env
================================================
FILE: api/Dockerfile
================================================
FROM node:10
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --only=production
COPY build ./build
CMD [ "npm", "start" ]
================================================
FILE: api/babel.config.js
================================================
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 8,
},
},
],
],
plugins: [
'@babel/plugin-transform-async-to-generator',
],
};
================================================
FILE: api/makefile
================================================
SERVERLESS := node_modules/.bin/serverless
DATABASE ?= comfy
export PGUSER ?= postgres
export PGHOST ?= localhost
export PGPASSWORD ?= password
install:
npm i
install-db:
docker run \
-e POSTGRES_PASSWORD=${PGPASSWORD} \
--name comfy-db \
-p 5432:5432 \
-d postgres:9.6
sleep 5s
psql -c "CREATE DATABASE ${DATABASE}"
psql -h localhost -U postgres -d comfy -f ./var/schema.sql
start-db:
docker start comfy-db
stop-db:
docker stop comfy-db
connect-db:
psql comfy
run:
$(SERVERLESS) offline start --host=0.0.0.0 --port=3000
deploy:
NODE_ENV=production $(SERVERLESS) deploy --stage prod
undeploy:
NODE_ENV=production $(SERVERLESS) remove --stage prod
test:
NODE_ENV=test ./node_modules/.bin/jest
test-watch:
NODE_ENV=test ./node_modules/.bin/jest --watch
================================================
FILE: api/migrations/20170524101600-initialisation.js
================================================
/* eslint-disable */
'use strict';
exports.up = function(db, cb) {
db.runSql(`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'state') THEN
CREATE TYPE state AS ENUM (
'live',
'archived'
);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'format') THEN
CREATE TYPE format AS ENUM (
'json',
'yaml',
'envvars'
);
END IF;
CREATE TABLE IF NOT EXISTS project (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
state state NOT NULL DEFAULT 'live',
access_key varchar(20) NOT NULL UNIQUE,
read_token varchar(40) NOT NULL,
write_token varchar(40) NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS environment (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project ON DELETE CASCADE,
name TEXT NOT NULL,
state state NOT NULL DEFAULT 'live',
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
UNIQUE(project_id, name)
);
CREATE TABLE IF NOT EXISTS configuration (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environment ON DELETE CASCADE,
name TEXT NOT NULL,
state state NOT NULL DEFAULT 'live',
default_format format,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
UNIQUE(environment_id, name)
);
CREATE TABLE IF NOT EXISTS version (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
configuration_id UUID NOT NULL REFERENCES configuration ON DELETE CASCADE,
hash varchar(64) NOT NULL,
previous varchar(64),
created_at timestamp with time zone DEFAULT now() NOT NULL,
UNIQUE(configuration_id, hash),
FOREIGN KEY (configuration_id, previous) REFERENCES version(configuration_id, hash)
);
CREATE TABLE IF NOT EXISTS entry (
version_id UUID NOT NULL REFERENCES version ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT,
UNIQUE(version_id, key)
);
CREATE TABLE IF NOT EXISTS tag (
configuration_id UUID NOT NULL REFERENCES configuration ON DELETE CASCADE,
version_id UUID NOT NULL REFERENCES version ON DELETE CASCADE,
name TEXT NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
UNIQUE(configuration_id, version_id, name)
);
END$$;
`, [], cb);
};
exports.down = function(db, cb) {
db.runSql(`
DROP TABLE tag;
DROP TABLE entry;
DROP TABLE version;
DROP TABLE configuration;
DROP TABLE environment;
DROP TABLE project;
DROP TYPE state;
DROP TYPE format;
DROP EXTENSION pgcrypto;
`, [], cb);
};
exports._meta = {
version: 1
};
================================================
FILE: api/migrations/20170524124810-unique-tag.js
================================================
/* eslint-disable */
'use strict';
exports.up = function(db, cb) {
db.runSql(
`DELETE FROM tag
USING version
WHERE version.id = tag.version_id AND EXISTS(
SELECT *
FROM tag t JOIN version v ON t.version_id = v.id
WHERE v.created_at > version.created_at AND t.name = tag.name AND t.configuration_id = tag.configuration_id
)
RETURNING *;
ALTER TABLE tag ADD CONSTRAINT unique_tag UNIQUE (configuration_id, name);`,
[], cb);
};
exports.down = function(db, cb) {
db.runSql(
`
ALTER TABLE tag DROP CONSTRAINT unique_tag UNIQUE (configuration_id, name);
`,
[], cb);
};
exports._meta = {
"version": 1
};
================================================
FILE: api/migrations/20200325133153-add-token-table.js
================================================
/* eslint-disable */
"use strict";
exports.up = function (db, cb) {
db.runSql(
`
DO $$
BEGIN
CREATE TYPE token_level AS ENUM (
'read',
'write'
);
CREATE TABLE token (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project ON DELETE CASCADE,
name TEXT NOT NULL,
level token_level NOT NULL,
key varchar(40) NOT NULL,
state state NOT NULL DEFAULT 'live',
expiry_date timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
UNIQUE(project_id, key),
UNIQUE(project_id, name)
);
INSERT INTO token ("project_id", "name", "level", "key", "expiry_date")
SELECT p.id, 'root', 'write', p.write_token, NULL
FROM project p;
INSERT INTO token ("project_id", "name", "level", "key", "expiry_date")
SELECT p.id, 'read_only', 'read', p.read_token, NULL
FROM project p;
ALTER TABLE project
DROP COLUMN write_token,
DROP COLUMN read_token;
END$$;
`,
[],
cb
);
};
exports.down = function (db, cb) {
db.runSql(
`
DO $$
BEGIN
ALTER TABLE project
ADD COLUMN write_token varchar(40),
ADD COLUMN read_token varchar(40);
UPDATE project
SET write_token = t."key"
FROM token t
WHERE
t.project_id = project.id AND
t."level" = 'write' AND
t."name" = 'root';
UPDATE project
SET read_token = t."key"
FROM token t
WHERE
t.project_id = project.id AND
t."level" = 'read' AND
t."name" = 'read_only';
DROP TABLE token;
DROP TYPE token_level;
END$$;
`,
[],
cb
);
};
exports._meta = {
version: 1,
};
================================================
FILE: api/now.json
================================================
{
"name": "comfygure-api",
"version": 2,
"builds": [
{
"src": "build/index.js",
"use": "@now/node"
}
],
"routes": [{ "src": ".*", "dest": "/build" }],
"env": {
"PGHOST": "@comfy-pghost",
"PGDATABASE": "@comfy-pgdatabase",
"PGUSER": "@comfy-pguser",
"PGPASSWORD": "@comfy-pgpassword"
}
}
================================================
FILE: api/package.json
================================================
{
"name": "comfygure",
"version": "1.2.0",
"license": "MIT",
"private": true,
"scripts": {
"start": "node build/launcher.js",
"dev": "node --require @babel/register src/launcher.js",
"build": "rm -rf build && babel src -d build",
"now-build": "rm -rf build && babel src -d build"
},
"dependencies": {
"body-parser": "^1.19.0",
"co": "~4.6.0",
"convict": "^5.2.0",
"date-fns": "^2.11.1",
"express": "^4.17.1",
"knex": "^0.20.13",
"lodash.omit": "^4.5.0",
"object-hash": "^2.0.3",
"pg": "^7.18.2",
"slug": "^2.1.1"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
"@babel/core": "^7.9.0",
"@babel/plugin-transform-async-to-generator": "^7.8.3",
"@babel/preset-env": "^7.9.0",
"@babel/register": "^7.9.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^25.2.6",
"babel-loader": "^8.1.0",
"jest": "^25.2.7",
"json-loader": "~0.5.4",
"request": "^2.88.2",
"serverless": "^1.67.0",
"serverless-offline": "^5.12.1",
"serverless-webpack": "^5.3.1",
"webpack": "^4.42.1"
},
"jest": {
"testEnvironment": "node"
}
}
================================================
FILE: api/serverless.yml
================================================
service: comfy
provider:
name: aws
runtime: nodejs12.x
stage: prod
region: eu-west-1
functions:
## PROJECTS
projectCreate:
handler: src/handlers/projects.create
description: Create a new project
events:
- http:
method: POST
path: projects
cors: true
projectUpdate:
handler: src/handlers/projects.update
description: Rename a project
events:
- http:
method: PUT
path: projects/{id}
cors: true
projectRemove:
handler: src/handlers/projects.remove
description: Delete a project
events:
- http:
method: DELETE
path: projects/{id}
cors: true
## ENVIRONMENTS
environmentGet:
handler: src/handlers/environments.get
description: List environments of a project
events:
- http:
method: GET
path: projects/{id}/environments
cors: true
environmentCreate:
handler: src/handlers/environments.create
description: Add a new environment to a project
events:
- http:
method: POST
path: projects/{id}/environments
cors: true
environmentUpdate:
handler: src/handlers/environments.update
description: Rename environment of a project
events:
- http:
method: PUT
path: projects/{id}/environments/{environmentName}
cors: true
environmentRemove:
handler: src/handlers/environments.remove
description: Delete environment of a project
events:
- http:
method: DELETE
path: projects/{id}/environments/{environmentName}
cors: true
## CONFIGURATIONS
configurationHistory:
handler: src/handlers/configurations.history
description: List history of a configuration
events:
- http:
method: GET
path: projects/{id}/environments/{environmentName}/configurations/{configName}/history
cors: true
- http:
method: GET
path: projects/{id}/environments/{environmentName}/configurations/history
cors: true
configurationGet:
handler: src/handlers/configurations.get
description: Get tag or hash version of a configuration
events:
- http:
method: GET
path: projects/{id}/environments/{environmentName}/configurations/{configName}
cors: true
- http:
method: GET
path: projects/{id}/environments/{environmentName}/configurations/{configName}/{tagName}
cors: true
configurationAdd:
handler: src/handlers/configurations.create
description: Add a new version of a configuration
events:
- http:
method: POST
path: projects/{id}/environments/{environmentName}/configurations/{configName}/{tagName}
cors: true
configurationRemove:
handler: src/handlers/configurations.remove
description: Remove a configuration
events:
- http:
method: POST
path: projects/{id}/environments/{environmentName}/configurations/{configName}
cors: true
## TAGS
tagAdd:
handler: src/handlers/tags.create
description: Add a new tag on a configuration
events:
- http:
method: POST
path: projects/{id}/environments/{environmentName}/configurations/{configName}/tags
cors: true
tagUpdate:
handler: src/handlers/tags.update
description: Move a tag
events:
- http:
method: PUT
path: projects/{id}/environments/{environmentName}/configurations/{configName}/tags/{tagName}
cors: true
tagRemove:
handler: src/handlers/tags.remove
description: Remove a tag
events:
- http:
method: DELETE
path: projects/{id}/environments/{environmentName}/configurations/{configName}/tags/{tagName}
cors: true
## TOKENS
tokenGet:
handler: src/handlers/tokens.get
description: List tokens of a project
events:
- http:
method: GET
path: projects/{id}/tokens
cors: true
tokenAdd:
handler: src/handlers/tokens.create
description: Add a new token
events:
- http:
method: POST
path: projects/{id}/tokens
cors: true
tokenRemove:
handler: src/handlers/tokens.remove
description: Remove a token
events:
- http:
method: DELETE
path: projects/{id}/tokens/{tokenId}
cors: true
plugins:
- serverless-webpack
- serverless-offline
custom:
webpack:
webpackConfig: "./webpack.config.babel.js"
includeModules: true
================================================
FILE: api/src/config.js
================================================
import convict from "convict";
const config = convict({
port: {
doc: "Default port for the comfy API (default : 80)",
format: Number,
default: 80,
env: "COMFY_API_PORT",
},
logs: {
debug: {
doc: "Log level debug (default: false)",
format: Boolean,
default: false,
env: "COMFY_LOG_DEBUG",
},
},
db: {
client: {
host: {
doc: "PostgreSQL host (default : localhost)",
format: String,
default: "localhost",
env: "PGHOST",
},
port: {
doc: "PostgreSQL port (default : 5432)",
format: Number,
default: 5432,
env: "PGPORT",
},
database: {
doc: "PostgreSQL database (default : 5432)",
format: String,
default: "comfy",
env: "PGDATABASE",
},
user: {
doc: "PostgreSQL user (default : postgres)",
format: String,
default: "postgres",
env: "PGUSER",
},
password: {
doc: "PostgreSQL password (default : '')",
format: String,
default: "",
env: "PGPASSWORD",
},
},
pooling: {
min: {
doc: "Minimum number of DB client in a pool (default : 0)",
format: Number,
default: 0,
env: "COMFY_DB_MIN_POOLING",
},
max: {
doc: "Maximum number of DB client in a pool (default : 2)",
format: Number,
default: 2,
env: "COMFY_DB_MAX_POOLING",
},
},
},
});
config.validate({ allowed: "strict" });
export default config.getProperties();
================================================
FILE: api/src/domain/common/formats.js
================================================
const ENVVARS = "envvars";
const JSON = "json";
const YAML = "yaml";
export { ENVVARS, JSON, YAML };
================================================
FILE: api/src/domain/common/states.js
================================================
const LIVE = "live";
const ARCHIVED = "archived";
export { LIVE, ARCHIVED };
================================================
FILE: api/src/domain/configurations/__mocks__/tag.js
================================================
export const add = jest.fn(() => Promise.resolve({}));
================================================
FILE: api/src/domain/configurations/add.js
================================================
import hash from "object-hash";
import entriesQueries from "../../queries/entries";
import versionsQueries from "../../queries/versions";
import configurationsQueries from "../../queries/configurations";
import { ENVVARS } from "../common/formats";
import { get as getVersion } from "./version";
import { add as addTag, get as getTag, update as updateTag } from "./tag";
import { getProjectOr404 } from "../projects";
import { getEnvironmentOr404 } from "../environments";
export default async (
projectId,
environmentName,
configurationName = "default",
tagName = null,
entries = {},
format = null
) => {
await getProjectOr404(projectId);
const environment = await getEnvironmentOr404(projectId, environmentName);
let configuration = await configurationsQueries.findOne(
projectId,
environmentName,
configurationName
);
let newlyCreated = false;
if (!configuration) {
configuration = await configurationsQueries.insertOne({
environment_id: environment.id,
name: configurationName,
default_format: format || ENVVARS,
});
newlyCreated = true;
}
if (!newlyCreated && configuration.default_format !== format) {
configuration = await configurationsQueries.updateOne(configuration.id, {
...configuration,
default_format: format || ENVVARS,
});
}
const currentVersion = await getVersion(
projectId,
environmentName,
configurationName,
tagName
);
const versionHash = hash({
previous: currentVersion ? currentVersion.hash : null,
entries,
});
// TODO: If the version hash already exist in DB
// return a 304 to warn that the version already exists
const version = await versionsQueries.insertOne({
hash: versionHash,
configuration_id: configuration.id,
previous: currentVersion ? currentVersion.hash : null,
});
if (newlyCreated) {
await addTag(configuration.id, version.id, "latest");
}
// Create or update the specified tag
if (tagName) {
const tag = await getTag(configuration.id, tagName);
if (tag) {
await updateTag(tag, { version_id: version.id });
} else {
await addTag(configuration.id, version.id, tagName);
}
}
await Promise.all(
Object.keys(entries).map((key) =>
entriesQueries.insertOne({
key,
value: entries[key],
version_id: version.id,
})
)
);
const { id, name, default_format: defaultFormat } = configuration;
return {
id,
name,
defaultFormat,
};
};
================================================
FILE: api/src/domain/configurations/get.js
================================================
import configurationsQueries from "../../queries/configurations";
import entriesQueries from "../../queries/entries";
import { NotFoundError } from "../../domain/errors";
import { get as getVersion } from "./version";
import { get as getTag } from "./tag";
import { checkEnvironmentExistsOrThrow404 } from "../validation";
const entriesToDictionary = (entries) =>
entries.reduce(
(dictionary, item) => ({
...dictionary,
[item.key]: item.value,
}),
{}
);
const findAloneConfiguration = async (projectId, environmentName) => {
const configurations = await configurationsQueries.findAllByEnvironmentName(
projectId,
environmentName
);
if (configurations.length !== 1) {
// TODO: If configurations.length = 0, return a usable error
// TODO: If configurations.length > 1, return a usable error
throw new Error(
"There is more than one configuration. Please select a configuration by its name."
);
}
return configurations[0];
};
export default async (
projectId,
environmentName,
selector,
pathTagOrHashName
) => {
// The `selector` argument can be a configName, a tag, or empty
// TODO (Kevin): If needed, move this selector intelligence into its own service
let configuration;
let tagOrHashName = pathTagOrHashName;
await checkEnvironmentExistsOrThrow404(projectId, environmentName);
if (selector && tagOrHashName) {
configuration = await configurationsQueries.findOne(
projectId,
environmentName,
selector
);
} else if (!selector) {
configuration = await findAloneConfiguration(projectId, environmentName);
} else {
configuration = await configurationsQueries.findOne(
projectId,
environmentName,
selector
);
}
if (!configuration) {
configuration = await findAloneConfiguration(projectId, environmentName);
tagOrHashName = pathTagOrHashName || selector;
}
let tag;
let version;
const defaultTag = "latest";
if (tagOrHashName) {
version = await getVersion(
projectId,
environmentName,
configuration.name,
tagOrHashName
);
} else {
tag = await getTag(configuration.id, defaultTag);
version = await getVersion(
projectId,
environmentName,
configuration.name,
tag ? tag.name : ""
);
}
if (!version) {
throw new NotFoundError(
`There is no tag or hash with this name: ${
tagOrHashName ? tagOrHashName : defaultTag
}.`
);
}
const entries = entriesToDictionary(
await entriesQueries.findByVersion(version.id)
);
return {
id: configuration.id,
name: configuration.name,
tag: tag ? tag.name : "",
hash: version.hash,
previous: version.previous,
defaultFormat: configuration.default_format,
body: entries,
state: configuration.state,
};
};
================================================
FILE: api/src/domain/configurations/history.js
================================================
import getConfiguration from "./get";
import versionsQueries from "../../queries/versions";
export default async (projectId, environmentName, configName, all = false) => {
const configuration = await getConfiguration(
projectId,
environmentName,
configName
);
const versions = await versionsQueries.find(configuration.id);
return versions
.filter((version) => (all ? true : version.tags.length)) // TODO: Do this filter in SQL
.sort((a, b) => b.created_at - a.created_at)
.map((version) => ({
name: configuration.name,
hash: version.hash,
previous: version.previous,
tags: version.tags,
defaultFormat: configuration.default_format,
created_at: version.created_at,
}));
};
================================================
FILE: api/src/domain/configurations/index.js
================================================
import add from "./add";
import get from "./get";
import history from "./history";
import update from "./update";
export default {
add,
get,
history,
update,
};
================================================
FILE: api/src/domain/configurations/tag.js
================================================
import tagsQueries from "../../queries/tags";
export const add = async (configurationId, versionId, name) =>
tagsQueries.insertOne({
configuration_id: configurationId,
version_id: versionId,
name, // TODO: slufigy the tag name or throw if the format is invalid
});
export const get = async (configurationId, name) =>
tagsQueries.findOne(configurationId, name);
export const update = async (tag, attributes) =>
tagsQueries.updateOne(tag, attributes);
================================================
FILE: api/src/domain/configurations/update.js
================================================
import hash from "object-hash";
import entriesQueries from "../../queries/entries";
import versionsQueries from "../../queries/versions";
import configurationsQueries from "../../queries/configurations";
import tagsQueries from "../../queries/tags";
import { get as getVersion } from "./version";
export default async (configurationId, tagName = "latest") => {
const configuration = await configurationsQueries.findOne(configurationId);
if (!configuration) {
return null;
}
const lastVersion = await getVersion(configurationId, tagName);
const versionHash = hash({
previous: lastVersion.hash,
entries,
});
const version = await versionsQueries.insertOne({
hash: versionHash,
previous: lastVersion.hash,
});
await tagsQueries.updateOne(lastTag.id, {
version_id: version.id,
});
await Object.keys(entries).map((key) =>
entriesQueries.insertOne({
key,
value: entries[key],
version_id: version.id,
})
);
};
================================================
FILE: api/src/domain/configurations/version.js
================================================
import configurationsQueries from "../../queries/configurations";
import versionsQueries from "../../queries/versions";
import tagsQueries from "../../queries/tags";
export const get = async (projectId, environmentName, configName, selector) => {
const configuration = await configurationsQueries.findOne(
projectId,
environmentName,
configName
);
const tag = await tagsQueries.findOne(configuration.id, selector);
let version;
if (!tag) {
version = await versionsQueries.findOneByHash(configuration.id, selector);
} else {
version = await versionsQueries.findOne(tag.version_id);
}
return version;
};
export const getDefault = async (projectId, configName, tagName) => {
const configuration = await configurationsQueries.findOne(
projectId,
"default",
configName
);
let tag = await tagsQueries.findOne(configuration.id, tagName);
if (!tag) {
tag = await tagsQueries.findOne(configuration.id, "latest");
}
const version = await versionsQueries.findOneByTag(configuration.id, tag.id);
return version;
};
================================================
FILE: api/src/domain/environments/__mocks__/get.js
================================================
export const getEnvironmentOr404 = jest.fn((id, name) =>
Promise.resolve({ name })
);
================================================
FILE: api/src/domain/environments/add.js
================================================
import hash from "object-hash";
import environmentsQueries from "../../queries/environments";
import configurationsQueries from "../../queries/configurations";
import versionsQueries from "../../queries/versions";
import { add as addTag } from "../configurations/tag";
import { LIVE } from "../common/states";
import { ENVVARS } from "../common/formats";
export default async (projectId, environmentName, configName = "default") => {
// TODO (Kevin): Check if the environment already exists and return a usable error if it's the case
// TODO (Kevin): Factorize the code with domain/configurations/add
const environment = await environmentsQueries.insertOne({
name: environmentName,
project_id: projectId,
state: LIVE,
});
const configuration = await configurationsQueries.insertOne({
environment_id: environment.id,
name: configName,
default_format: ENVVARS,
});
const version = await versionsQueries.insertOne({
configuration_id: configuration.id,
hash: hash({ previous: null }),
previous: null,
});
await addTag(configuration.id, version.id, "latest");
return {
...environment,
configurations: [configuration],
};
};
================================================
FILE: api/src/domain/environments/add.spec.js
================================================
import add from "./add";
import { LIVE } from "../common/states";
import environmentsQueries from "../../queries/environments";
import configurationsQueries from "../../queries/configurations";
jest.mock("../../queries/environments");
jest.mock("../../queries/configurations");
jest.mock("../../queries/versions");
jest.mock("../configurations/tag");
describe("domain/environments/add", () => {
const projectId = 1;
const environmentName = "production";
const configurationName = "frontend";
it("should create an environment for the project", async () => {
const environment = await add(projectId, environmentName);
expect(environmentsQueries.insertOne).toHaveBeenCalledWith({
name: environmentName,
project_id: projectId,
state: LIVE,
});
expect(environment).toBeTruthy();
expect(environment).toMatchObject({
id: 1,
name: environmentName,
project_id: projectId,
state: LIVE,
});
});
it("should create a configuration for the project and the environment", async () => {
const environment = await add(
projectId,
environmentName,
configurationName
);
expect(configurationsQueries.insertOne).toHaveBeenCalledWith({
default_format: "envvars",
environment_id: 1,
name: "frontend",
});
expect(environment.configurations.length).toEqual(1);
expect(environment.configurations[0].name).toEqual(configurationName);
});
it("should create the configuration as `default` if no name is provided", async () => {
const environment = await add(projectId, environmentName);
expect(configurationsQueries.insertOne).toHaveBeenCalledWith({
default_format: "envvars",
environment_id: 1,
name: "default",
});
expect(environment.configurations.length).toEqual(1);
expect(environment.configurations[0].name).toEqual("default");
});
it("should return both environment and linked configurations", async () => {
const environment = await add(
projectId,
environmentName,
configurationName
);
expect(environment).toMatchObject({
id: 1,
name: environmentName,
project_id: projectId,
state: LIVE,
});
expect(environment.configurations[0].name).toEqual(configurationName);
});
});
================================================
FILE: api/src/domain/environments/get.js
================================================
import environmentsQueries from "../../queries/environments";
import { NotFoundError } from "../errors";
export const getEnvironmentOr404 = async (projectId, environmentName) => {
const env = await environmentsQueries.findOne(projectId, environmentName);
if (!env) {
throw new NotFoundError({
message: `Unable to find environment "${environmentName}" for project "${projectId}".`,
details: 'Type "comfy env ls" to list available environments.',
});
}
return env;
};
export default async (projectId) =>
environmentsQueries.selectByProject(projectId);
================================================
FILE: api/src/domain/environments/get.spec.js
================================================
import get, { getEnvironmentOr404 } from "./get";
import environmentsQueries from "../../queries/environments";
jest.mock("../../queries/environments");
describe("domain/environments/get", () => {
describe("get", () => {
it("should call the query with the right arguments", async () => {
const projectId = 1;
await get(projectId);
expect(environmentsQueries.selectByProject).toHaveBeenCalledWith(
projectId
);
});
});
describe("getEnvironmentOr404", () => {
it("should retrieve an environment", async () => {
const projectId = 42;
const environmentName = "prod";
const env = await getEnvironmentOr404(projectId, environmentName);
expect(environmentsQueries.findOne).toHaveBeenCalledWith(
projectId,
environmentName
);
expect(env).not.toBeUndefined();
});
it("should throw a NotFoundError if the env does not exist", async () => {
const projectId = 42;
const environmentName = "prod";
environmentsQueries.findOne.mockImplementation(() =>
Promise.resolve(null)
);
try {
await getEnvironmentOr404(projectId, environmentName);
} catch (error) {
expect(error.name).toBe("NotFoundError");
return;
}
expect("The function should throw an error").toBe(false);
});
});
});
================================================
FILE: api/src/domain/environments/index.js
================================================
import add from "./add";
import get, { getEnvironmentOr404 } from "./get";
import remove from "./remove";
import rename from "./rename";
export { getEnvironmentOr404 };
export default {
add,
get,
getEnvironmentOr404,
remove,
rename,
};
================================================
FILE: api/src/domain/environments/remove.js
================================================
import environmentsQueries from "../../queries/environments";
import { ARCHIVED } from "../common/states";
import { getProjectOr404 } from "../projects/get";
import { getEnvironmentOr404 } from "./get";
export default async (projectId, environmentName) => {
await getProjectOr404(projectId);
const environment = await getEnvironmentOr404(projectId, environmentName);
if (environment.state === ARCHIVED) {
return null;
}
return environmentsQueries.updateOne(environment.id, {
state: ARCHIVED,
});
};
================================================
FILE: api/src/domain/environments/remove.spec.js
================================================
import remove from "./remove";
import { ARCHIVED } from "../common/states";
import environmentsQueries from "../../queries/environments";
import { getEnvironmentOr404 } from "./get";
jest.mock("../../queries/environments");
jest.mock("./get");
jest.mock("../projects/get");
describe("domain/environments/remove", () => {
const projectId = 1;
const environmentName = "staging";
it("should try to find the environment by project id and name", async () => {
await remove(projectId, environmentName);
expect(getEnvironmentOr404).toHaveBeenCalledWith(
projectId,
environmentName
);
});
it("should set `ARCHIVED` state on environment", async () => {
await remove(projectId, environmentName);
expect(environmentsQueries.updateOne).toHaveBeenCalledWith(undefined, {
state: ARCHIVED,
});
});
});
================================================
FILE: api/src/domain/environments/rename.js
================================================
import environmentsQueries from "../../queries/environments";
import { getProjectOr404 } from "../projects/get";
import { getEnvironmentOr404 } from "./get";
export default async (projectId, environmentName, newEnvironmentName) => {
await getProjectOr404(projectId);
const environment = await getEnvironmentOr404(projectId, environmentName);
if (!environment) {
return null;
}
return environmentsQueries.updateOne(environment.id, {
name: newEnvironmentName,
});
};
================================================
FILE: api/src/domain/environments/rename.spec.js
================================================
import rename from "./rename";
import environmentsQueries from "../../queries/environments";
import { getEnvironmentOr404 } from "./get";
jest.mock("../../queries/environments");
jest.mock("./get");
jest.mock("../projects/get");
describe("domain/environments/rename", () => {
const projectId = 1;
const environmentName = "staging";
const newEnvironmentName = "integration";
it("should try to find the environment by project id and name", async () => {
await rename(projectId, environmentName, newEnvironmentName);
expect(getEnvironmentOr404).toHaveBeenCalledWith(
projectId,
environmentName
);
});
it("should change name on environment", async () => {
await rename(projectId, environmentName, newEnvironmentName);
expect(environmentsQueries.updateOne).toHaveBeenCalledWith(undefined, {
name: newEnvironmentName,
});
});
});
================================================
FILE: api/src/domain/errors.js
================================================
export class NotFoundError extends Error {
constructor(args) {
super(args);
if (typeof args === "string") {
this.message = args;
} else {
this.message = args.message;
this.details = args.details;
}
this.name = this.constructor.name;
if (typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, this.constructor);
} else {
this.stack = new Error(this.message).stack;
}
this.stack = new Error().stack;
}
}
export class ValidationError extends Error {
constructor(args) {
super(args);
if (typeof args === "string") {
this.message = args;
} else {
this.message = args.message;
this.details = args.details;
}
this.name = this.constructor.name;
if (typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, this.constructor);
} else {
this.stack = new Error(this.message).stack;
}
this.stack = new Error().stack;
}
}
================================================
FILE: api/src/domain/permissions.js
================================================
import tokenQueries from "../queries/tokens";
export const checkPermission = async (projectId, tokenKey, level) => {
const token = await tokenQueries.findValidTokenByKey(projectId, tokenKey);
if (!token) {
throw new Error("Project ID or token is invalid.");
}
let permissionIsValid;
switch (level) {
case "write":
permissionIsValid = token.level === "write";
break;
case "read":
permissionIsValid = ["write", "read"].includes(token.level);
break;
default:
throw new Error(`Level "${level}" doesn't exists.`);
}
if (!permissionIsValid) {
throw new Error("Your token doesn't allow you to perform this action.");
}
};
================================================
FILE: api/src/domain/projects/__mocks__/get.js
================================================
export const getProjectOr404 = jest.fn((id) => Promise.resolve({ id }));
================================================
FILE: api/src/domain/projects/add.js
================================================
import { LIVE } from "../common/states";
import projectsQueries from "../../queries/projects";
import addEnvironment from "../environments/add";
import addToken from "../tokens/add";
import generateRandomString from "../tokens/generateRandomString";
export default async (
name,
environmentName = "default",
configurationName = "default"
) => {
const project = await projectsQueries.insertOne({
name,
state: LIVE,
access_key: generateRandomString(20, true),
});
const environment = await addEnvironment(
project.id,
environmentName,
configurationName
);
const writeToken = await addToken(project.id, "root", "write");
return {
...project,
environments: [environment],
tokens: [writeToken],
// Keep the following keys to not break retro-compatibility
writeToken: writeToken.key,
readToken: null,
};
};
================================================
FILE: api/src/domain/projects/get.js
================================================
import projectsQueries from "../../queries/projects";
import { NotFoundError } from "../errors";
export const getProjectOr404 = async (projectId) => {
const project = await projectsQueries.findOne(projectId);
if (!project) {
throw new NotFoundError({
message: `Unable to find project "${projectId}"`,
details: [
"Have you initialized a comfy project in thin directory?",
'Type "comfy init" to do so.',
].join(" "),
});
}
return project;
};
================================================
FILE: api/src/domain/projects/get.spec.js
================================================
import { getProjectOr404 } from "./get";
import projectsQueries from "../../queries/projects";
jest.mock("../../queries/projects");
describe("domain/projects/get", () => {
describe("getProjectOr404", () => {
it("should retrieve an environment", async () => {
const env = await getProjectOr404(42);
expect(projectsQueries.findOne).toHaveBeenCalledWith(42);
expect(env).not.toBeUndefined();
});
it("should throw a NotFoundError if the env does not exist", async () => {
projectsQueries.findOne.mockImplementation(() => Promise.resolve(null));
try {
await getProjectOr404(42);
} catch (error) {
expect(error.name).toBe("NotFoundError");
return;
}
expect("The function should throw an error").toBe(false);
});
});
});
================================================
FILE: api/src/domain/projects/index.js
================================================
import add from "./add";
import { getProjectOr404 } from "./get";
import remove from "./remove";
import rename from "./rename";
export { getProjectOr404 };
export default {
add,
getProjectOr404,
remove,
rename,
};
================================================
FILE: api/src/domain/projects/remove.js
================================================
import projectsQueries from "../../queries/projects";
import { ARCHIVED } from "../common/states";
export default async (id) =>
projectsQueries.updateOne(id, {
state: ARCHIVED,
});
================================================
FILE: api/src/domain/projects/rename.js
================================================
import projectQueries from "../../queries/projects";
export default async (id, name) =>
projectQueries.updateOne(id, {
name,
});
================================================
FILE: api/src/domain/tags/add.js
================================================
import { get as getVersion } from "../configurations/version";
import tagsQueries from "../../queries/tags";
import validateTag from "./validator";
export default async (
projectId,
environmentName,
configName,
selector,
name
) => {
validateTag(name);
const version = await getVersion(
projectId,
environmentName,
configName,
selector
);
if (!version) {
throw new Error(`No configuration found for selector "${selector}"`);
}
return tagsQueries.insertOne({
configuration_id: version.configuration_id,
version_id: version.id,
name,
});
};
================================================
FILE: api/src/domain/tags/move.js
================================================
import { get as getVersion } from "../configurations/version";
import tagsQueries from "../../queries/tags";
import configurationsQueries from "../../queries/configurations";
import validateTag from "./validator";
export default async (
projectId,
environmentName,
configurationName,
name,
selector
) => {
validateTag(name);
const configuration = await configurationsQueries.findOne(
projectId,
environmentName,
configurationName
);
if (!configuration) {
throw new Error(`Configuration "${configurationName}" doesn't exist`);
}
const tag = await tagsQueries.findOne(configuration.id, name);
if (!tag) {
throw new Error(`Tag "${name}" doesn't exist`);
}
const newVersion = await getVersion(
projectId,
environmentName,
configurationName,
selector
);
if (!newVersion) {
throw new Error(`No version found for selector "${selector}"`);
}
return tagsQueries.updateOne(
{
configuration_id: tag.configuration_id,
version_id: tag.version_id,
name: tag.name,
},
{
version_id: newVersion.id,
}
);
};
================================================
FILE: api/src/domain/tags/remove.js
================================================
import tagsQueries from "../../queries/tags";
import configurationsQueries from "../../queries/configurations";
import validateTag from "./validator";
export default async (projectId, environmentName, configurationName, name) => {
validateTag(name);
const configuration = await configurationsQueries.findOne(
projectId,
environmentName,
configurationName
);
if (!configuration) {
throw new Error(`Configuration "${configurationName}" doesn't exist`);
}
const tag = await tagsQueries.findOne(configuration.id, name);
if (!tag) {
throw new Error(`Tag "${name}" doesn't exist`);
}
return tagsQueries.removeOne(tag);
};
================================================
FILE: api/src/domain/tags/validator.js
================================================
import slug from "slug";
export default (name) => {
if (slug(name) !== name) {
throw new Error(
`Tag name "${name}" is not valid. It should not contain whitespace or special character.`
);
}
};
================================================
FILE: api/src/domain/tokens/add.js
================================================
import { addDays } from "date-fns";
import { ValidationError } from "../errors";
import tokensQueries from "../../queries/tokens";
import generateRandomString from "./generateRandomString";
export default async (projectId, name, level, expiresInDays) => {
const expiryDate = expiresInDays
? addDays(new Date(), expiresInDays + 1)
: null;
try {
return await tokensQueries.insertOne({
project_id: projectId,
name,
level,
key: generateRandomString(40),
expiry_date: expiryDate,
});
} catch (error) {
if (error.message.includes("token_project_id_name_key")) {
throw new ValidationError({
message: `A token named "${name}" already exists for that project`,
details: 'Type "comfy token list" to list available tokens.',
});
}
throw error;
}
};
================================================
FILE: api/src/domain/tokens/generateRandomString.js
================================================
export default (size, upperAlphaOnly = false) => {
const numeric = "0123456789";
const lowerAlpha = "abcdefghijklmnopqrstuvwxyz";
const upperAlpha = lowerAlpha.toUpperCase();
const source = upperAlphaOnly
? upperAlpha
: numeric + lowerAlpha + upperAlpha;
let randomlyGeneratedString = "";
while (randomlyGeneratedString.length < size) {
const randomIndex = Math.floor(Math.random() * (source.length - 1));
randomlyGeneratedString += source[randomIndex];
}
return randomlyGeneratedString;
};
================================================
FILE: api/src/domain/tokens/get.js
================================================
import tokensQueries from "../../queries/tokens";
export default (projectId, all = false) =>
tokensQueries.findByProjectId(projectId, all);
================================================
FILE: api/src/domain/tokens/remove.js
================================================
import tokensQueries from "../../queries/tokens";
import { ARCHIVED } from "../common/states";
export default async (tokenId) => {
return tokensQueries.updateOne(tokenId, {
state: ARCHIVED,
});
};
================================================
FILE: api/src/domain/validation.js
================================================
import { getProjectOr404 } from "./projects/get";
import { getEnvironmentOr404 } from "./environments/get";
export const checkEnvironmentExistsOrThrow404 = async (
projectId,
environmentName
) => {
await getProjectOr404(projectId);
await getEnvironmentOr404(projectId, environmentName);
};
================================================
FILE: api/src/handlers/configurations.js
================================================
import λ from "./utils/λ";
import {
checkAuthorizationOr403,
parseAuthorizationToken,
} from "./utils/authorization";
import getConfiguration from "../domain/configurations/get";
import getHistory from "../domain/configurations/history";
import addConfiguration from "../domain/configurations/add";
export const create = λ(async (event) => {
const {
id: projectId,
environmentName,
configName,
tagName,
} = event.pathParameters;
const { entries, format } = event.body;
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
return addConfiguration(
projectId,
environmentName,
configName,
tagName,
entries,
format
);
});
export const get = λ(async (event) => {
const {
id: projectId,
environmentName,
configName: selector,
tagName: tagOrHashName,
} = event.pathParameters;
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"read"
);
return getConfiguration(projectId, environmentName, selector, tagOrHashName);
});
export const history = λ(async (event) => {
const { id: projectId, environmentName, configName } = event.pathParameters;
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"read"
);
const all =
event.queryStringParameters &&
Object.keys(event.queryStringParameters).includes("all");
return getHistory(projectId, environmentName, configName, all);
});
================================================
FILE: api/src/handlers/environments.js
================================================
import λ from "./utils/λ";
import {
checkAuthorizationOr403,
parseAuthorizationToken,
} from "./utils/authorization";
import addEnvironment from "../domain/environments/add";
import getEnvironments from "../domain/environments/get";
import renameEnvironment from "../domain/environments/rename";
import removeEnvironment from "../domain/environments/remove";
export const get = λ(async (event) => {
const { id: projectId } = event.pathParameters || {};
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"read"
);
return getEnvironments(projectId);
});
export const create = λ(async (event) => {
const { id: projectId } = event.pathParameters || {};
const { name: environmentName } = event.body || {};
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
return addEnvironment(projectId, environmentName);
});
export const update = λ(async (event) => {
const { id: projectId, environmentName } = event.pathParameters || {};
const { name: newEnvironmentName } = event.body || {};
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
return renameEnvironment(projectId, environmentName, newEnvironmentName);
});
export const remove = λ(async (event) => {
const { id: projectId, environmentName } = event.pathParameters || {};
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
return removeEnvironment(projectId, environmentName);
});
================================================
FILE: api/src/handlers/projects.js
================================================
import λ from "./utils/λ";
import {
checkAuthorizationOr403,
parseAuthorizationToken,
} from "./utils/authorization";
import addProject from "../domain/projects/add";
import renameProject from "../domain/projects/rename";
import removeProject from "../domain/projects/remove";
export const create = λ(async (event) => {
const { name: projectName, environment: environmentName } = event.body || {};
const project = await addProject(projectName, environmentName);
return project;
});
export const update = λ(async (event) => {
const { id: projectId } = event.pathParameters || {};
const { name: newProjectName } = event.body || {};
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
const project = await renameProject(projectId, newProjectName);
return project;
});
export const remove = λ(async (event) => {
const { id: projectId } = event.pathParameters || {};
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
return removeProject(projectId);
});
================================================
FILE: api/src/handlers/tags.js
================================================
import λ from "./utils/λ";
import {
checkAuthorizationOr403,
parseAuthorizationToken,
} from "./utils/authorization";
import addTag from "../domain/tags/add";
import moveTag from "../domain/tags/move";
import removeTag from "../domain/tags/remove";
export const create = λ(async (event) => {
const { id: projectId, environmentName, configName } = event.pathParameters;
const { selector, name } = event.body;
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
const tag = await addTag(
projectId,
environmentName,
configName,
selector,
name
);
return tag;
});
export const update = λ(async (event) => {
const {
id: projectId,
environmentName,
configName,
tagName,
} = event.pathParameters;
const { selector } = event.body;
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
const project = await moveTag(
projectId,
environmentName,
configName,
tagName,
selector
);
return project;
});
export const remove = λ(async (event) => {
const {
id: projectId,
environmentName,
configName,
tagName,
} = event.pathParameters;
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
return removeTag(projectId, environmentName, configName, tagName);
});
================================================
FILE: api/src/handlers/tokens.js
================================================
import λ from "./utils/λ";
import {
checkAuthorizationOr403,
parseAuthorizationToken,
} from "./utils/authorization";
import getTokens from "../domain/tokens/get";
import addToken from "../domain/tokens/add";
import removeToken from "../domain/tokens/remove";
export const get = λ(async (event) => {
const { id: projectId } = event.pathParameters || {};
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"read"
);
const all =
event.queryStringParameters &&
Object.keys(event.queryStringParameters).includes("all");
return getTokens(projectId, all);
});
export const create = λ(async (event) => {
const { id: projectId } = event.pathParameters;
const { name, level, expiresInDays } = event.body;
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
return addToken(projectId, name, level, expiresInDays);
});
export const remove = λ(async (event) => {
const { id: projectId, tokenId } = event.pathParameters;
await checkAuthorizationOr403(
parseAuthorizationToken(event),
projectId,
"write"
);
return removeToken(tokenId);
});
================================================
FILE: api/src/handlers/utils/authorization.js
================================================
import { checkPermission } from "../../domain/permissions";
import { HttpError } from "./errors";
export const parseAuthorizationToken = (event) => {
const { Authorization: authorizationHeader } = event.headers || {};
if (!authorizationHeader) {
throw new HttpError(401, "Authorization header should be set.");
}
const [type, token] = authorizationHeader.split(" ");
if (type !== "Token" || !token) {
throw new HttpError(403, "Authorization header format is invalid.");
}
return token;
};
export const checkAuthorizationOr403 = async (token, projectId, level) => {
try {
await checkPermission(projectId, token, level);
} catch (e) {
throw new HttpError(403, e.message);
}
};
================================================
FILE: api/src/handlers/utils/errors.js
================================================
import config from "../../config";
import { NotFoundError, ValidationError } from "../../domain/errors";
export class HttpError extends Error {
constructor(statusCode = 500, message = "An error occured", details = null) {
super(message);
this.message = message;
this.details = details;
this.statusCode = statusCode;
this.name = this.constructor.name;
if (typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, this.constructor);
} else {
this.stack = new Error(message).stack;
}
this.stack = new Error().stack;
}
}
export const convertErrorToHttpError = (error) => {
if (error instanceof HttpError) {
return error;
}
if (error instanceof NotFoundError) {
return new HttpError(404, error.message, error.details);
}
if (error instanceof ValidationError) {
return new HttpError(400, error.message, error.details);
}
return new HttpError(
500,
config.logs.debug ? error.message : "An error occured",
config.logs.debug ? error.details : "Please contact an administrator"
);
};
================================================
FILE: api/src/handlers/utils/λ.js
================================================
import co from "co";
import logger from "../../logger";
import config from "../../config";
import { convertErrorToHttpError } from "./errors";
export default (handler) => {
if (!!process.env.SERVERLESS) {
return (event, context) => {
co(function* () {
const body = yield handler({
...event,
body: event.body ? JSON.parse(event.body) : null,
});
context.succeed({
statusCode: 200,
body: JSON.stringify(body),
});
}).catch((error) => {
logger.error("ERROR", error.message);
logger.error("ERR. STACK", error.stack);
const httpError = convertErrorToHttpError(error);
context.succeed({
statusCode: httpError.statusCode || 500,
body: JSON.stringify({
error: config.logs.debug ? error : null,
message: httpError.message,
details: httpError.details,
}),
});
});
};
}
return async (event) => handler(event);
};
================================================
FILE: api/src/index.js
================================================
import express from "express";
import bodyParser from "body-parser";
import config from "./config";
import logger from "./logger";
import { convertErrorToHttpError } from "./handlers/utils/errors";
import {
create as createProject,
update as updateProject,
remove as removeProject,
} from "./handlers/projects";
import {
get as getEnvironments,
create as createEnvironment,
update as updateEnvironment,
remove as removeEnvironment,
} from "./handlers/environments";
import {
history as getConfigurationHistory,
get as getConfiguration,
create as createConfiguration,
remove as removeConfiguration,
} from "./handlers/configurations";
import {
create as createTag,
update as updateTag,
remove as removeTag,
} from "./handlers/tags";
const app = express();
const handlerToMiddleware = (handler) => async (req, res) => {
const event = {
pathParameters: req.params,
body: req.body,
headers: Object.assign({}, req.headers, {
Authorization: req.headers.authorization,
}),
};
try {
const body = await handler(event);
res.send(body);
} catch (error) {
logger.error("ERROR", error.message);
logger.error("ERR. STACK", error.stack);
const httpError = convertErrorToHttpError(error);
res.status(httpError.statusCode || 500).send({
error: config.logs.debug ? error : null,
message: httpError.message,
details: httpError.details,
});
}
};
app.use(bodyParser.json());
app.post("/projects", handlerToMiddleware(createProject));
app.put("/projects/:id", handlerToMiddleware(updateProject));
app.delete("/projects/:id", handlerToMiddleware(removeProject));
app.get("/projects/:id/environments", handlerToMiddleware(getEnvironments));
app.post("/projects/:id/environments", handlerToMiddleware(createEnvironment));
app.put(
"/projects/:id/environments/:environmentName",
handlerToMiddleware(updateEnvironment)
);
app.delete(
"/projects/:id/environments/:environmentName",
handlerToMiddleware(removeEnvironment)
);
app.get(
"/projects/:id/environments/:environmentName/configurations/history",
handlerToMiddleware(getConfigurationHistory)
);
app.get(
"/projects/:id/environments/:environmentName/configurations/:configName/history",
handlerToMiddleware(getConfigurationHistory)
);
app.get(
"/projects/:id/environments/:environmentName/configurations/:configName",
handlerToMiddleware(getConfiguration)
);
app.get(
"/projects/:id/environments/:environmentName/configurations/:configName/:tagName",
handlerToMiddleware(getConfiguration)
);
app.post(
"/projects/:id/environments/:environmentName/configurations/:configName/:tagName",
handlerToMiddleware(createConfiguration)
);
app.delete(
"/projects/:id/environments/:environmentName/configurations/:configName",
handlerToMiddleware(removeConfiguration)
);
app.post(
"/projects/:id/environments/:environmentName/configurations/:configName/tags",
handlerToMiddleware(createTag)
);
app.put(
"/projects/:id/environments/:environmentName/configurations/:configName/tags/:tagName",
handlerToMiddleware(updateTag)
);
app.delete(
"/projects/:id/environments/:environmentName/configurations/:configName/tags/:tagName",
handlerToMiddleware(removeTag)
);
export default app;
================================================
FILE: api/src/launcher.js
================================================
import app from "./index";
import config from "./config";
app.listen(config.port, () =>
console.log(`Comfygure API listening on port ${config.port}.`)
);
================================================
FILE: api/src/logger.js
================================================
/* eslint-disable no-console */
export default {
log: console.log,
info: console.info,
error: console.error,
};
================================================
FILE: api/src/mocks.js
================================================
import mockQueries from "./queries/mocks";
import mockDomain from "./domain/mocks";
const restore = () => {
mockQueries.restore();
mockDomain.restore();
};
export default {
queries: mockQueries,
domain: mockDomain,
restore,
};
================================================
FILE: api/src/queries/__mocks__/configurations.js
================================================
const insertOne = jest.fn((entity) => Promise.resolve({ id: 1, ...entity }));
const findOne = jest.fn(() => Promise.resolve({}));
export default {
insertOne,
findOne,
};
================================================
FILE: api/src/queries/__mocks__/environments.js
================================================
const insertOne = jest.fn((entity) => Promise.resolve({ id: 1, ...entity }));
const updateOne = jest.fn((entity) => Promise.resolve(entity));
const findOne = jest.fn((id) => Promise.resolve({ id }));
const selectByProject = jest.fn(() => Promise.resolve([]));
export default {
insertOne,
updateOne,
findOne,
selectByProject,
};
================================================
FILE: api/src/queries/__mocks__/projects.js
================================================
const findOne = jest.fn((id) => Promise.resolve({ id }));
export default {
findOne,
};
================================================
FILE: api/src/queries/__mocks__/versions.js
================================================
const insertOne = jest.fn((entity) => Promise.resolve({ id: 1, ...entity }));
const find = jest.fn(() => Promise.resolve([]));
const findOneByHash = jest.fn(() => Promise.resolve({}));
const findOneByTag = jest.fn(() => Promise.resolve({}));
export default {
insertOne,
find,
findOneByHash,
findOneByTag,
};
================================================
FILE: api/src/queries/configurations.js
================================================
import omit from "lodash.omit";
import client, {
insertOne as insertOneQuery,
updateOne as updateOneQuery,
} from "./knex";
const table = "configuration";
const fields = [
"configuration.id",
"configuration.name",
"configuration.state",
"configuration.environment_id",
"configuration.default_format",
"configuration.created_at",
"configuration.updated_at",
"environment.name as environment_name",
"environment.project_id as project_id",
];
const findOne = async (projectId, environmentName, configurationName) =>
client
.select(fields)
.from(table)
.innerJoin("environment", "environment.id", "configuration.environment_id")
.where({
project_id: projectId,
"environment.name": environmentName,
"configuration.name": configurationName,
"configuration.state": "live",
})
.first();
const findAllByEnvironmentName = async (projectId, environmentName) =>
client
.select(fields)
.from(table)
.innerJoin("environment", "environment.id", "configuration.environment_id")
.where({
project_id: projectId,
"environment.name": environmentName,
"configuration.state": "live",
});
const insertOne = async (data) => {
const { id } = await insertOneQuery(table, ["id"])(data);
return client
.select(fields)
.from(table)
.innerJoin("environment", "environment.id", "configuration.environment_id")
.where({
"configuration.id": id,
})
.first();
};
const updateOne = async (id, data) => {
await updateOneQuery(table, ["id"])(
id,
omit(data, ["project_id", "environment_name"])
);
return client
.select(fields)
.from(table)
.innerJoin("environment", "environment.id", "configuration.environment_id")
.where({
"configuration.id": id,
})
.first();
};
export default {
findOne,
findAllByEnvironmentName,
insertOne,
updateOne,
};
================================================
FILE: api/src/queries/entries.js
================================================
import client, { insertOne } from "./knex";
const table = "entry";
const fields = ["version_id", "key", "value"];
const findByVersion = async (version_id) =>
client.select(fields).from(table).where({ version_id });
export default {
insertOne: insertOne(table, fields),
findByVersion,
};
================================================
FILE: api/src/queries/environments.js
================================================
import client, { insertOne, updateOne } from "./knex";
const table = "environment";
const fields = ["id", "name"];
const findOne = async (projectId, environmentName) =>
client
.select(fields)
.from(table)
.where({
project_id: projectId,
state: "live",
name: environmentName,
})
.first();
const selectByProject = async (projectId) => {
const environments = await client.select(fields).from(table).where({
project_id: projectId,
state: "live",
});
return environments;
};
export default {
insertOne: insertOne(table, fields),
updateOne: updateOne(table, fields),
findOne,
selectByProject,
};
================================================
FILE: api/src/queries/knex.js
================================================
import knex from "knex";
import config from "../config";
const client = knex({
client: "pg",
connection: config.db.client,
pool: config.db.pooling,
debug: false // Toggle this variable to log SQL queries
});
export const findOne = (table, fields, primaryKey = "id") => async identifier =>
client
.select(fields)
.from(table)
.where({ [primaryKey]: identifier })
.first();
export const insertOne = (table, fields) => async row => {
const results = await client(table)
.insert(row)
.returning(fields);
return results[0]; // Cannot chain .first() on "insert" query
};
export const updateOne = (table, fields, primaryKey = "id") => async (
identifier,
row
) => {
const results = await client(table)
.where({ [primaryKey]: identifier })
.update(row)
.returning(fields);
return results[0]; // Cannot chain .first() on "update" query
};
export default client;
================================================
FILE: api/src/queries/projects.js
================================================
import { findOne, insertOne, updateOne } from "./knex";
const table = "project";
const fields = ["id", "name", "access_key as accessKey"];
export default {
findOne: findOne(table, fields),
insertOne: insertOne(table, fields),
updateOne: updateOne(table, fields)
};
================================================
FILE: api/src/queries/tags.js
================================================
import client, { insertOne } from "./knex";
const table = "tag";
const fields = ["configuration_id", "version_id", "name"];
const updateOne = async (tag, { version_id: newVersionId }) => {
const results = await client(table)
.where(tag)
.update({ version_id: newVersionId })
.returning(fields);
return results[0]; // Cannot chain .first() on "update" query
};
const removeOne = async (tag) => {
const results = await client(table).where(tag).del().returning(fields);
return results[0]; // Cannot chain .first() on "del" query
};
const batchInsert = async (tags) =>
client(table).insert(tags).returning(fields);
const findOne = async (configurationId, tagName) =>
client
.select(fields)
.from(table)
.where({ configuration_id: configurationId, name: tagName })
.first();
export default {
updateOne,
insertOne: insertOne(table, fields),
removeOne,
batchInsert,
findOne,
};
================================================
FILE: api/src/queries/tokens.js
================================================
import client, { insertOne, updateOne } from "./knex";
import { LIVE } from "../domain/common/states";
const table = "token";
// token.key should not appear in this default list
const fields = [
"id",
"project_id",
"name",
"level",
"expiry_date",
"created_at",
"updated_at",
];
const findByProjectId = async (project_id, all = false) => {
return client
.select(fields)
.from(table)
.where({
project_id,
state: LIVE,
})
.andWhere(function () {
if (all) {
return this;
}
return this.whereRaw("expiry_date > NOW()").orWhere({
expiry_date: null,
});
})
.orderBy("created_at");
};
const findValidTokenByKey = async (project_id, key) =>
client
.select(fields)
.from(table)
.where({
key,
project_id,
state: LIVE,
})
.andWhere(function () {
return this.whereRaw("expiry_date > NOW()").orWhere({
expiry_date: null,
});
})
.first();
export default {
findByProjectId,
findValidTokenByKey,
insertOne: insertOne(table, [...fields, "key"]),
updateOne: updateOne(table, fields),
};
================================================
FILE: api/src/queries/versions.js
================================================
import { raw } from "knex";
import client, { insertOne } from "./knex";
const table = "version";
const fields = ["id", "configuration_id", "hash", "previous", "created_at"];
const prefix = (pre) => (str) => `${pre}.${str}`;
const selectVersions = async (whereConditions, single = true) => {
const versions = await client
.select([
...fields.map(prefix(table)),
raw(
"case when count(tag.name) = 0 then '[]' else json_agg(tag.name) end as tags"
),
])
.leftJoin("tag", "tag.version_id", "version.id")
.from(table)
.where(whereConditions)
.groupBy(fields.map(prefix(table)));
if (single) {
return versions[0];
}
return versions;
};
const findOne = async (id) => selectVersions({ "version.id": id });
const find = async (configurationId) =>
selectVersions(
{
"version.configuration_id": configurationId,
},
false
);
const findOneByHash = async (configurationId, hash) =>
selectVersions({
"version.configuration_id": configurationId,
"version.hash": hash,
});
const findOneByTag = async (configurationId, tagId) =>
selectVersions({
"version.configuration_id": configurationId,
"tag.id": tagId,
});
export default {
findOne,
insertOne: insertOne(table, fields),
find,
findOneByHash,
findOneByTag,
};
================================================
FILE: api/var/schema.sql
================================================
--
-- PostgreSQL database dump
--
-- Dumped from database version 9.6.17
-- Dumped by pg_dump version 11.7 (Ubuntu 11.7-0ubuntu0.19.10.1)
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
--
-- Name: format; Type: TYPE; Schema: public; Owner: -
--
CREATE TYPE public.format AS ENUM (
'json',
'yaml',
'envvars'
);
--
-- Name: state; Type: TYPE; Schema: public; Owner: -
--
CREATE TYPE public.state AS ENUM (
'live',
'archived'
);
--
-- Name: token_level; Type: TYPE; Schema: public; Owner: -
--
CREATE TYPE public.token_level AS ENUM (
'read',
'write'
);
SET default_tablespace = '';
SET default_with_oids = false;
--
-- Name: configuration; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.configuration (
id uuid DEFAULT public.gen_random_uuid() NOT NULL,
environment_id uuid NOT NULL,
name text NOT NULL,
state public.state DEFAULT 'live'::public.state NOT NULL,
default_format public.format,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: entry; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.entry (
version_id uuid NOT NULL,
key text NOT NULL,
value text
);
--
-- Name: environment; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.environment (
id uuid DEFAULT public.gen_random_uuid() NOT NULL,
project_id uuid NOT NULL,
name text NOT NULL,
state public.state DEFAULT 'live'::public.state NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: project; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.project (
id uuid DEFAULT public.gen_random_uuid() NOT NULL,
name text NOT NULL,
state public.state DEFAULT 'live'::public.state NOT NULL,
access_key character varying(20) NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: tag; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.tag (
configuration_id uuid NOT NULL,
version_id uuid NOT NULL,
name text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: token; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.token (
id uuid DEFAULT public.gen_random_uuid() NOT NULL,
project_id uuid NOT NULL,
name text NOT NULL,
level public.token_level NOT NULL,
key character varying(40) NOT NULL,
state public.state DEFAULT 'live'::public.state NOT NULL,
expiry_date timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: version; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.version (
id uuid DEFAULT public.gen_random_uuid() NOT NULL,
configuration_id uuid NOT NULL,
hash character varying(64) NOT NULL,
previous character varying(64),
created_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: configuration configuration_environment_id_name_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.configuration
ADD CONSTRAINT configuration_environment_id_name_key UNIQUE (environment_id, name);
--
-- Name: configuration configuration_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.configuration
ADD CONSTRAINT configuration_pkey PRIMARY KEY (id);
--
-- Name: entry entry_version_id_key_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.entry
ADD CONSTRAINT entry_version_id_key_key UNIQUE (version_id, key);
--
-- Name: environment environment_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.environment
ADD CONSTRAINT environment_pkey PRIMARY KEY (id);
--
-- Name: environment environment_project_id_name_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.environment
ADD CONSTRAINT environment_project_id_name_key UNIQUE (project_id, name);
--
-- Name: project project_access_key_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.project
ADD CONSTRAINT project_access_key_key UNIQUE (access_key);
--
-- Name: project project_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.project
ADD CONSTRAINT project_pkey PRIMARY KEY (id);
--
-- Name: tag tag_configuration_id_version_id_name_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.tag
ADD CONSTRAINT tag_configuration_id_version_id_name_key UNIQUE (configuration_id, version_id, name);
--
-- Name: token token_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.token
ADD CONSTRAINT token_pkey PRIMARY KEY (id);
--
-- Name: token token_project_id_key_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.token
ADD CONSTRAINT token_project_id_key_key UNIQUE (project_id, key);
--
-- Name: token token_project_id_name_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.token
ADD CONSTRAINT token_project_id_name_key UNIQUE (project_id, name);
--
-- Name: tag unique_tag; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.tag
ADD CONSTRAINT unique_tag UNIQUE (configuration_id, name);
--
-- Name: version version_configuration_id_hash_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.version
ADD CONSTRAINT version_configuration_id_hash_key UNIQUE (configuration_id, hash);
--
-- Name: version version_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.version
ADD CONSTRAINT version_pkey PRIMARY KEY (id);
--
-- Name: configuration configuration_environment_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.configuration
ADD CONSTRAINT configuration_environment_id_fkey FOREIGN KEY (environment_id) REFERENCES public.environment(id) ON DELETE CASCADE;
--
-- Name: entry entry_version_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.entry
ADD CONSTRAINT entry_version_id_fkey FOREIGN KEY (version_id) REFERENCES public.version(id) ON DELETE CASCADE;
--
-- Name: environment environment_project_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.environment
ADD CONSTRAINT environment_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.project(id) ON DELETE CASCADE;
--
-- Name: tag tag_configuration_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.tag
ADD CONSTRAINT tag_configuration_id_fkey FOREIGN KEY (configuration_id) REFERENCES public.configuration(id) ON DELETE CASCADE;
--
-- Name: tag tag_version_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.tag
ADD CONSTRAINT tag_version_id_fkey FOREIGN KEY (version_id) REFERENCES public.version(id) ON DELETE CASCADE;
--
-- Name: token token_project_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.token
ADD CONSTRAINT token_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.project(id) ON DELETE CASCADE;
--
-- Name: version version_configuration_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.version
ADD CONSTRAINT version_configuration_id_fkey FOREIGN KEY (configuration_id) REFERENCES public.configuration(id) ON DELETE CASCADE;
--
-- Name: version version_configuration_id_fkey1; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.version
ADD CONSTRAINT version_configuration_id_fkey1 FOREIGN KEY (configuration_id, previous) REFERENCES public.version(configuration_id, hash);
--
-- PostgreSQL database dump complete
--
================================================
FILE: api/webpack.config.babel.js
================================================
const path = require("path");
const webpack = require("webpack");
const slsw = require("serverless-webpack");
module.exports = {
mode: slsw.lib.webpack.isLocal ? "development" : "production",
target: "node",
entry: slsw.lib.entries,
output: {
libraryTarget: "commonjs2",
path: path.resolve(__dirname, ".webpack"),
filename: "[name].js"
},
module: {
rules: [
{
test: /\.js$/,
use: "babel-loader",
exclude: /node_modules/
}
],
noParse: [
/pg\/lib\/native/,
/knex\/lib\/dialects\/(mssql|mysql|mysql2|sqlite3|oracledb)/
]
},
externals: ["aws-sdk", "pg-query-stream"],
plugins: [
new webpack.DefinePlugin({
"process.env.SERVERLESS": JSON.stringify(true)
})
],
optimization: {
minimize: false
},
performance: {
hints: false
}
};
================================================
FILE: cli/.eslintrc
================================================
{
"env": {
"jest": true,
"node": true,
"es6": true
},
"plugins": ["prettier"],
"extends": ["eslint:recommended", "prettier"],
"parserOptions": {
"ecmaVersion": 2017
},
"rules": {
"prettier/prettier": [
"error",
{
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "es5",
"printWidth": 120
}
],
"no-unused-vars": [
"error",
{
"ignoreRestSiblings": true
}
]
}
}
================================================
FILE: cli/.npmignore
================================================
Makefile
*.spec.js
================================================
FILE: cli/Makefile
================================================
install:
npm install
test-unit:
./node_modules/.bin/jest
test-unit-watch:
./node_modules/.bin/jest --watch
test: test-unit
================================================
FILE: cli/README.md
================================================
[](https://badge.fury.io/js/comfygure)   [](http://npmjs.com/comfygure) [](https://hub.docker.com/r/marmelab/comfygure) [](https://travis-ci.org/marmelab/comfygure)
# comfygure
Encrypted and versioned configuration storage built with collaboration in mind.
[Source](https://github.com/marmelab/comfygure) - [Releases](https://github.com/marmelab/comfygure/releases) - [Stack Overflow](https://stackoverflow.com/questions/tagged/comfy/)
[](https://asciinema.org/a/137703)
## Features
- Simple CLI
- End-to-end AES-256 encryption
- Multiple formats support (JSON, YAML, environment variables)
- Git-like Versioning
- Easy to host on your own
Comfygure is great to manage application configurations for multiple environments, toggle feature flags quickly, manage A/B testing based on configuration files.
It is not a [Secret Management Tool](https://gist.github.com/maxvt/bb49a6c7243163b8120625fc8ae3f3cd), it focus on configurations files, their history, and how teams collaborate with them.
## Get Started
On every server that needs access to the settings of an app, install the `comfy` CLI using `npm`:
```bash
npm install -g comfygure
comfy help
```
## Usage
Initialize comfygure in a project directory with `comfy init`:
```bash
> cd myproject
> comfy init
Initializing project configuration...
Project created on comfy server https://comfy.marmelab.com
Configuration saved locally in .comfy/config
comfy project successfully created
```
This creates a unique key to access the settings for `myproject`, and stores the key in `.comfy/config`. You can copy this file to share the credentials with co-workers or other computers.
**Note**: By default, the `comfy` command stores encrypted settings in the `comfy.marmelab.com` server. To host your own comfy server, see [the related documentation](https://marmelab.com/comfygure/HostYourOwn.html#host-your-own-comfy-server).
Import an existing settings file to comfygure using `comfy setall`:
```bash
> echo '{"login": "admin", "password": "S3cr3T"}' > config.json
> comfy setall development config.json
Great! Your configuration was successfully saved.
```
From any computer sharing the same credentials, grab these settings using `comfy get`:
```bash
> comfy get development
{"login": "admin", "password": "S3cr3T"}
> comfy get development --envvars
export LOGIN='admin';
export PASSWORD='S3cr3T';
```
To turn settings grabbed from comfygure into environment variables, use the following:
```bash
> comfy get development --envvars | source /dev/stdin
> echo $LOGIN
admin
```
See the [documentation](https://marmelab.com/comfygure/) to know more about how it works and the remote usage.
## License
Comfygure is licensed under the [MIT License](https://github.com/marmelab/comfygure/blob/master/LICENSE), sponsored and supported by [marmelab](http://marmelab.com).
================================================
FILE: cli/bin/comfy.js
================================================
#!/usr/bin/env node
const nodeVersion = require('node-version');
if (nodeVersion.major < 6) {
console.error('Comfygure requires at least version 6 of Node. Please upgrade!');
process.exit(1);
}
const co = require('co');
const ui = require('../src/ui/console');
const comfy = require('../src')(ui, process.argv);
// Dunno why I need to use co but I have to. I'll see that later
co(comfy).catch(error => console.error(error));
================================================
FILE: cli/package.json
================================================
{
"name": "comfygure",
"version": "1.2.0",
"description": "Encrypted and versioned configuration store built with collaboration in mind",
"keywords": [
"configuration",
"deployment",
"node",
"bash",
"continuous"
],
"engines": {
"node": ">=8"
},
"bin": {
"comfy": "bin/comfy.js"
},
"scripts": {
"test": "make test"
},
"repository": {
"type": "git",
"url": "git+https://github.com/marmelab/comfygure.git"
},
"author": "Marmelab <info@marmelab.com> (https://marmelab.com/fr/)",
"contributors": [
"Kévin Maschtaler <kevin@marmelab.com>"
],
"license": "MIT",
"bugs": {
"url": "https://github.com/marmelab/comfygure/issues"
},
"homepage": "https://marmelab.com/comfygure",
"dependencies": {
"chalk": "^3.0.0",
"cli-table": "^0.3.1",
"co": "^4.6.0",
"humanize-duration": "^3.22.0",
"ini": "^1.3.5",
"js-yaml": "^3.13.1",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"minimist": "^1.2.5",
"node-version": "^2.0.0",
"request": "^2.88.2",
"traverse": "^0.6.6"
},
"devDependencies": {
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.1",
"eslint-plugin-prettier": "^3.1.2",
"jest": "^25.2.7",
"prettier": "^1.19.1"
}
}
================================================
FILE: cli/src/client.js
================================================
module.exports = request => {
const defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
const parseResponse = callback => (err, response) => {
if (err) {
callback(err);
return;
}
if (response.statusCode < 200 || response.statusCode >= 300) {
const error = new Error(`The API call returned a ${response.statusCode} HTTP error code`);
error.code = response.statusCode;
error.body = response.body && JSON.parse(response.body);
callback(error);
return;
}
callback(null, JSON.parse(response.body));
};
const get = (url, headers = {}) => cb =>
request(url, { headers: Object.assign({}, defaultHeaders, headers) }, parseResponse(cb));
const post = (url, body, headers = {}) => cb => {
const options = {
method: 'POST',
headers: Object.assign({}, defaultHeaders, headers),
body: JSON.stringify(body),
};
return request(url, options, parseResponse(cb));
};
const put = (url, body, headers = {}) => cb => {
const options = {
method: 'PUT',
headers: Object.assign({}, defaultHeaders, headers),
body: JSON.stringify(body),
};
return request(url, options, parseResponse(cb));
};
const remove = (url, headers = {}) => cb => {
const options = {
method: 'DELETE',
headers: Object.assign({}, defaultHeaders, headers),
};
return request(url, options, parseResponse(cb));
};
const buildAuthorization = project => ({ Authorization: `Token ${project.secretToken}` });
return {
get,
post,
put,
delete: remove,
buildAuthorization,
};
};
================================================
FILE: cli/src/commands/admin.js
================================================
const exec = require('child_process').exec;
const minimist = require('minimist');
const moduleAvailable = name => {
try {
require.resolve(name);
return true;
} catch (e) {} // eslint-disable-line no-empty
return false;
};
const runCommand = cmd =>
new Promise((resolve, reject) => {
exec(cmd, error => {
if (error) {
reject(error);
return;
}
resolve();
});
});
const help = ui => {
const { bold, cyan } = ui.colors;
ui.print(`
${bold('NAME')}
comfy admin - Run the comfy admin web application
${bold('SYNOPSIS')}
${bold('comfy')} admin [<options>]
${bold('OPTIONS')}
-p, --port The port used to serve the admin (defaults to 3000)
-h, --help Show this very help message
${bold('EXAMPLE')}
${cyan('comfy admin -p 8080')}
`);
};
module.exports = ui =>
function* admin(rawOptions) {
const options = minimist(rawOptions);
const port = options.p || options.port || 3000;
if (options.help || options.h || options._.includes('help')) {
help(ui);
return ui.exit(0);
}
if (moduleAvailable('comfy-admin')) {
yield runCommand(`comfy-admin -p ${port}`);
return;
}
ui.print('You need to install comfy-admin: npm install -g comfy-admin');
const response = yield ui.input.text('Do you want us to install it for you ? y/n');
if (response.toLowerCase() !== 'y') {
return;
}
try {
yield runCommand('npm install -g comfy');
} catch (error) {
if (error.message.match('Please try running this command again as root/Administrator.')) {
ui.print(`
Uh oh, it looks like npm need administrator rights to install package globally on your machine.
Either run the command with sudo and trust us, or look on internet to see how to configure your
environment so that sudo is no longer required to install global packages (which is a lot better).
`);
return;
}
throw error;
}
yield runCommand(`comfy-admin -p ${port}`);
};
================================================
FILE: cli/src/commands/diff.js
================================================
const fs = require('fs');
const minimist = require('minimist');
const { exec } = require('child_process');
const help = ui => {
const { bold, cyan, dim } = ui.colors;
ui.print(`
${bold('NAME')}
comfy diff - Show diff of two configurations for a given environment
${bold('SYNOPSIS')}
${bold('comfy')} diff <environment> <hash|tag> [<hash|tag>]
${bold('OPTIONS')}
<environment> Name of the environment (must already exist in project)
<hash|tag> Hash or tag of your configuration to diff it
--json Output the configurations as a JSON file
--envvars Output the configurations as a sourceable bash file
--yml Output the configurations as a YAML file
--js Output the configurations as a JavaScript script
-w Ignore all whitespaces
${bold('EXAMPLES')}
${dim('# Diff from the latest version to the `stable` one')}
${cyan('comfy diff development stable')}
${dim('# Diff from one version to another')}
${cyan('comfy diff development stable latest')}
`);
};
module.exports = (ui, modules) =>
function* diff(rawOptions) {
const { red, bold, green } = ui.colors;
const options = minimist(rawOptions);
const [env, ...hashes] = options._;
const { w } = options;
if (options.help || options.h || options._.includes('help')) {
help(ui);
return ui.exit(0);
}
if (!env || hashes.length === 0 || hashes.length > 2) {
ui.error(red('Not enough or too much arguments.'));
ui.print(`${bold('SYNOPSIS')}
${bold('comfy')} diff <environment> <hash|tag> [<hash|tag>]
Type ${green('comfy diff --help')} for details`);
return ui.exit(0);
}
const project = yield modules.project.retrieveFromConfig();
const firstConfigOutput = yield modules.config.getAndFormat(project, env, hashes[0], undefined, options);
const secondConfigOutput = yield modules.config.getAndFormat(
project,
env,
hashes[1] || 'latest',
undefined,
options
);
const firstConfigName = `/tmp/comfy-${hashes[0]}`;
const secondConfigName = `/tmp/comfy-${hashes[1] || 'latest'}`;
yield cb => fs.writeFile(firstConfigName, firstConfigOutput + '\n', { flag: 'w' }, cb);
yield cb => fs.writeFile(secondConfigName, secondConfigOutput + '\n', { flag: 'w' }, cb);
const code = yield cb =>
exec(`diff ${firstConfigName} ${secondConfigName} -u ${w ? ' -w' : ''}`, (error, stdout, stderr) => {
if (error && error.code && error.code !== 1) {
ui.error(red('Failed to use the `diff` util. Is it installed on your host?'));
return cb(null, 1);
}
stderr && ui.error(stderr);
stdout && ui.print(stdout);
cb(null, 0);
});
yield cb => fs.unlink(firstConfigName, cb);
yield cb => fs.unlink(secondConfigName, cb);
ui.exit(code);
};
================================================
FILE: cli/src/commands/env.js
================================================
const help = ui => {
const { bold, cyan } = ui.colors;
ui.print(`
${bold('NAME')}
comfy env - Manage configuration environments
${bold('SYNOPSIS')}
${bold('comfy')} env <command> [<options>]
${bold('COMMANDS')}
list List all environments
add <environment> Create the environment <environment>
${bold('OPTIONS')}
<environment> Name of the environment
-h, --help Show this very help message
${bold('EXAMPLES')}
${cyan('comfy env ls')}
${cyan('comfy env add production')}
`);
};
const list = (ui, modules) =>
function*() {
const project = yield modules.project.retrieveFromConfig();
const environments = yield modules.environment.list(project);
for (const environment of environments) {
ui.print(environment.name);
}
ui.exit();
};
const add = (ui, modules, options) =>
function*() {
const { red, bold, green } = ui.colors;
if (!options.length) {
ui.error(`${red('No environment specified.')}`);
}
if (options.length > 1) {
ui.error(`${red('Invalid environment format. The environment name should be one word.')}`);
}
if (options.length !== 1) {
ui.print(`${bold('SYNOPSIS')}
${bold('comfy')} env add <environment>
Type ${green('comfy env --help')} for details`);
return ui.exit(0);
}
const project = yield modules.project.retrieveFromConfig();
const environment = yield modules.environment.add(project, options[0]);
const addCommand = `comfy setall ${environment.name}`;
ui.print(`${bold(green('Environment successfully created'))}`);
ui.print(`You can now set a configuration for this environment using ${bold(addCommand)}`);
ui.exit();
};
module.exports = (ui, modules) =>
function*([command, ...options]) {
switch (command) {
case 'list':
case 'ls':
yield list(ui, modules);
break;
case 'add':
yield add(ui, modules, options);
break;
default:
help(ui);
}
};
================================================
FILE: cli/src/commands/get.js
================================================
const minimist = require('minimist');
const help = ui => {
const { bold, cyan, dim } = ui.colors;
ui.print(`
${bold('NAME')}
comfy get - Retrieve the configuration for a given environment
${bold('SYNOPSIS')}
${bold('comfy')} get <environment> [<selector>] [<options>]
${bold('OPTIONS')}
<environment> Name of the environment (must already exist in project)
<selector> Get only a subset of the config (dot separated)
--json Output the configuration as a JSON file
--envvars Output the configuration as a sourceable bash file
--yml Output the configuration as a YAML file
--js Output the configuration as a JavaScript script
-t, --tag=<tag> Get a tag for this config version (default: stable)
--hash=<hash> Get a specific hash for this config version (ignore tag then)
-h, --help Show this very help message
${bold('EXAMPLES')}
${dim('# Get the development configuration as json')}
${cyan('comfy get development')}
${dim('# Get the staging configuration for the next tag in yaml')}
${cyan('comfy get staging -t next --yml > config/staging.yaml')}
${dim('# Get the staging configuration for a specific hash in yaml')}
${cyan('comfy get staging --hash=5eb9f3ea5cf01384333115007cf7606f --yml > config/staging.yaml')}
${dim('# Get the production configuration and set it as environment variables')}
${cyan('comfy get production --envvars | source /dev/stdin')}
${dim('# Get only a field of the config')}
${dim('config.json: { "admin": { "user": "Admin", "pass": "1234" } }')}
${cyan('comfy get production admin.user')} // Admin
`);
};
module.exports = (ui, modules) =>
function* get(rawOptions) {
const { bold, green, red } = ui.colors;
const options = minimist(rawOptions);
const [env, selector] = options._;
const tag = options.tag || options.t || 'latest';
const hash = options.hash;
if (options.help || options.h || options._.includes('help')) {
help(ui);
return ui.exit(0);
}
if (!env) {
ui.error(red('No environment specified.'));
ui.print(`${bold('SYNOPSIS')}
${bold('comfy')} get <environment> [<selector>] [<options>]
Type ${green('comfy get --help')} for details`);
return ui.exit(0);
}
if ([options.json, options.yml, options.js].filter(x => x).length > 1) {
ui.error(`${red('You need to chose either --json, --yml or --js')}`);
help(ui);
return ui.exit(1);
}
const project = yield modules.project.retrieveFromConfig();
const output = yield modules.config.getAndFormat(project, env, hash || tag, selector, options);
ui.print(output);
ui.exit();
};
================================================
FILE: cli/src/commands/help.js
================================================
module.exports = ui => () => {
const { bold, dim, gray, cyan } = ui.colors;
ui.print(`
${bold('NAME')}
comfy - Store and deploy settings across development, test, and production environments, using an encrypted key-value store.
${bold('SYNOPSIS')}
${bold('comfy')} <command> [<options>]
${bold('COMMANDS')}
help Show this very help message
init Initialize comfy for a directory
setall Add a new configuration version
set Replace a single entry in an existing configuration
get Retrieve a configuration
diff Diff two configuration versions
env Manage configuration environments
tag Manage configuration tags
log List all configuration versions
project Manage or deploy the current project
version Output CLI version information and exit
${bold('EXAMPLES')}
${dim('# Display the help')}
${cyan('comfy help')}
${dim('# Initialize comfy in the current directory')}
${cyan("comfy init --origin 'http://mycomfy.mydomain.com'")}
${dim('# Set a new configuration version')}
${cyan('comfy setall development config/api.json')}
${dim('# Add production environment')}
${cyan('comfy env add production')}
${dim('# Set a new configuration version for the next tag')}
${cyan('comfy setall production -t next config/api_prod.json')}
${dim('# List all configuration versions in production')}
${cyan('comfy log production')}
${dim('# Retrieve the latest development configuration and use it to set env vars')}
${cyan('comfy get development --envvars | source /dev/stdin')}
${dim('# Diff from your latest version to the stable one')}
${cyan('comfy diff development stable')}
${bold('ABOUT')}
${bold('comfy')} is licensed under the MIT Licence, sponsored and supported by marmelab.
${gray('-')} ${cyan('https://marmelab.com')}
`);
ui.exit(0);
};
================================================
FILE: cli/src/commands/init.js
================================================
const fs = require('fs');
const path = require('path');
const minimist = require('minimist');
const help = ui => {
const { bold, cyan } = ui.colors;
ui.print(`
${bold('NAME')}
comfy init - Initialize a comfy configuration for the current directory
${bold('SYNOPSIS')}
${bold('comfy')} init [<options>]
${bold('OPTIONS')}
--name=<name> The configuration name (defaults to the current directory name)
--env=<env> The first environment to create (defaults to 'development')
--origin=<origin> URL of the comfy server (defaults to https://comfy.marmelab.com)
-g, --nogitignore Do not add .comfy directory to .gitignore
-h, --help Show this very help message
${bold('EXAMPLES')}
${cyan('comfy init')}
${cyan("comfy init --name foo --env 'development' --origin 'http://mycomfy.mydomain.com'")}
`);
};
module.exports = (ui, modules) =>
function*(rawOptions) {
const { bold, dim, yellow, green } = ui.colors;
const options = minimist(rawOptions);
if (options.help || options.h || options._.includes('help')) {
help(ui);
return ui.exit(0);
}
const CONFIG_PATH = modules.project.CONFIG_PATH;
const checkAlreadyInitialized = fs.existsSync(`${process.cwd()}${path.sep}${CONFIG_PATH}`);
const isGitDirectory = fs.existsSync(`${process.cwd()}${path.sep}.git`);
const gitignore = `${process.cwd()}${path.sep}.gitignore`;
if (checkAlreadyInitialized) {
ui.error(
`${yellow('comfy is already initialized!')}` +
`\nYou can update your configuration by editing ${dim(CONFIG_PATH)}.`
);
return ui.exit(1);
}
const folders = process.cwd().split(path.sep);
const defaultProjectName = folders[folders.length - 1];
const projectName = options.name || defaultProjectName;
const environment = options.env || process.env.NODE_ENV || 'development';
const privateKey = modules.project.generateNewPrivateKey();
const hmacKey = modules.project.generateNewHmacKey();
ui.print('\nInitializing project configuration...');
const project = yield modules.project.create(projectName, environment, options.origin);
yield modules.project.saveToConfig(project, privateKey, hmacKey, options.origin);
const { origin } = yield modules.project.retrieveFromConfig();
if (isGitDirectory && !options.g) {
const gitignoreContent = fs.readFileSync(gitignore);
if (!gitignoreContent.includes(CONFIG_PATH)) {
fs.appendFileSync(gitignore, `${CONFIG_PATH}\n`);
}
}
ui.print(`Project created on comfy server ${dim(origin)}`);
ui.print(`Configuration saved locally in ${dim(CONFIG_PATH)}`);
ui.print(`${bold(green('comfy project successfully created'))}`);
ui.exit();
};
================================================
FILE: cli/src/commands/log.js
================================================
const minimist = require('minimist');
const help = ui => {
const { bold } = ui.colors;
ui.print(`
${bold('NAME')}
comfy log - List all configuration versions
${bold('SYNOPSIS')}
${bold('comfy')} log <environment> [<options>]
${bold('OPTIONS')}
<environment> Name of the environment (must already exist in project)
-t, --tags Show only tagged versions
-h, --help Show this very help message
`);
};
const formatDate = dateStr => {
const date = new Date(dateStr);
return date.toLocaleString();
};
module.exports = (ui, modules) =>
function*(rawOptions) {
const { bold, red, yellow, gray, green } = ui.colors;
const options = minimist(rawOptions);
const env = options._[0];
const onlyTags = options.t || options.tags;
if (options.help || options.h || options._.includes('help')) {
help(ui);
return ui.exit(0);
}
if (!env) {
ui.error(`${red('No environment specified.')}`);
ui.print(`${bold('SYNOPSIS')}
${bold('comfy')} log <environment> [<options>]
Type ${green('comfy log --help')} for details`);
return ui.exit(0);
}
const project = yield modules.project.retrieveFromConfig();
const configs = yield modules.config.list(project, env, 'default', !onlyTags);
const noTag = gray('no tag');
for (const config of configs) {
const tags = config.tags.length > 0 ? config.tags.map(tag => yellow(tag)).join(', ') : noTag;
ui.print(`${formatDate(config.created_at)}\t${env}\t${config.hash}\t${tags}`);
}
};
================================================
FILE: cli/src/commands/project.js
================================================
const minimist = require('minimist');
const help = ui => {
const { bold, cyan } = ui.colors;
ui.print(`
${bold('NAME')}
comfy project - Manage your comfy project
${bold('SYNOPSIS')}
${bold('comfy')} project <command>
${bold('COMMANDS')}
info Display available infos of the current project
deploy Show instructions to deploy your configurations on a server
delete Permanently delete the current project from the store
${bold('EXAMPLES')}
${cyan('comfy project info')}
${cyan('comfy project deploy')}
${cyan('comfy project delete')}
`);
};
const info = function*(ui, modules) {
const project = yield modules.project.retrieveFromConfig();
const folder = modules.project.getConfigFolder();
const path = modules.project.getConfigPath();
ui.table([
['Project ID', project.id],
['Origin', project.origin],
['Config. Folder', folder],
['Config. File', path],
['API Access Key', project.accessKey],
]);
};
const deploy = (ui, modules) => {
const { CREDENTIALS_VARIABLE, toEncodedCredentials } = modules.project;
const credentials = toEncodedCredentials();
const { dim } = ui.colors;
ui.print(
`Here are the instructions to install comfy on an remote server:
1. Install comfygure
2. Export the following environment variable
3. Retrieve your config in the format of your choice
${dim('npm install -g comfygure')}
${dim(`export ${CREDENTIALS_VARIABLE}=${credentials}`)}
${dim('comfy get production --json')}
`
);
ui.exit(0);
};
const del = function*(ui, modules, rawOptions) {
const project = yield modules.project.retrieveFromConfig();
const options = minimist(rawOptions);
const { black, bgRedBright, cyan, bold, green } = ui.colors;
if (options.permanently === true && options.id === project.id) {
yield modules.project.permanentlyDelete();
ui.print(`${bold(green('comfy project successfully deleted'))}`);
ui.exit();
return;
}
const environments = yield modules.environment.list(project);
ui.print(`
${bgRedBright(black('DANGER ZONE: This action is irreversible!'))}
You are about to delete your comfy project.
This process will delete the following informations from the origin (${project.origin}):
All configurations and their precedent versions
All environments (${environments.map(env => env.name).join(', ')})
All access keys and access logs
All available informations about the project "${project.id}"
If you are sure, please type the following command:
${cyan('comfy project delete --permanently --id=<project-id>')}
`);
ui.exit();
};
module.exports = (ui, modules) =>
function*([command, ...options]) {
switch (command) {
case 'i':
case 'info':
yield info(ui, modules);
break;
case 'deploy':
deploy(ui, modules);
break;
case 'delete':
yield del(ui, modules, options);
break;
default:
help(ui);
}
};
================================================
FILE: cli/src/commands/set.js
================================================
const minimist = require('minimist');
const set = require('lodash.set');
const { parseFlat } = require('../format');
const help = ui => {
const { bold, cyan } = ui.colors;
ui.print(`
${bold('NAME')}
comfy set - Replace an entry of an existing configuration
${bold('SYNOPSIS')}
${bold('comfy')} set <environment> <selector> <value> [<options>]
${bold('OPTIONS')}
<environment> Name of the environment (must already exist in project)
<selector> Select the entry of the config (dot separated)
<value> The replacement value
-t, --tag=<tag> Set a tag for this config version (default: stable)
-h, --help Show this very help message
${bold('EXAMPLES')}
${cyan('comfy set development admin.user "SuperUser"')}
${cyan('comfy set development admin.pass "S3cret" -t next')}
`);
};
module.exports = (ui, modules) =>
function* setCommand(rawOptions) {
const { red, green, bold, dim } = ui.colors;
const options = minimist(rawOptions);
const env = options._[0];
const selector = options._[1];
const value = options._[2];
const tag = options.tag || options.t || 'latest';
if (options.help || options.h || options._.includes('help')) {
help(ui);
return ui.exit(0);
}
if (!env) {
ui.error(red('No environment specified.'));
}
if (!selector) {
ui.error(red('No selector specified.'));
}
if (!value) {
ui.error(red('No value specified.'));
}
if (!env || !selector || !value) {
ui.print(`${bold('SYNOPSIS')}
${bold('comfy')} set <environment> <selector> <value> [<options>]
Type ${dim('comfy set --help')} for details`);
return ui.exit(0);
}
const project = yield modules.project.retrieveFromConfig();
const config = yield modules.config.get(project, env, {
configName: 'default',
tag,
});
const sanitizedSelector = selector.toLowerCase();
const updatedConfig = set(parseFlat(config.body), sanitizedSelector, value);
yield modules.config.add(project, env, updatedConfig, {
tag,
configName: 'default',
format: config.defaultFormat,
});
ui.print(`${bold(green('comfy configuration successfully saved'))}`);
return ui.exit();
};
================================================
FILE: cli/src/commands/setall.js
================================================
const fs = require('fs');
const path = require('path');
const minimist = require('minimist');
const { parseYAML, guessFormat } = require('../format');
const help = ui => {
const { bold, cyan } = ui.colors;
ui.print(`
${bold('NAME')}
comfy setall - Replace the configuration for a given environment
${bold('SYNOPSIS')}
${bold('comfy')} setall <environment> <path> [<options>]
${bold('OPTIONS')}
<environment> Name of the environment (must already exist in project)
<path> Path to a configuration file (accepts json and yml formats)
-t, --tag=<tag> Set a tag for this config version (default: stable)
-h, --help Show this very help message
${bold('EXAMPLES')}
${cyan('comfy setall development config/comfy.json')}
${cyan('comfy setall production config/api.yml -t next')}
`);
};
module.exports = (ui, modules) =>
function* setall(rawOptions) {
const { red, green, bold } = ui.colors;
const options = minimist(rawOptions);
const env = options._[0];
const configPath = options._[1];
const tag = options.tag || options.t || 'latest';
if (options.help || options.h || options._.includes('help')) {
help(ui);
return ui.exit(0);
}
if (!env) {
ui.error(red('No environment specified.'));
}
if (!configPath) {
ui.error(red('No config file specified.'));
}
if (!env || !configPath) {
ui.print(`${bold('SYNOPSIS')}
${bold('comfy')} setall <environment> <path> [<options>]
Type ${green('comfy setall --help')} for details`);
return ui.exit(0);
}
const filename = configPath.startsWith(path.sep)
? path.normalize(configPath)
: path.normalize(`${process.cwd()}${path.sep}${configPath}`);
if (!fs.existsSync(filename)) {
ui.error(`The file ${red(configPath)} doesn't exist.`);
return ui.exit(1);
}
const stats = fs.statSync(filename);
if (!stats.isFile()) {
ui.error(`The object located at ${red(configPath)} is not a file.`);
return ui.exit(1);
}
const file = fs.readFileSync(filename, 'utf-8');
let parsedContent;
try {
parsedContent = parseYAML(file);
} catch (err) {
ui.error(red(`Failed to parse ${configPath}`));
}
const project = yield modules.project.retrieveFromConfig();
yield modules.config.add(project, env, parsedContent, {
tag,
configName: 'default',
format: guessFormat(path.extname(filename)),
});
ui.print(`${bold(green('comfy configuration successfully saved'))}`);
return ui.exit();
};
================================================
FILE: cli/src/commands/tag.js
================================================
const minimist = require("minimist");
const help = (ui) => {
const { bold, dim, cyan } = ui.colors;
ui.print(`
${bold("NAME")}
comfy tag - Manage configuration tags
${bold("SYNOPSIS")}
${bold("comfy")} tag <command> [<options>]
${bold("COMMANDS")}
add <environment> <tag> <hash> Add a new tag
list <environment> List tags
move <environment> <tag> <hash> Move a tag to a new version
delete <environment> <tag> Delete a tag
${bold("OPTIONS")}
<environment> Name of the environment (must already exist in project)
<tag> Name of the tag (e.g. "stable")
<hash> Name of the hash (e.g. "0b49fc8766d432fdd7422d948836d32f9632d72a")
-h, --help Show this very help message
${bold("EXAMPLES")}
${dim('# Add a new "experimental" tag for development configuration')}
${cyan(
"comfy tag add development experimental 517ac071cec80340c8fc08cdb7eeefefbaf1dbba"
)}
${dim("# List tags for development configuration")}
${cyan("comfy tag list development")}
${dim('# Move the "stable" tag to another hash')}
${cyan(
"comfy tag move development stable 964e51df37c0fe2a518998fb6457b461c4013d28"
)}
${dim('# Delete the "experimental" tag in production')}
${cyan("comfy tag delete production experimental")}
${bold("HINT")}
To list tags, you can type ${bold("comfy log <environment>")}
`);
};
const add = function*(ui, modules, options) {
const { green, red, bold } = ui.colors;
if (options._.length < 3) {
ui.error(red("Missing environment, tag, or hash"));
}
if (options._.length > 3) {
ui.error(red("Too many arguments"));
}
if (options._.length !== 3) {
ui.print(`${bold("SYNOPSIS")}
${bold("comfy")} tag add <environment> <tag> <hash>
Type ${green("comfy tag --help")} for details`);
return ui.exit(0);
}
const environment = options._[0];
const tag = options._[1];
const hash = options._[2];
const project = yield modules.project.retrieveFromConfig();
yield modules.tag.add(project, environment, "default", tag, hash);
ui.print(`${bold(green("Tag successfully created"))}`);
ui.exit();
};
const formatDate = (dateStr) => {
const date = new Date(dateStr);
return date.toLocaleString();
};
const list = function*(ui, modules, options) {
const { green, red, bold, yellow, gray } = ui.colors;
if (options._.length === 0) {
ui.error(red("Missing environment"));
}
if (options._.length > 1) {
ui.error(red("Too many arguments"));
}
if (options._.length !== 1) {
ui.print(`${bold("SYNOPSIS")}
${bold("comfy")} tag ls <environment>
Type ${green("comfy tag --help")} for details`);
return ui.exit(0);
}
const env = options._[0];
const project = yield modules.project.retrieveFromConfig();
const configs = yield modules.config.list(project, env, "default", false);
const noTag = gray("no tag");
for (const config of configs) {
const tags =
config.tags.length > 0
? config.tags.map((tag) => yellow(tag)).join(", ")
: noTag;
ui.print(
`${formatDate(config.created_at)}\t${env}\t${config.hash}\t${tags}`
);
}
ui.exit();
};
const move = function*(ui, modules, options) {
const { green, red, bold } = ui.colors;
if (options._.length < 3) {
ui.error(red("Missing environment, tag, or hash"));
}
if (options._.length > 3) {
ui.error(red("Too many arguments"));
}
if (options._.length !== 3) {
ui.print(`${bold("SYNOPSIS")}
${bold("comfy")} tag move <environment> <tag> <hash>
Type ${green("comfy tag --help")} for details`);
return ui.exit(0);
}
const environment = options._[0];
const tag = options._[1];
const hash = options._[2];
if (tag.toLowerCase() === "latest") {
ui.error(red("The tag `latest` cannot be moved"));
return ui.exit(1);
}
const project = yield modules.project.retrieveFromConfig();
yield modules.tag.move(project, environment, "default", tag, hash);
ui.print(`${bold(green("Tag successfully moved"))}`);
ui.exit();
};
const remove = function*(ui, modules, options) {
const { green, red, bold } = ui.colors;
if (options._.length < 2) {
ui.error(red("Missing environment, or tag"));
}
if (options._.length > 2) {
ui.error(red("Too many arguments"));
}
if (options._.length !== 2) {
ui.print(`${bold("SYNOPSIS")}
${bold("comfy")} tag delete <environment> <tag>
Type ${green("comfy tag --help")} for details`);
return ui.exit(0);
}
const environment = options._[0];
const tag = options._[1];
if (tag.toLowerCase() === "latest") {
ui.error(red("The tag `latest` cannot be deleted"));
return ui.exit(1);
}
const project = yield modules.project.retrieveFromConfig();
yield modules.tag.remove(project, environment, "default", tag);
ui.print(`${bold(green("Tag successfully deleted"))}`);
ui.exit();
};
module.exports = (ui, modules) =>
function*([command, ...rawOptions]) {
const options = minimist(rawOptions);
if (options.help || options.h || options._.includes("help")) {
help(ui);
return ui.exit(0);
}
switch (command) {
case "add":
case "create":
yield add(ui, modules, options);
break;
case "list":
case "ls":
yield list(ui, modules, options);
break;
case "move":
case "mv":
yield move(ui, modules, options);
break;
case "delete":
case "remove":
case "rm":
yield remove(ui, modules, options);
break;
default:
help(ui);
}
};
================================================
FILE: cli/src/commands/token.js
================================================
const minimist = require("minimist");
const humanize = require("humanize-duration");
const help = (ui) => {
const { bold, dim, cyan } = ui.colors;
ui.print(`
${bold("NAME")}
comfy token - Manage authentication tokens
${bold("SYNOPSIS")}
${bold("comfy")} token <command> [<options>]
${bold("COMMANDS")}
list [options] List authentication tokens
add <name> [options] Create a new token (default: read only, never expire)
delete <name> Delete a token
${bold("OPTIONS")}
-a, --all List expired tokens too
--full-access Create a token with full access permissions
-e, --expires-in Create a token with an expiry date (number of days)
-h, --help Show this very help message
${bold("EXAMPLES")}
${dim("# List all tokens, including expired ones")}
${cyan("comfy token list --all")}
`);
};
const formatDate = (dateStr) => {
const date = new Date(dateStr);
return date.toLocaleString();
};
const renderTokenLevel = ({ level }) => {
if (level === "read") {
return "read only".padEnd(16, " ");
}
return "full permissions";
};
const renderTokenState = (ui, { expiry_date }) => {
const { dim, green, red } = ui.colors;
if (!expiry_date) {
return `${green("active")} ${dim("(never expire)")}`;
}
const expiryDate = new Date(expiry_date);
const duration = Math.abs(new Date() - expiryDate);
if (expiryDate > new Date()) {
return `${green("active")} ${dim(
`(expires in ${humanize(duration, { largest: 1 })})`
)}`;
}
return `${red("expired")} ${dim(
`${humanize(duration, { largest: 1 })} ago`
)}`;
};
const list = function*(ui, modules, options) {
const all = options.a || options.all;
const project = yield modules.project.retrieveFromConfig();
const tokens = yield modules.token.list(project, all);
const maxNameLength = tokens
.map((token) => token.name)
.reduce((max, name) => {
if (name.length > max) {
return name.length;
}
return max;
}, 0);
for (const token of tokens) {
const name = token.name.padEnd(maxNameLength, " ");
ui.print(
`${formatDate(token.created_at)}\t${name}\t${renderTokenLevel(
token
)}\t${renderTokenState(ui, token)}`
);
}
ui.exit();
};
const parseName = (ui, options) => {
const { red, bold, green } = ui.colors;
if (options._.length < 1) {
ui.error(red("Missing token name"));
}
if (options._.length > 1) {
ui.error(red("Too many arguments"));
}
if (options._.length !== 1) {
ui.print(`${bold("SYNOPSIS")}
${bold("comfy")} token add <name> --full-access=<false> --expires-in=<0>
Type ${green("comfy token --help")} for details`);
return ui.exit(0);
}
return options._[0];
};
const add = function*(ui, modules, options) {
const { bold, green } = ui.colors;
const name = parseName(ui, options);
const level = options["full-access"] ? "write" : "read";
const expiresInDays = options.e || options["expires-in"] || null;
const project = yield modules.project.retrieveFromConfig();
const token = yield modules.token.add(project, name, level, expiresInDays);
ui.print(`${bold(green("Token successfully created"))}`);
ui.print(
"Make sure to copy your new access token now. You won't be able to see it again!"
);
ui.print(`${green("✓")} ${token.key}`);
ui.exit();
};
const remove = function*(ui, modules, options) {
const { green, bold, red, dim, bgRedBright, black, cyan } = ui.colors;
const name = parseName(ui, options);
const project = yield modules.project.retrieveFromConfig();
const tokens = yield modules.token.list(project, true);
const token = tokens.find((token) => token.name === name);
if (!token) {
ui.error(red(`Cannot find a token named "${name}"`));
ui.error(
`Type ${dim("comfy token list --all")} to list all available tokens`
);
return ui.exit(1);
}
const isLastFullAccessToken =
token.level === "write" &&
tokens.filter((t) => t.level === "write").length === 1;
if (isLastFullAccessToken && !options.permanently) {
ui.print(`
${bgRedBright(black("DANGER ZONE: This action is irreversible!"))}
You are about to delete your last write token for this project.
You will no longer be able to update configurations on this project.
If there are available read tokens, they'll be able to retrieve configurations until they expires.
If you want to delete your project instead, you can type:
${cyan(`comfy project delete`)}
If you are sure, please type the following command:
${cyan(`comfy token delete --permanently ${name}`)}
`);
ui.exit();
}
yield modules.token.remove(project, token.id);
ui.print(`${bold(green("Token successfully deleted"))}`);
ui.exit();
};
module.exports = (ui, modules) =>
function*([command, ...rawOptions]) {
const options = minimist(rawOptions);
if (options.help || options.h || options._.includes("help")) {
help(ui);
return ui.exit(0);
}
switch (command) {
case "ls":
case "list":
yield list(ui, modules, options);
break;
case "add":
case "create":
yield add(ui, modules, options);
break;
case "delete":
case "remove":
case "rm":
yield remove(ui, modules, options);
break;
default:
help(ui);
}
};
================================================
FILE: cli/src/commands/version.js
================================================
const printVersion = require('../domain/printVersion');
module.exports = ui => {
printVersion();
ui.exit();
};
================================================
FILE: cli/src/crypto/index.js
================================================
const crypto = require('crypto');
const { serialize, unserialize } = require('./serialization');
const { sign, isSignatureValid } = require('./signature');
const ALGORITHM = 'aes-256-ctr';
const KEY_BYTE_LENGTH = 32;
const IV_LENGTH = 16;
const HMAC_KEY_LENGTH = 32;
const hexToBuffer = hex => Buffer.from(hex, 'hex');
const bufferToHex = buffer => buffer.toString('hex');
const castKeyToBuffer = (key, castToBuffer = true) => {
if (Buffer.isBuffer(key)) {
if (key.length === KEY_BYTE_LENGTH) {
return key;
}
throw new Error(`The "key" argument must be a ${KEY_BYTE_LENGTH} bytes Buffer`);
}
if (castToBuffer) {
return castKeyToBuffer(hexToBuffer(key), false);
}
throw new Error('The "key" argument is must be a Buffer or a hexadecimal-encoded string');
};
const encrypt = (value, privateKey, hmacKey) => {
const key = castKeyToBuffer(privateKey);
const serializedValue = serialize(value);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const cipherText = Buffer.concat([cipher.update(serializedValue, 'utf-8'), cipher.final()]);
const signature = sign(cipherText, iv, hmacKey);
return `${ALGORITHM}:${iv.toString('hex')}:${cipherText.toString('hex')}:${signature}`;
};
const decrypt = (entry, privatekey, hmacKey) => {
const key = castKeyToBuffer(privatekey);
const [algorithm, hexIV, cipherText, signature] = entry.split(':');
if (algorithm !== ALGORITHM) {
throw new Error(`Unsupported algorithm: ${algorithm}`);
}
const iv = hexToBuffer(hexIV);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
if (!isSignatureValid(hexToBuffer(cipherText), iv, hmacKey, signature)) {
throw new Error('An encrypted value has been tampered. Aborting decryption.');
}
const decipherText = Buffer.concat([decipher.update(cipherText, 'hex'), decipher.final()]);
return unserialize(decipherText.toString('utf-8'));
};
const generateNewPrivateKey = () => bufferToHex(crypto.randomBytes(KEY_BYTE_LENGTH));
const generateNewHmacKey = () => bufferToHex(crypto.randomBytes(HMAC_KEY_LENGTH));
module.exports = {
ALGORITHM,
IV_LENGTH,
KEY_BYTE_LENGTH,
encrypt,
decrypt,
generateNewPrivateKey,
generateNewHmacKey,
};
================================================
FILE: cli/src/crypto/index.spec.js
================================================
const { encrypt, decrypt, generateNewPrivateKey, generateNewHmacKey } = require('./');
describe('Crypto Features', () => {
it('should keep the consistancy between encryption & decryption', () => {
const data = 'SOME VERY PRIVATE INFO';
const privateKey = generateNewPrivateKey();
const hmacKey = generateNewHmacKey();
const encryptedData = encrypt(data, privateKey, hmacKey);
expect(encryptedData).not.toBe(data);
const decryptedData = decrypt(encryptedData, privateKey, hmacKey);
expect(decryptedData).toBe(data);
});
it('should not return an identical signature twice for the same given entry and private key', () => {
const data = 'SOME VERY PRIVATE INFO';
const privateKey = generateNewPrivateKey();
const hmacKey = generateNewHmacKey();
const encryptedData = encrypt(data, privateKey, hmacKey);
const encryptedData2 = encrypt(data, privateKey, hmacKey);
expect(encryptedData).not.toBe(encryptedData2);
const decryptedData = decrypt(encryptedData, privateKey, hmacKey);
const decryptedData2 = decrypt(encryptedData2, privateKey, hmacKey);
expect(decryptedData).toBe(data);
expect(decryptedData2).toBe(data);
});
it('should throw an error if the data is tampered', () => {
const data = 'SOME VERY PRIVATE INFO';
const privateKey = generateNewPrivateKey();
const hmacKey = generateNewHmacKey();
const encryptedData = encrypt(data, privateKey, hmacKey);
const [algorithm, cipherText, iv, signature] = encryptedData.split(':');
const tamperedData = `${algorithm}:${cipherText}:${iv}:${signature}tampered`;
expect(() => {
decrypt(tamperedData, privateKey, hmacKey);
}).toThrow(/tampered/);
expect(() => {
decrypt(encryptedData, privateKey, hmacKey);
}).not.toThrow();
});
});
================================================
FILE: cli/src/crypto/serialization.js
================================================
const serialize = JSON.stringify;
const unserialize = JSON.parse;
module.exports = { serialize, unserialize };
================================================
FILE: cli/src/crypto/serialization.spec.js
================================================
const { serialize, unserialize } = require('./serialization');
describe('Serialization', () => {
it('should keep the value of the serialized entry', () => {
const entry = 'entry';
const unserializedEntry = unserialize(serialize(entry));
expect(unserializedEntry).toEqual(entry);
});
it('should keep the type of the serialized entry', () => {
const entry = false;
expect(typeof entry).toEqual('boolean');
const serializedEntry = serialize(entry);
expect(typeof serializedEntry).toEqual('string');
const unseriazedEntry = unserialize(serializedEntry);
expect(typeof unseriazedEntry).toEqual('boolean');
});
it('should keep `null` intact', () => {
const entry = null;
const unserializedEntry = unserialize(serialize(entry));
expect(unserializedEntry).toEqual(entry);
});
});
================================================
FILE: cli/src/crypto/signature.js
================================================
const crypto = require('crypto');
const ALGORITHM = 'SHA256';
const sign = (cipherText, iv, hmacKey) => {
const hmac = crypto.createHmac(ALGORITHM, hmacKey);
hmac.update(cipherText);
hmac.update(iv);
return hmac.digest('hex');
};
const isSignatureValid = (cipherText, iv, hmacKey, signature) => {
const control = sign(cipherText, iv, hmacKey);
return control === signature;
};
module.exports = { sign, isSignatureValid };
================================================
FILE: cli/src/domain/config.js
================================================
const { parseFlat, toJSON, toYAML, toEnvVars, toJavascript, toFlat } = require('../format');
const { JSON, YAML, JAVASCRIPT } = require('../format/constants');
const { encrypt, decrypt } = require('../crypto');
module.exports = (client, ui) => {
const list = function*(project, env, config, all = false) {
let url = config
? `${project.origin}/projects/${project.id}/environments/${env}/configurations/${config}/history`
: `${project.origin}/projects/${project.id}/environments/${env}/configurations/history`;
if (all) {
url += '?all';
}
try {
return yield client.get(url, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
return ui.exit(1);
}
};
const add = function*(project, env, content, { configName, tag, format }) {
const entries = toFlat(content);
Object.keys(entries).forEach(key => {
entries[key] = encrypt(entries[key], project.privateKey, project.hmacKey);
});
const url = `${project.origin}/projects/${project.id}/environments/${env}/configurations/${configName}/${tag}`;
const body = {
entries,
format,
};
try {
yield client.post(url, body, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
};
const get = function*(project, env, { tag, hash }) {
let url = `${project.origin}/projects/${project.id}/environments/${env}/configurations/default`;
const hashOrTag = hash || tag;
if (hashOrTag) {
url += `/${hashOrTag}`;
}
let response;
try {
response = yield client.get(url, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
const { body, defaultFormat } = response;
Object.keys(body).forEach(key => {
body[key] = decrypt(body[key], project.privateKey, project.hmacKey);
});
return { body, defaultFormat };
};
const getAndFormat = function*(project, env, hash, selector, options = {}) {
const config = yield get(project, env, { hash });
let format = config.defaultFormat;
if (options.json) format = JSON;
if (options.yml) format = YAML;
if (options.js) format = JAVASCRIPT;
let entries = config.body;
if (selector) {
const sanitizedSelector = selector.toLowerCase();
const entry = entries[sanitizedSelector] || entries[selector];
if (entry) {
// @TODO Support subset getter for nested entries
return entry;
}
entries = Object.entries(entries)
.map(([key, value]) => [key.toLowerCase(), value])
.filter(([key]) => key.startsWith(sanitizedSelector))
.reduce(
(newEntries, [key, value]) =>
Object.assign({}, newEntries, {
[options.envvars || format === 'envvars'
? key
: key.replace(`${sanitizedSelector}.`, '')]: value,
}),
{}
);
}
if (options.envvars) {
return toEnvVars(entries);
}
const body = parseFlat(entries);
switch (format) {
case YAML:
return toYAML(body);
case JAVASCRIPT:
return toJavascript(body);
default:
return toJSON(body);
}
};
return { list, add, get, getAndFormat };
};
================================================
FILE: cli/src/domain/constants.js
================================================
const CONFIG_FOLDER = '.comfy';
const CONFIG_PATH = '.comfy/config';
const DEFAULT_ORIGIN = 'https://comfy.marmelab.com';
const CREDENTIALS_VARIABLE = 'COMFY_CREDENTIALS';
module.exports = {
CONFIG_FOLDER,
CONFIG_PATH,
DEFAULT_ORIGIN,
CREDENTIALS_VARIABLE
};
================================================
FILE: cli/src/domain/environment.js
================================================
module.exports = (client, ui) => {
const list = function*(project) {
const url = `${project.origin}/projects/${project.id}/environments`;
try {
return yield client.get(url, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
};
const add = function*(project, environmentName) {
const url = `${project.origin}/projects/${project.id}/environments`;
const data = { name: environmentName };
try {
return yield client.post(url, data, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
};
return { list, add };
};
================================================
FILE: cli/src/domain/printVersion.js
================================================
const { name, version } = require('../../package.json');
module.exports = () => {
console.log(`${name} ${version}`); // eslint-disable-line no-console
};
================================================
FILE: cli/src/domain/project.js
================================================
const fs = require('fs');
const ini = require('ini');
const path = require('path');
const { CONFIG_FOLDER, CONFIG_PATH, DEFAULT_ORIGIN, CREDENTIALS_VARIABLE } = require('./constants');
const { generateNewPrivateKey, generateNewHmacKey } = require('../crypto');
const getConfigFolder = () => `${process.cwd()}${path.sep}${CONFIG_FOLDER}`;
const getConfigPath = () => `${process.cwd()}${path.sep}${CONFIG_PATH}`;
// The function `fs.mkdir(folder, { recursive: true })` doesn't exist in Node <10
const mkdirRecursive = function*(folder) {
try {
yield cb => fs.mkdir(folder, cb);
} catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
}
};
module.exports = (client, ui) => {
const create = function*(name, environment, origin = DEFAULT_ORIGIN) {
const url = `${origin}/projects`;
try {
return yield client.post(url, { name, environment });
} catch (error) {
ui.printRequestError(error);
return ui.exit(1);
}
};
const saveToConfig = function*(project, privateKey, hmacKey, origin = DEFAULT_ORIGIN) {
const config = ini.stringify(
{
projectId: project.id,
accessKey: project.accessKey,
secretToken: project.writeToken,
origin,
privateKey,
hmacKey,
},
{ section: 'project' }
);
const filename = getConfigPath();
yield mkdirRecursive(getConfigFolder());
yield cb => fs.writeFile(filename, config, { flag: 'w' }, cb);
};
const checkProjectInfos = ({ id, accessKey, secretToken, privateKey, hmacKey, origin }) => {
const errors = [];
const { red, bold } = ui.colors;
if (!id) {
errors.push(`Unable to locate the ${red('project identifier')}.`);
}
if (!accessKey) {
errors.push(`Unable to locate the ${red('access key')}.`);
}
if (!secretToken) {
errors.push(`Unable to locate the ${red('secret token')}.`);
}
if (!privateKey) {
errors.push(`Unable to locate the ${red('private key')} to decrypt your configs.`);
}
if (!hmacKey) {
errors.push(`Unable to locate the ${red('hmac key')} to sign and verify your configs.`);
}
if (!origin) {
errors.push(`Unable to locate the ${red('server origin')} to decrypt your configs.`);
}
if (errors.length > 0) {
ui.error(`${errors.join('\n')}
Have you exported the ${bold(CREDENTIALS_VARIABLE)} environment variable?
Have you tried to initialize comfy in this folder?
Type ${bold('comfy init')} to do so.`);
ui.exit(1);
}
};
const retrieveFromConfig = () => {
if (process.env[CREDENTIALS_VARIABLE]) {
try {
const buffer = Buffer.from(process.env[CREDENTIALS_VARIABLE], 'base64');
const credentials = JSON.parse(buffer.toString('utf8'));
checkProjectInfos(credentials);
return credentials;
} catch (error) {
ui.error(`The credentials encoded in ${CREDENTIALS_VARIABLE} are invalid`);
ui.exit(1);
}
}
const envs = {
id: process.env.COMFY_PROJECT_ID,
accessKey: process.env.COMFY_ACCESS_KEY,
secretToken: process.env.COMFY_SECRET_TOKEN,
privateKey: process.env.COMFY_PRIVATE_KEY,
hmacKey: process.env.COMFY_HMAC_KEY,
origin: process.env.COMFY_ORIGIN,
};
const filename = getConfigPath();
if (!fs.existsSync(filename)) {
checkProjectInfos(envs);
return envs;
}
const file = fs.readFileSync(filename, 'utf8');
const config = ini.parse(file);
const projectInfos = Object.assign({}, envs, {
id: config.project.projectId,
accessKey: config.project.accessKey,
secretToken: config.project.secretToken,
privateKey: config.project.privateKey,
hmacKey: config.project.hmacKey,
origin: config.project.origin,
});
checkProjectInfos(projectInfos);
return projectInfos;
};
const toEncodedCredentials = () => {
const project = retrieveFromConfig();
const buffer = Buffer.from(JSON.stringify(project), 'utf8');
return buffer.toString('base64');
};
const permanentlyDelete = function*() {
const project = retrieveFromConfig();
const url = `${project.origin}/projects/${project.id}`;
try {
yield client.delete(url, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
yield cb => fs.unlink(getConfigPath(), cb);
yield cb => fs.rmdir(getConfigFolder(), cb);
};
return {
create,
retrieveFromConfig,
saveToConfig,
CONFIG_FOLDER,
CONFIG_PATH,
CREDENTIALS_VARIABLE,
generateNewPrivateKey,
generateNewHmacKey,
getConfigFolder,
getConfigPath,
permanentlyDelete,
toEncodedCredentials,
};
};
================================================
FILE: cli/src/domain/project.spec.js
================================================
const projectFactory = require('./project');
const { CREDENTIALS_VARIABLE } = require('./constants');
const client = null;
const ui = {
colors: { red: jest.fn(), bold: jest.fn() },
error: jest.fn(),
exit: jest.fn(),
};
describe('Project Domain', () => {
const credentials = {
id: 'id',
accessKey: 'accessKey',
secretToken: 'secretToken',
privateKey: 'privateKey',
hmacKey: 'hmacKey',
origin: 'origin',
};
const removeCredentialsFromEnvironment = () => {
delete process.env.COMFY_PROJECT_ID;
delete process.env.COMFY_ACCESS_KEY;
delete process.env.COMFY_SECRET_TOKEN;
delete process.env.COMFY_PRIVATE_KEY;
delete process.env.COMFY_HMAC_KEY;
delete process.env.COMFY_ORIGIN;
};
beforeEach(() => {
process.env.COMFY_PROJECT_ID = credentials.id;
process.env.COMFY_ACCESS_KEY = credentials.accessKey;
process.env.COMFY_SECRET_TOKEN = credentials.secretToken;
process.env.COMFY_PRIVATE_KEY = credentials.privateKey;
process.env.COMFY_HMAC_KEY = credentials.hmacKey;
process.env.COMFY_ORIGIN = credentials.origin;
});
it('should build an hex encoded string with existing credentials', () => {
const project = projectFactory(client, ui);
const encodedCredentials = project.toEncodedCredentials();
expect(encodedCredentials).toBe(
'eyJpZCI6ImlkIiwiYWNjZXNzS2V5IjoiYWNjZXNzS2V5Iiwic2VjcmV0VG9rZW4iOiJzZWNyZXRUb2tlbiIsInByaXZhdGVLZXkiOiJwcml2YXRlS2V5IiwiaG1hY0tleSI6ImhtYWNLZXkiLCJvcmlnaW4iOiJvcmlnaW4ifQ=='
);
});
it('should be able to restore a project configuration via an encoded credentials', () => {
process.env[CREDENTIALS_VARIABLE] = projectFactory(client, ui).toEncodedCredentials();
expect(projectFactory(client, ui).retrieveFromConfig()).toEqual(credentials);
});
afterEach(() => {
removeCredentialsFromEnvironment();
delete process.env[CREDENTIALS_VARIABLE];
});
});
================================================
FILE: cli/src/domain/tag.js
================================================
module.exports = (client, ui) => {
const add = function*(project, environment, configName, name, selector) {
const url = `${project.origin}/projects/${
project.id
}/environments/${environment}/configurations/${configName}/tags`;
const body = {
name,
selector,
};
try {
return yield client.post(url, body, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
};
const move = function*(project, environment, configName, name, selector) {
const url = `${project.origin}/projects/${
project.id
}/environments/${environment}/configurations/${configName}/tags/${name}`;
const body = { selector };
try {
return yield client.put(url, body, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
};
const remove = function*(project, environment, configName, name) {
const url = `${project.origin}/projects/${
project.id
}/environments/${environment}/configurations/${configName}/tags/${name}`;
try {
return yield client.delete(url, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
};
return { add, move, remove };
};
================================================
FILE: cli/src/domain/token.js
================================================
module.exports = (client, ui) => {
const list = function*(project, all = false) {
let url = `${project.origin}/projects/${project.id}/tokens`;
if (all) {
url += "?all";
}
try {
return yield client.get(url, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
};
const add = function*(project, name, level = "read", expiresInDays = null) {
const url = `${project.origin}/projects/${project.id}/tokens`;
const body = {
name,
level,
expiresInDays,
};
try {
return yield client.post(url, body, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
};
const remove = function*(project, id) {
const url = `${project.origin}/projects/${project.id}/tokens/${id}`;
try {
return yield client.delete(url, client.buildAuthorization(project));
} catch (error) {
ui.printRequestError(error);
ui.exit(1);
}
};
return { list, add, remove };
};
================================================
FILE: cli/src/format/constants.js
================================================
const ENVVARS = 'envvars';
const JSON = 'json';
const YAML = 'yaml';
const JAVASCRIPT = 'javascript';
module.exports = {
ENVVARS,
JSON,
YAML,
JAVASCRIPT,
};
================================================
FILE: cli/src/format/guessFormat.js
================================================
const { JSON, YAML, ENVVARS } = require('./constants');
module.exports = ext => {
switch ((ext || '').toLowerCase()) {
case '.json':
return JSON;
case '.yml':
case '.yaml':
return YAML;
default:
return ENVVARS;
}
};
================================================
FILE: cli/src/format/guessFormat.spec.js
================================================
const { JSON, YAML, ENVVARS } = require('./constants');
const guessFormat = require('./guessFormat');
describe('Format guessFormat', () => {
it.each([
['.json', JSON],
['.yml', YAML],
['.yaml', YAML],
['.unknown', ENVVARS],
[undefined, ENVVARS],
[null, ENVVARS],
])('should transform extention "%s" into the format "%s"', (ext, format) => {
expect(guessFormat(ext)).toBe(format);
});
});
================================================
FILE: cli/src/format/index.js
================================================
const deepSet = require('lodash.set');
const yaml = require('js-yaml');
const toFlat = require('./toFlat');
const guessFormat = require('./guessFormat');
const parseJSON = content => JSON.parse(content);
const toJSON = content => JSON.stringify(content, null, 4);
const parseYAML = content => yaml.safeLoad(content);
const toYAML = content => {
if (!content || Object.keys(content).length === 0) return '';
return yaml.safeDump(content);
};
const parseFlat = content => {
const body = {};
const keys = Object.keys(content).sort();
for (const key of keys) {
deepSet(body, key, content[key]);
}
return body;
};
const toEnvVars = flatContent => {
let source = '';
for (const key of Object.keys(flatContent).sort()) {
const value = flatContent[key] ? flatContent[key].toString() : '';
// Replace each ' by '"'"' in the value
// @see http://stackoverflow.com/a/1250279/3868326
const escapedValue = value.replace("'", "'\"'\"'");
const envVar = key
.replace(/\./g, '_')
.replace(/\[/g, '_')
.replace(/\]/g, '')
.toUpperCase();
source += `export ${envVar}='${escapedValue}';\n`;
}
return source;
};
const toJavascript = content => `window.COMFY = ${JSON.stringify(content)};`;
module.exports = {
toJavascript,
parseJSON,
toJSON,
parseYAML,
toYAML,
parseFlat,
toEnvVars,
toFlat,
guessFormat,
};
================================================
FILE: cli/src/format/index.spec.js
================================================
const { toEnvVars, toJavascript } = require('./');
describe('Format', () => {
describe('toEnvVars', () => {
it('should handle null value', () => {
expect(toEnvVars({ key: '' })).toEqual("export KEY='';\n");
expect(toEnvVars({ key: null })).toEqual("export KEY='';\n");
expect(toEnvVars({ key: undefined })).toEqual("export KEY='';\n");
});
it('should handle multiple level of children', () => {
expect(toEnvVars({ 'key.a': 'value' })).toEqual("export KEY_A='value';\n");
expect(toEnvVars({ 'key.a.b': 'value' })).toEqual("export KEY_A_B='value';\n");
});
it('should handle (nested) lists', () => {
expect(toEnvVars({ 'key[0]': 'value' })).toEqual("export KEY_0='value';\n");
expect(toEnvVars({ 'key[0].a[0].b': 'value' })).toEqual("export KEY_0_A_0_B='value';\n");
});
});
describe('toJavascript', () => {
it('should transform a config into a javascript object', () => {
const config = {
key: undefined,
nullable: null,
admin: 'admin',
password: 'S3cret!',
permissions: ['read', 'write'],
attributes: { size: 42 },
};
expect(toJavascript(config)).toEqual(
'window.COMFY = {"nullable":null,"admin":"admin","password":"S3cret!","permissions":["read","write"],"attributes":{"size":42}};'
);
});
});
});
================================================
FILE: cli/src/format/toFlat.js
================================================
const deepGet = require('lodash.get');
const traverse = (obj, parentKey = '') => {
const list = [];
if (Array.isArray(obj)) {
obj.forEach((item, i) => {
const traversed = traverse(item, `${parentKey}[${i}]`);
traversed.forEach(t => {
list.push(t);
});
});
} else if (obj && obj.toString() === '[object Object]') {
for (const key of Object.keys(obj)) {
const pKey = parentKey ? `${parentKey}.` : '';
const objectList = traverse(obj[key], `${pKey}${key}`);
for (const objectItem of objectList) {
list.push(objectItem);
}
}
} else {
list.push(parentKey);
}
return list;
};
const toFlat = body => {
const content = {};
const keyList = traverse(body);
for (const key of keyList.sort()) {
content[key] = deepGet(body, key);
}
return content;
};
module.exports = toFlat;
================================================
FILE: cli/src/index.js
================================================
/* eslint-disable global-require */
const clientFactory = require("./client");
const projectModuleFactory = require("./domain/project");
const environmentModuleFactory = require("./domain/environment");
const configModuleFactory = require("./domain/config");
const tagModuleFactory = require("./domain/tag");
const tokenModuleFactory = require("./domain/token");
const printVersion = require("./domain/printVersion");
const main = (ui, evt) => {
const commands = {
help: require("./commands/help"),
init: require("./commands/init"),
env: require("./commands/env"),
setall: require("./commands/setall"),
set: require("./commands/set"),
get: require("./commands/get"),
diff: require("./commands/diff"),
log: require("./commands/log"),
tag: require("./commands/tag"),
version: require("./commands/version"),
project: require("./commands/project"),
token: require("./commands/token")
};
return function* mainCommand() {
const request = ui.digestEvent(evt);
if (request.command === "-V" || request.arguments.includes("-V")) {
printVersion();
ui.exit();
}
let command = commands.help;
if (request.command) {
command = commands[request.command];
}
if (!command) {
const { red, green } = ui.colors;
ui.error(
`The command ${red(request.command)} doesn't exist.` +
`\nType ${green("comfy help")} to see the available commands.`
);
ui.exit(1);
}
const client = clientFactory(require("request"));
const modules = {
project: projectModuleFactory(client, ui),
environment: environmentModuleFactory(client, ui),
config: configModuleFactory(client, ui),
tag: tagModuleFactory(client, ui),
token: tokenModuleFactory(client, ui)
};
if (typeof command === "function") {
yield command(ui, modules)(request.arguments);
}
};
};
module.exports = main;
================================================
FILE: cli/src/ui/console.js
================================================
const chalk = require("chalk");
const readline = require("readline");
const Table = require("cli-table");
const print = console.log; // eslint-disable-line no-console
const { error } = console; // eslint-disable-line no-console
// eslint-disable-next-line no-unused-vars
const digestEvent = ([bin, file, command, ...args]) => ({
command,
arguments: args,
});
const exit = (code = 0) => {
process.exit(code);
};
const colors = chalk;
const input = {
text: (question) => (callback) => {
const reader = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
reader.question(`${question} `, (answer) => {
reader.close();
callback(null, answer);
});
},
};
const printRequestError = (err) => {
const { red, cyan, dim } = colors;
switch (err.code) {
case "ECONNREFUSED":
case "ECONNRESET":
case "ETIMEDOUT":
case "ENETUNREACH":
case "ENOTFOUND":
error(`${dim(err.message)}
${red("Unable to reach the comfy server.")}
Please check your connection and try again.`);
break;
case 403:
error(`${red("You are not allowed to perform this action.")}
Please check your read or write token.`);
break;
case 400:
case 404:
if (err.body && err.body.message) {
error(red(err.body.message));
if (err.body.details) {
error(err.body.details);
}
break;
}
// eslint-disable-next-line no-fallthrough
default:
error(`${dim(err.message)}
${dim(err.stack)}
${red("Unknown error, command aborted.")}
If the error persists, please report it at ${cyan(
"https://github.com/marmelab/comfygure/issues"
)}`);
}
};
const table = (rows) => {
const t = new Table();
t.push(...rows);
print(t.toString());
};
module.exports = {
colors,
print,
error,
exit,
input,
table,
digestEvent,
printRequestError,
};
================================================
FILE: docs/HostYourOwn.md
================================================
---
layout: default
title: 'Host Your Own Comfygure Origin Server'
---
# Host Your Own Comfygure Origin Server
Marmelab hosts the default comfygure server at `https://comfy.marmelab.com`. You can use it for free, for your tests, with no warranties of availability. We reserve the right to suspend usage in case of abuse.
In production, you'll probably want to host your own comfygure server. Fortunately, the comfygure server code is open-source and [available on GitHub](https://github.com/marmelab/comfygure).
Once your server is configured, use the standard `comfy` client to initialize your project, and pass your server URL in the `--origin` option:
```bash
# The origin will be stored in .comfy/config
# Feel free to edit this file if you want to change your origin server
comfy init --origin https://my.custom.host
```
Now, how to setup an origin server? There are a few options.
## With The Docker Image
The comfygure API is published as a docker image : [marmelab/comfygure](https://hub.docker.com/r/marmelab/comfygure).
It requires a [PostgreSQL instance](https://hub.docker.com/_/postgres) to store the configs, so let's start by that :
```bash
# Create a docker network in order to let comfygure reach the postgres container
docker network create comfy-network
# Grab the initial database schema on the repo
wget https://raw.githubusercontent.com/marmelab/comfygure/master/api/var/schema.sql
# Start the postgres container with the initial schema
docker run --name comfy-postgres \
-e POSTGRES_PASSWORD=mysecretpassword \
-v `pwd`/schema.sql:/docker-entrypoint-initdb.d/schema.sql \
--network comfy-network \
-d postgres
# Run comfygure container and expose its port
docker run --name comfygure-api \
-e PGHOST=comfy-postgres \
-e PGDATABASE=postgres \
-e PGPASSWORD=mysecretpassword \
--network comfy-network \
-p 3000:80 \
-d marmelab/comfygure
```
Your comfygure API container is now up and running at http://localhost:3000, and you can use it with the comfy CLI to manage your configs.
```bash
npm install -g comfygure
comfy init --origin http://localhost:3000
Initializing project configuration...
Project created on comfy server http://localhost:3000
Configuration saved locally in .comfy/config
comfy project successfully created
```
## With ZEIT's Now
Now make deployment easy. Just like with a docker container, you have to configure your postgres instance.
But if you have to deploy the API, feel free to clone the GitHub repository and use pre-filled `now.json` file.
```bash
git clone git@github.com:marmelab/comfygure.git
cd comfygure/api
```
Edit `now.json` to add your environment variables
```json
{
"name": "comfygure",
"version": 2,
"builds": [
{
"src": "build/index.js",
"use": "@now/node"
}
],
"routes": [{ "src": ".*", "dest": "/build" }],
"env": {
"PGHOST": "@comfy-pghost",
"PGDATABASE": "@comfy-pgdatabase",
"PGUSER": "@comfy-pguser",
"PGPASSWORD": "@comfy-pgpassword"
}
}
```
You can then starting the server locally by running `now dev` or deploying directly with `now`.
Don't forget to write down your environment variables in a `.env` file, or with [`now secret`](https://zeit.co/docs/v2/deployments/environment-variables-and-secrets).
## Environment Variables
The comfygure API doesn't require much configuration other than to plug the postgres, here are all the environment variables you can use to tune it.
To be noted that the container has a configuration validation at the server start : if one of these environment variables is invalid, the container will crash at start.
**`COMFY_LOG_DEBUG`** (default: false)
**`PGHOST`** (default: localhost)
**`PGPORT`** (default: 5432)
**`PGDATABASE`** (default: comfy)
**`PGUSER`** (default: postgres)
**`PGPASSWORD`** (default: '')
It is **highly** recommended to set a default root password.
================================================
FILE: docs/HowItWorks.md
================================================
---
layout: default
title: 'How It Works'
---
## The Problem: Managing Application Settings
How do you store the settings of a web application?
Most developers use configuration files (`config.json`, `parameters.yaml`, `root.xml`, etc). But these files should not be committed to source control (git), because they contain sensible information (db passwords, service credentials, etc), and because they can change with each developer.
So developers usually commit fake config files (like `parameters.yml-dist`), and keep a real configuration file locally (ignored by source control). This leads to problems when one developer adds a new setting in the file, but doesn't tell other developers.
Another solution is to use environment variables. This makes sharing the settings between developers and between environments even harder.
## The Solution
Comfygure proposes to solve that problem by storing settings on a remote server (like a remote git), encrypted. Comfygure clients know how to read and write from that remote server, and decrypt the settings to dump it locally.

Ultimately, this lets you execute the following command from any server:
```bash
> comfy get development --envvars
export LOGIN='admin';
export PASSWORD='S3cr3T';
```
## Storage
By default, comy stores the encrypted settings in the [comfy.marmelab.com](https://comfy.marmelab.com) server (run by marmelab). The comfygure project contains [the code](https://github.com/marmelab/comfygure) to let you host your own comfygure server (see the [Custom Server documentation](./HostYourOwn.html#host-your-own-comfy-server).
## Security
From a security standpoint, if the remote server is owned, the attacker can only access encrypted data (AES-256). Since the server never has access to the decryption key, the attacker can't decrypt the settings.
Developers store the decryption key locally, allowing them to decrypt and/or update the app settings. In a similar fashion, CI servers can also check out the app settings with a simple decryption key, then build an artifact to be deployed to production.
================================================
FILE: docs/Usage.md
================================================
---
layout: default
title: 'Usage'
---
## Initialization
Initialize comfygure in a project directory with `comfy init`:
```bash
> cd dev/my-project
> comfy init
Initializing project configuration...
Project created on comfy server https://comfy.marmelab.com
Configuration saved locally in .comfy/config
comfy project successfully created
```
By default, the `comfy` command stores encrypted data in the `comfy.marmelab.com` server. To host your own comfy server, see [the related documentation](./HostYourOwn.html).
### `.comfy/` Folder
The initialization command creates:
- A `.comfy/config` file containing all identification and credentials about the current project, in order to sync with the comfygure origin server
- A new line on your `.gitignore` in order to avoid committing this file (if a `.git` folder is found in the current folder)
Here is how the comfygure config file looks like.
```bash
> cat .comfy/config
[project]
# Your project ID to identify your project, useful to debug
projectId=1111111111-1111-1111-1111-1111111111111
# Your credentials to access to the comfy origin server
accessKey=XXXXXXXXXXXXXXXX
secretToken=YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY
# The comfy server URI
origin=https://comfy.marmelab.com
# The private key used to encrypt your configuration, never sent to the server
privateKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# The HMAC key used to sign and verify the integrity of your configuration, never sent to the server
hmacKey=yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
```
The comfy server don't have access to your private and HMAC keys, ever. Be sure to keep these informations safe and secure.
**Warning**: If you lose this file, you will no longer be able to retrieve your settings, and no one will be able to help you, not even the server administrators.
## Managing Environments
By default, comfy creates one single environment, called `development`. You can choose a different name during initialization:
```bash
comfy init --env production
```
At any time, you can list environments, or create a new environment:
```bash
# list environments
> comfy env ls
development
# create a "production" environment
> comfy env add production
Environment successfully created
You can now set a configuration for this environment using comfy setall production
> comfy env ls
development
production
```
## Adding A New Version Of Settings
When you initialize comfygure on an app, it starts with no settings.
```bash
> comfy get development
{}
```
In order to add a new version of the settings, you have to use the `setall` command, with a file containing your settings.
```
> cat config.json
{ "login": "admin", "password": "S3cret!" }
> comfy setall development config.json
comfy configuration successfully saved
```
Or your can use the `set` command to add or update a single entry in your config:
```
> comfy set development version "0.1"
> comfy get development version
0.1
```
## Retrieving Configuration
To retrieve a configuration, use `comfy get`:
```bash
> comfy get development
{
"login": "admin",
"password": "S3cret!"
}
```
Optionally, you can format the configuration as a YAML, or as environment variables:
```bash
> comfy get development --yml
login: admin
password: S3cret!
> comfy get development --envvars
export LOGIN='admin';
export PASSWORD='S3cret!';
```
You can then use the standard output to create a new file, or source your environment variables.
```bash
> comfy get development --yml > src/config/development.yml
> cat src/config/development.yml
login: admin
password: S3cret!
> comfy get development --envvars | source /dev/stdin
> echo $LOGIN
admin
```
## Collaborating With A Team
To retrieve the settings of an app, comfygure needs all the information from the `.comfy/config` file for that app.
If you want to give the ability to Bob, your co-worker, to fetch the settings usinf comfygure, just give him this file.
```bash
scp .comfy/config bob@bob-workstation:~/repository/.comfy/config
```
You and Bob will now be able to share the settings. If bob edits a setting, you and other team members can retrieve it immediately.
## Deployment
The `.comfy/config` file is convenient for tests and development, but not for real deployment.
To this end, if comfy doesn't find `.comfy/config` from the current folder, it looks for the credentials in environment variables.
Instructions to retrieve your configurations from a remote server are available by running `comfy project deploy`.
```
> comfy project deploy
Here are the instructions to install comfy on an remote server:
1. Install comfygure
2. Export the following environment variable
3. Retrieve your config in the format of your choice
npm install -g comfygure
export COMFY_CREDENTIALS=<TOKEN>
comfy get production --json
```
The `COMFY_CREDENTIALS` environment variable is generated using your credentials in `.comfy/config`. It contains your comfy credentials in a JSON string encoded in base64, is is not encrypted. **Do not share this token.**
Alternatively, you can specify all environment variable one by one, if you need to fine tune your comfy CLI. Say, you have the following `.comfy/config` file:
```ini
[project]
projectId=1111111111-1111-1111-1111-1111111111111
accessKey=XXXXXXXXXXXXXXXX
secretToken=YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY
origin=https://comfy.marmelab.com
privateKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
hmacKey=yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
```
You can specify the following environment variables to replace it:
```bash
# All of these are included in COMFY_CREDENTIALS
export COMFY_PROJECT_ID=1111111111-1111-1111-1111-1111111111111;
export COMFY_ACCESS_KEY=XXXXXXXXXXXXXXXX;
export COMFY_SECRET_TOKEN=YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY;
export COMFY_ORIGIN=https://comfy.marmelab.com;
export COMFY_PRIVATE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx;
export COMFY_HMAC_KEY=yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy;
comfy get production
```
Set the environment variable(s) in your CI configuration, code builder, or any continuous delivery system to let them use your configurations.
================================================
FILE: docs/_config.yml
================================================
name: comfygure documentation
markdown: kramdown
kramdown:
input: GFM
highlighter: rouge
================================================
FILE: docs/_layouts/default.html
================================================
<!DOCTYPE HTML>
<html lang="en-US">
<head>
<title>Comfygure - {{ page.title }}</title>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta name="description" content="{{ page.description }}">
<meta name="HandheldFriendly" content="true" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<link rel="stylesheet" href="css/normalize.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/syntax.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/octicons/4.4.0/font/octicons.css">
</head>
<body>
<div class="book with-summary font-size-2 font-family-1">
<div class="book-summary">
<nav role="navigation">
<ul class="summary">
<li class="header">Table of Contents</li>
<li class="chapter {% if page.path == 'index.md' %}active{% endif %}">
<a href="./index.html">
<b>1.</b> Comfygure
</a>
</li>
<li class="chapter {% if page.path == 'HowItWorks.md' %}active{% endif %}">
<a href="./HowItWorks.html">
<b>2.</b> How It Works
</a>
<ul class="articles" {% if page.path != 'HowItWorks.md' %}style="display:none" {% endif %}>
<li class="chapter">
<a href="#the-problem-managing-application-configuration">
The Problem: Managing Application Configuration
</a>
</li>
<li class="chapter">
<a href="#the-solution">The Solution</a>
</li>
<li class="chapter">
<a href="#storage">Storage</a>
</li>
<li class="chapter">
<a href="#Security">Security</a>
</li>
</ul>
</li>
<li class="chapter {% if page.path == 'Usage.md' %}active{% endif %}">
<a href="./Usage.html">
<b>3.</b> Basic Usage
</a>
<ul class="articles" {% if page.path != 'Usage.md' %}style="display:none" {% endif %}>
<li class="chapter">
<a href="#installation">
Installation
</a>
</li>
<li class="chapter">
<a href="#project-initialization">Project Initialization</a>
</li>
<li class="chapter">
<a href="#managing-environments">Managing Environments</a>
</li>
<li class="chapter">
<a href="#add-a-configuration-version">Add a configuration version</a>
</li>
<li class="chapter">
<a href="#retrieve-a-configuration">Retrieve a configuration</a>
</li>
<li class="chapter">
<a href="#collaborate-with-your-team">Collaborate with your team</a>
</li>
<li class="chapter">
<a href="#deployment">Deployment</a>
</li>
</ul>
</li>
<li class="chapter {% if path.path == 'HostYourOwn.md' %}active{% endif %}">
<a href="./HostYourOwn.html">
<b>4.</b> Host Your Own
</a>
<ul class="articles" {% if page.path != 'HostYourOwn.md' %}style="display:none" {% endif %}>
<li class="chapter">
<a href="#with-the-docker-image">With the Docker Image</a>
</li>
<li class="chapter">
<a href="#with-zeits-now">With ZEIT's Now</a>
</li>
<li class="chapter">
<a href="#environment-variables">Environment Variables</a>
</li>
</ul>
</li>
</ul>
</nav>
</div>
<div class="book-body">
<div class="body-inner">
<div class="page-wrapper" tabindex="-1" role="main">
<div class="page-inner">
<div id="book-search-results">
<div class="search-noresults">
<section class="normal markdown-section">
{{ content }}
</section>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-46201426-1', 'auto');
ga('send', 'pageview');
</script>
</body>
</html>
================================================
FILE: docs/css/normalize.css
================================================
/*! normalize.css v4.2.0 | MIT License | github.com/necolas/normalize.css */
/**
* 1. Change the default font family in all browsers (opinionated).
* 2. Correct the line height in all browsers.
* 3. Prevent adjustments of font size after orientation changes in IE and iOS.
*/
/* Document
========================================================================== */
html {
font-family: sans-serif; /* 1 */
line-height: 1.15; /* 2 */
-ms-text-size-adjust: 100%; /* 3 */
-webkit-text-size-adjust: 100%; /* 3 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers (opinionated).
*/
body {
margin: 0;
}
/**
* Add the correct display in IE 9-.
*/
article,
aside,
footer,
header,
nav,
section {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* Add the correct display in IE 9-.
* 1. Add the correct display in IE.
*/
figcaption,
figure,
main { /* 1 */
display: block;
}
/**
* Add the correct margin in IE 8.
*/
figure {
margin: 1em 40px;
}
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* 1. Remove the gray background on active links in IE 10.
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
*/
a {
background-color: transparent; /* 1 */
-webkit-text-decoration-skip: objects; /* 2 */
}
/**
* Remove the outline on focused links when they are also active or hovered
* in all browsers (opinionated).
*/
a:active,
a:hover {
outline-width: 0;
}
/**
* 1. Remove the bottom border in Firefox 39-.
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
*/
b,
strong {
font-weight: inherit;
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font style in Android 4.3-.
*/
dfn {
font-style: italic;
}
/**
* Add the correct background and color in IE 9-.
*/
mark {
background-color: #ff0;
color: #000;
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
audio,
video {
display: inline-block;
}
/**
* Add the correct display in iOS 4-7.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Remove the border on images inside links in IE 10-.
*/
img {
border-style: none;
}
/**
* Hide the overflow in IE.
*/
svg:not(:root) {
overflow: hidden;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers (opinionated).
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: sans-serif; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
* controls in Android 4.
* 2. Correct the inability to style clickable types in iOS and Safari.
*/
button,
html [type="button"], /* 1 */
[type="reset"],
[type="submit"] {
-webkit-appearance: button; /* 2 */
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Change the border, margin, and padding in all browsers (opinionated).
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* 1. Add the correct display in IE 9-.
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Remove the default vertical scrollbar in IE.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10-.
* 2. Remove the padding in IE 10-.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding and cancel buttons in Chrome and Safari on OS X.
*/
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in IE 9-.
* 1. Add the correct display in Edge, IE, and Firefox.
*/
details, /* 1 */
menu {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Scripting
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
canvas {
display: inline-block
gitextract_ij6k_m60/
├── .github/
│ ├── CONTRIBUTING.md
│ ├── ISSUE_TEMPLATE.md
│ └── SECURITY.md
├── .gitignore
├── .travis.yml
├── LICENSE
├── Makefile
├── README.md
├── api/
│ ├── .dockerignore
│ ├── .gitignore
│ ├── Dockerfile
│ ├── babel.config.js
│ ├── makefile
│ ├── migrations/
│ │ ├── 20170524101600-initialisation.js
│ │ ├── 20170524124810-unique-tag.js
│ │ └── 20200325133153-add-token-table.js
│ ├── now.json
│ ├── package.json
│ ├── serverless.yml
│ ├── src/
│ │ ├── config.js
│ │ ├── domain/
│ │ │ ├── common/
│ │ │ │ ├── formats.js
│ │ │ │ └── states.js
│ │ │ ├── configurations/
│ │ │ │ ├── __mocks__/
│ │ │ │ │ └── tag.js
│ │ │ │ ├── add.js
│ │ │ │ ├── get.js
│ │ │ │ ├── history.js
│ │ │ │ ├── index.js
│ │ │ │ ├── tag.js
│ │ │ │ ├── update.js
│ │ │ │ └── version.js
│ │ │ ├── environments/
│ │ │ │ ├── __mocks__/
│ │ │ │ │ └── get.js
│ │ │ │ ├── add.js
│ │ │ │ ├── add.spec.js
│ │ │ │ ├── get.js
│ │ │ │ ├── get.spec.js
│ │ │ │ ├── index.js
│ │ │ │ ├── remove.js
│ │ │ │ ├── remove.spec.js
│ │ │ │ ├── rename.js
│ │ │ │ └── rename.spec.js
│ │ │ ├── errors.js
│ │ │ ├── permissions.js
│ │ │ ├── projects/
│ │ │ │ ├── __mocks__/
│ │ │ │ │ └── get.js
│ │ │ │ ├── add.js
│ │ │ │ ├── get.js
│ │ │ │ ├── get.spec.js
│ │ │ │ ├── index.js
│ │ │ │ ├── remove.js
│ │ │ │ └── rename.js
│ │ │ ├── tags/
│ │ │ │ ├── add.js
│ │ │ │ ├── move.js
│ │ │ │ ├── remove.js
│ │ │ │ └── validator.js
│ │ │ ├── tokens/
│ │ │ │ ├── add.js
│ │ │ │ ├── generateRandomString.js
│ │ │ │ ├── get.js
│ │ │ │ └── remove.js
│ │ │ └── validation.js
│ │ ├── handlers/
│ │ │ ├── configurations.js
│ │ │ ├── environments.js
│ │ │ ├── projects.js
│ │ │ ├── tags.js
│ │ │ ├── tokens.js
│ │ │ └── utils/
│ │ │ ├── authorization.js
│ │ │ ├── errors.js
│ │ │ └── λ.js
│ │ ├── index.js
│ │ ├── launcher.js
│ │ ├── logger.js
│ │ ├── mocks.js
│ │ └── queries/
│ │ ├── __mocks__/
│ │ │ ├── configurations.js
│ │ │ ├── environments.js
│ │ │ ├── projects.js
│ │ │ └── versions.js
│ │ ├── configurations.js
│ │ ├── entries.js
│ │ ├── environments.js
│ │ ├── knex.js
│ │ ├── projects.js
│ │ ├── tags.js
│ │ ├── tokens.js
│ │ └── versions.js
│ ├── var/
│ │ └── schema.sql
│ └── webpack.config.babel.js
├── cli/
│ ├── .eslintrc
│ ├── .npmignore
│ ├── Makefile
│ ├── README.md
│ ├── bin/
│ │ └── comfy.js
│ ├── package.json
│ └── src/
│ ├── client.js
│ ├── commands/
│ │ ├── admin.js
│ │ ├── diff.js
│ │ ├── env.js
│ │ ├── get.js
│ │ ├── help.js
│ │ ├── init.js
│ │ ├── log.js
│ │ ├── project.js
│ │ ├── set.js
│ │ ├── setall.js
│ │ ├── tag.js
│ │ ├── token.js
│ │ └── version.js
│ ├── crypto/
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ ├── serialization.js
│ │ ├── serialization.spec.js
│ │ └── signature.js
│ ├── domain/
│ │ ├── config.js
│ │ ├── constants.js
│ │ ├── environment.js
│ │ ├── printVersion.js
│ │ ├── project.js
│ │ ├── project.spec.js
│ │ ├── tag.js
│ │ └── token.js
│ ├── format/
│ │ ├── constants.js
│ │ ├── guessFormat.js
│ │ ├── guessFormat.spec.js
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── toFlat.js
│ ├── index.js
│ └── ui/
│ └── console.js
├── docs/
│ ├── HostYourOwn.md
│ ├── HowItWorks.md
│ ├── Usage.md
│ ├── _config.yml
│ ├── _layouts/
│ │ └── default.html
│ ├── css/
│ │ ├── normalize.css
│ │ ├── style.css
│ │ └── syntax.css
│ └── index.md
├── package.json
└── test/
├── Makefile
├── cli.js
├── package.json
├── pm2_configuration.json
├── setup.js
└── specs/
├── basicUsage.js
├── commands.js
├── formats.js
├── init.js
└── scenarios.js
SYMBOL INDEX (34 symbols across 10 files)
FILE: api/src/domain/common/formats.js
constant ENVVARS (line 1) | const ENVVARS = "envvars";
constant JSON (line 2) | const JSON = "json";
constant YAML (line 3) | const YAML = "yaml";
FILE: api/src/domain/common/states.js
constant LIVE (line 1) | const LIVE = "live";
constant ARCHIVED (line 2) | const ARCHIVED = "archived";
FILE: api/src/domain/errors.js
class NotFoundError (line 1) | class NotFoundError extends Error {
method constructor (line 2) | constructor(args) {
class ValidationError (line 22) | class ValidationError extends Error {
method constructor (line 23) | constructor(args) {
FILE: api/src/handlers/utils/errors.js
class HttpError (line 4) | class HttpError extends Error {
method constructor (line 5) | constructor(statusCode = 500, message = "An error occured", details = ...
FILE: api/var/schema.sql
type public (line 65) | CREATE TABLE public.configuration (
type public (line 80) | CREATE TABLE public.entry (
type public (line 91) | CREATE TABLE public.environment (
type public (line 105) | CREATE TABLE public.project (
type public (line 119) | CREATE TABLE public.tag (
type public (line 132) | CREATE TABLE public.token (
type public (line 149) | CREATE TABLE public.version (
FILE: cli/src/crypto/index.js
constant ALGORITHM (line 5) | const ALGORITHM = 'aes-256-ctr';
constant KEY_BYTE_LENGTH (line 6) | const KEY_BYTE_LENGTH = 32;
constant IV_LENGTH (line 7) | const IV_LENGTH = 16;
constant HMAC_KEY_LENGTH (line 8) | const HMAC_KEY_LENGTH = 32;
FILE: cli/src/crypto/signature.js
constant ALGORITHM (line 3) | const ALGORITHM = 'SHA256';
FILE: cli/src/domain/constants.js
constant CONFIG_FOLDER (line 1) | const CONFIG_FOLDER = '.comfy';
constant CONFIG_PATH (line 2) | const CONFIG_PATH = '.comfy/config';
constant DEFAULT_ORIGIN (line 3) | const DEFAULT_ORIGIN = 'https://comfy.marmelab.com';
constant CREDENTIALS_VARIABLE (line 4) | const CREDENTIALS_VARIABLE = 'COMFY_CREDENTIALS';
FILE: cli/src/format/constants.js
constant ENVVARS (line 1) | const ENVVARS = 'envvars';
constant JSON (line 2) | const JSON = 'json';
constant YAML (line 3) | const YAML = 'yaml';
constant JAVASCRIPT (line 4) | const JAVASCRIPT = 'javascript';
FILE: test/cli.js
constant COMFY_BIN (line 3) | const COMFY_BIN = '../../cli/bin/comfy.js';
constant DEFAULT_CWD (line 4) | const DEFAULT_CWD = './.env/';
constant DEFAULT_ORIGIN (line 5) | const DEFAULT_ORIGIN = 'http://localhost:3000';
Condensed preview — 145 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (246K chars).
[
{
"path": ".github/CONTRIBUTING.md",
"chars": 1336,
"preview": "Want to open a PR on comfy? Thank you! Here are a few things you need to know.\n\n# Project organisation\nThis repository i"
},
{
"path": ".github/ISSUE_TEMPLATE.md",
"chars": 576,
"preview": "Is this issue a question ? Please ask it on StackOverflow with the tag \"comfy\"\nhttps://stackoverflow.com/questions/tagge"
},
{
"path": ".github/SECURITY.md",
"chars": 485,
"preview": "# Security Policy\n\n## Supported Versions\n\nAll versions of comfygure are supported by the security policy.\n\n## Reporting "
},
{
"path": ".gitignore",
"chars": 159,
"preview": "cli/node_modules/\n\ntest/node_modules/\ntest/.pm2/\ntest/.env/\n\ndocs/_site/\ndocs/.jekyll-metadata\n\n.comfy/config\n\nsonar-pro"
},
{
"path": ".travis.yml",
"chars": 501,
"preview": "language: node_js\nnode_js:\n - '8.10'\n - '9'\n - '10'\n - '11'\n - '12'\n - '13'\n\nservices:\n - postgresq"
},
{
"path": "LICENSE",
"chars": 1065,
"preview": "MIT License\n\nCopyright (c) 2017 marmelab\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\no"
},
{
"path": "Makefile",
"chars": 487,
"preview": ".PHONY: test\n\nPWD = $(shell pwd)\n\ninstall:\n\tmake -C api install\n\tmake -C cli install\n\tmake -C test install\n\nrun:\n\t-make "
},
{
"path": "README.md",
"chars": 3347,
"preview": "[](https://badge.fury.io/js/comfygure)  {\n db.runSql(`\n CREATE EXTENSION IF NOT EXIS"
},
{
"path": "api/migrations/20170524124810-unique-tag.js",
"chars": 659,
"preview": "/* eslint-disable */\n'use strict';\n\nexports.up = function(db, cb) {\n db.runSql(\n`DELETE FROM tag\n USING version\n "
},
{
"path": "api/migrations/20200325133153-add-token-table.js",
"chars": 1883,
"preview": "/* eslint-disable */\n\"use strict\";\n\nexports.up = function (db, cb) {\n db.runSql(\n `\n DO $$\n BEGIN\n CREATE"
},
{
"path": "api/now.json",
"chars": 387,
"preview": "{\n \"name\": \"comfygure-api\",\n \"version\": 2,\n \"builds\": [\n {\n \"src\": \"build/index.js\",\n "
},
{
"path": "api/package.json",
"chars": 1288,
"preview": "{\n \"name\": \"comfygure\",\n \"version\": \"1.2.0\",\n \"license\": \"MIT\",\n \"private\": true,\n \"scripts\": {\n \""
},
{
"path": "api/serverless.yml",
"chars": 4670,
"preview": "service: comfy\n\nprovider:\n name: aws\n runtime: nodejs12.x\n stage: prod\n region: eu-west-1\n\nfunctions:\n ## PROJECTS\n"
},
{
"path": "api/src/config.js",
"chars": 1589,
"preview": "import convict from \"convict\";\n\nconst config = convict({\n port: {\n doc: \"Default port for the comfy API (default : 8"
},
{
"path": "api/src/domain/common/formats.js",
"chars": 102,
"preview": "const ENVVARS = \"envvars\";\nconst JSON = \"json\";\nconst YAML = \"yaml\";\n\nexport { ENVVARS, JSON, YAML };\n"
},
{
"path": "api/src/domain/common/states.js",
"chars": 78,
"preview": "const LIVE = \"live\";\nconst ARCHIVED = \"archived\";\n\nexport { LIVE, ARCHIVED };\n"
},
{
"path": "api/src/domain/configurations/__mocks__/tag.js",
"chars": 55,
"preview": "export const add = jest.fn(() => Promise.resolve({}));\n"
},
{
"path": "api/src/domain/configurations/add.js",
"chars": 2526,
"preview": "import hash from \"object-hash\";\n\nimport entriesQueries from \"../../queries/entries\";\nimport versionsQueries from \"../../"
},
{
"path": "api/src/domain/configurations/get.js",
"chars": 2860,
"preview": "import configurationsQueries from \"../../queries/configurations\";\nimport entriesQueries from \"../../queries/entries\";\nim"
},
{
"path": "api/src/domain/configurations/history.js",
"chars": 747,
"preview": "import getConfiguration from \"./get\";\nimport versionsQueries from \"../../queries/versions\";\n\nexport default async (proje"
},
{
"path": "api/src/domain/configurations/index.js",
"chars": 170,
"preview": "import add from \"./add\";\nimport get from \"./get\";\nimport history from \"./history\";\nimport update from \"./update\";\n\nexpor"
},
{
"path": "api/src/domain/configurations/tag.js",
"chars": 473,
"preview": "import tagsQueries from \"../../queries/tags\";\n\nexport const add = async (configurationId, versionId, name) =>\n tagsQuer"
},
{
"path": "api/src/domain/configurations/update.js",
"chars": 985,
"preview": "import hash from \"object-hash\";\n\nimport entriesQueries from \"../../queries/entries\";\nimport versionsQueries from \"../../"
},
{
"path": "api/src/domain/configurations/version.js",
"chars": 1075,
"preview": "import configurationsQueries from \"../../queries/configurations\";\nimport versionsQueries from \"../../queries/versions\";\n"
},
{
"path": "api/src/domain/environments/__mocks__/get.js",
"chars": 88,
"preview": "export const getEnvironmentOr404 = jest.fn((id, name) =>\n Promise.resolve({ name })\n);\n"
},
{
"path": "api/src/domain/environments/add.js",
"chars": 1193,
"preview": "import hash from \"object-hash\";\n\nimport environmentsQueries from \"../../queries/environments\";\nimport configurationsQuer"
},
{
"path": "api/src/domain/environments/add.spec.js",
"chars": 2315,
"preview": "import add from \"./add\";\nimport { LIVE } from \"../common/states\";\n\nimport environmentsQueries from \"../../queries/enviro"
},
{
"path": "api/src/domain/environments/get.js",
"chars": 584,
"preview": "import environmentsQueries from \"../../queries/environments\";\nimport { NotFoundError } from \"../errors\";\n\nexport const g"
},
{
"path": "api/src/domain/environments/get.spec.js",
"chars": 1369,
"preview": "import get, { getEnvironmentOr404 } from \"./get\";\n\nimport environmentsQueries from \"../../queries/environments\";\n\njest.m"
},
{
"path": "api/src/domain/environments/index.js",
"chars": 248,
"preview": "import add from \"./add\";\nimport get, { getEnvironmentOr404 } from \"./get\";\nimport remove from \"./remove\";\nimport rename "
},
{
"path": "api/src/domain/environments/remove.js",
"chars": 522,
"preview": "import environmentsQueries from \"../../queries/environments\";\nimport { ARCHIVED } from \"../common/states\";\nimport { getP"
},
{
"path": "api/src/domain/environments/remove.spec.js",
"chars": 848,
"preview": "import remove from \"./remove\";\nimport { ARCHIVED } from \"../common/states\";\n\nimport environmentsQueries from \"../../quer"
},
{
"path": "api/src/domain/environments/rename.js",
"chars": 488,
"preview": "import environmentsQueries from \"../../queries/environments\";\nimport { getProjectOr404 } from \"../projects/get\";\nimport "
},
{
"path": "api/src/domain/environments/rename.spec.js",
"chars": 887,
"preview": "import rename from \"./rename\";\n\nimport environmentsQueries from \"../../queries/environments\";\nimport { getEnvironmentOr4"
},
{
"path": "api/src/domain/errors.js",
"chars": 999,
"preview": "export class NotFoundError extends Error {\n constructor(args) {\n super(args);\n\n if (typeof args === \"string\") {\n "
},
{
"path": "api/src/domain/permissions.js",
"chars": 686,
"preview": "import tokenQueries from \"../queries/tokens\";\n\nexport const checkPermission = async (projectId, tokenKey, level) => {\n "
},
{
"path": "api/src/domain/projects/__mocks__/get.js",
"chars": 73,
"preview": "export const getProjectOr404 = jest.fn((id) => Promise.resolve({ id }));\n"
},
{
"path": "api/src/domain/projects/add.js",
"chars": 873,
"preview": "import { LIVE } from \"../common/states\";\nimport projectsQueries from \"../../queries/projects\";\nimport addEnvironment fro"
},
{
"path": "api/src/domain/projects/get.js",
"chars": 494,
"preview": "import projectsQueries from \"../../queries/projects\";\n\nimport { NotFoundError } from \"../errors\";\n\nexport const getProje"
},
{
"path": "api/src/domain/projects/get.spec.js",
"chars": 813,
"preview": "import { getProjectOr404 } from \"./get\";\n\nimport projectsQueries from \"../../queries/projects\";\n\njest.mock(\"../../querie"
},
{
"path": "api/src/domain/projects/index.js",
"chars": 224,
"preview": "import add from \"./add\";\nimport { getProjectOr404 } from \"./get\";\nimport remove from \"./remove\";\nimport rename from \"./r"
},
{
"path": "api/src/domain/projects/remove.js",
"chars": 190,
"preview": "import projectsQueries from \"../../queries/projects\";\nimport { ARCHIVED } from \"../common/states\";\n\nexport default async"
},
{
"path": "api/src/domain/projects/rename.js",
"chars": 138,
"preview": "import projectQueries from \"../../queries/projects\";\n\nexport default async (id, name) =>\n projectQueries.updateOne(id, "
},
{
"path": "api/src/domain/tags/add.js",
"chars": 597,
"preview": "import { get as getVersion } from \"../configurations/version\";\nimport tagsQueries from \"../../queries/tags\";\nimport vali"
},
{
"path": "api/src/domain/tags/move.js",
"chars": 1114,
"preview": "import { get as getVersion } from \"../configurations/version\";\n\nimport tagsQueries from \"../../queries/tags\";\nimport con"
},
{
"path": "api/src/domain/tags/remove.js",
"chars": 658,
"preview": "import tagsQueries from \"../../queries/tags\";\nimport configurationsQueries from \"../../queries/configurations\";\nimport v"
},
{
"path": "api/src/domain/tags/validator.js",
"chars": 213,
"preview": "import slug from \"slug\";\n\nexport default (name) => {\n if (slug(name) !== name) {\n throw new Error(\n `Tag name \""
},
{
"path": "api/src/domain/tokens/add.js",
"chars": 835,
"preview": "import { addDays } from \"date-fns\";\nimport { ValidationError } from \"../errors\";\nimport tokensQueries from \"../../querie"
},
{
"path": "api/src/domain/tokens/generateRandomString.js",
"chars": 527,
"preview": "export default (size, upperAlphaOnly = false) => {\n const numeric = \"0123456789\";\n const lowerAlpha = \"abcdefghijklmno"
},
{
"path": "api/src/domain/tokens/get.js",
"chars": 143,
"preview": "import tokensQueries from \"../../queries/tokens\";\n\nexport default (projectId, all = false) =>\n tokensQueries.findByProj"
},
{
"path": "api/src/domain/tokens/remove.js",
"chars": 206,
"preview": "import tokensQueries from \"../../queries/tokens\";\nimport { ARCHIVED } from \"../common/states\";\n\nexport default async (to"
},
{
"path": "api/src/domain/validation.js",
"chars": 299,
"preview": "import { getProjectOr404 } from \"./projects/get\";\nimport { getEnvironmentOr404 } from \"./environments/get\";\n\nexport cons"
},
{
"path": "api/src/handlers/configurations.js",
"chars": 1489,
"preview": "import λ from \"./utils/λ\";\nimport {\n checkAuthorizationOr403,\n parseAuthorizationToken,\n} from \"./utils/authorization\""
},
{
"path": "api/src/handlers/environments.js",
"chars": 1550,
"preview": "import λ from \"./utils/λ\";\nimport {\n checkAuthorizationOr403,\n parseAuthorizationToken,\n} from \"./utils/authorization\""
},
{
"path": "api/src/handlers/projects.js",
"chars": 1081,
"preview": "import λ from \"./utils/λ\";\nimport {\n checkAuthorizationOr403,\n parseAuthorizationToken,\n} from \"./utils/authorization\""
},
{
"path": "api/src/handlers/tags.js",
"chars": 1401,
"preview": "import λ from \"./utils/λ\";\nimport {\n checkAuthorizationOr403,\n parseAuthorizationToken,\n} from \"./utils/authorization\""
},
{
"path": "api/src/handlers/tokens.js",
"chars": 1167,
"preview": "import λ from \"./utils/λ\";\nimport {\n checkAuthorizationOr403,\n parseAuthorizationToken,\n} from \"./utils/authorization\""
},
{
"path": "api/src/handlers/utils/authorization.js",
"chars": 717,
"preview": "import { checkPermission } from \"../../domain/permissions\";\nimport { HttpError } from \"./errors\";\n\nexport const parseAut"
},
{
"path": "api/src/handlers/utils/errors.js",
"chars": 1093,
"preview": "import config from \"../../config\";\nimport { NotFoundError, ValidationError } from \"../../domain/errors\";\n\nexport class H"
},
{
"path": "api/src/handlers/utils/λ.js",
"chars": 1018,
"preview": "import co from \"co\";\nimport logger from \"../../logger\";\nimport config from \"../../config\";\n\nimport { convertErrorToHttpE"
},
{
"path": "api/src/index.js",
"chars": 3261,
"preview": "import express from \"express\";\nimport bodyParser from \"body-parser\";\n\nimport config from \"./config\";\nimport logger from "
},
{
"path": "api/src/launcher.js",
"chars": 157,
"preview": "import app from \"./index\";\nimport config from \"./config\";\n\napp.listen(config.port, () =>\n console.log(`Comfygure API li"
},
{
"path": "api/src/logger.js",
"chars": 119,
"preview": "/* eslint-disable no-console */\n\nexport default {\n log: console.log,\n info: console.info,\n error: console.error,\n};\n"
},
{
"path": "api/src/mocks.js",
"chars": 239,
"preview": "import mockQueries from \"./queries/mocks\";\nimport mockDomain from \"./domain/mocks\";\n\nconst restore = () => {\n mockQueri"
},
{
"path": "api/src/queries/__mocks__/configurations.js",
"chars": 175,
"preview": "const insertOne = jest.fn((entity) => Promise.resolve({ id: 1, ...entity }));\nconst findOne = jest.fn(() => Promise.reso"
},
{
"path": "api/src/queries/__mocks__/environments.js",
"chars": 337,
"preview": "const insertOne = jest.fn((entity) => Promise.resolve({ id: 1, ...entity }));\nconst updateOne = jest.fn((entity) => Prom"
},
{
"path": "api/src/queries/__mocks__/projects.js",
"chars": 90,
"preview": "const findOne = jest.fn((id) => Promise.resolve({ id }));\n\nexport default {\n findOne,\n};\n"
},
{
"path": "api/src/queries/__mocks__/versions.js",
"chars": 317,
"preview": "const insertOne = jest.fn((entity) => Promise.resolve({ id: 1, ...entity }));\nconst find = jest.fn(() => Promise.resolve"
},
{
"path": "api/src/queries/configurations.js",
"chars": 1910,
"preview": "import omit from \"lodash.omit\";\nimport client, {\n insertOne as insertOneQuery,\n updateOne as updateOneQuery,\n} from \"."
},
{
"path": "api/src/queries/entries.js",
"chars": 296,
"preview": "import client, { insertOne } from \"./knex\";\n\nconst table = \"entry\";\nconst fields = [\"version_id\", \"key\", \"value\"];\n\ncons"
},
{
"path": "api/src/queries/environments.js",
"chars": 657,
"preview": "import client, { insertOne, updateOne } from \"./knex\";\n\nconst table = \"environment\";\nconst fields = [\"id\", \"name\"];\n\ncon"
},
{
"path": "api/src/queries/knex.js",
"chars": 919,
"preview": "import knex from \"knex\";\n\nimport config from \"../config\";\n\nconst client = knex({\n client: \"pg\",\n connection: config.db"
},
{
"path": "api/src/queries/projects.js",
"chars": 274,
"preview": "import { findOne, insertOne, updateOne } from \"./knex\";\n\nconst table = \"project\";\n\nconst fields = [\"id\", \"name\", \"access"
},
{
"path": "api/src/queries/tags.js",
"chars": 929,
"preview": "import client, { insertOne } from \"./knex\";\n\nconst table = \"tag\";\nconst fields = [\"configuration_id\", \"version_id\", \"nam"
},
{
"path": "api/src/queries/tokens.js",
"chars": 1145,
"preview": "import client, { insertOne, updateOne } from \"./knex\";\nimport { LIVE } from \"../domain/common/states\";\n\nconst table = \"t"
},
{
"path": "api/src/queries/versions.js",
"chars": 1322,
"preview": "import { raw } from \"knex\";\nimport client, { insertOne } from \"./knex\";\n\nconst table = \"version\";\nconst fields = [\"id\", "
},
{
"path": "api/var/schema.sql",
"chars": 8456,
"preview": "--\n-- PostgreSQL database dump\n--\n\n-- Dumped from database version 9.6.17\n-- Dumped by pg_dump version 11.7 (Ubuntu 11.7"
},
{
"path": "api/webpack.config.babel.js",
"chars": 850,
"preview": "const path = require(\"path\");\nconst webpack = require(\"webpack\");\nconst slsw = require(\"serverless-webpack\");\n\nmodule.ex"
},
{
"path": "cli/.eslintrc",
"chars": 610,
"preview": "{\n \"env\": {\n \"jest\": true,\n \"node\": true,\n \"es6\": true\n },\n \"plugins\": [\"prettier\"],\n \""
},
{
"path": "cli/.npmignore",
"chars": 19,
"preview": "Makefile\n*.spec.js\n"
},
{
"path": "cli/Makefile",
"chars": 129,
"preview": "install:\n\tnpm install\n\ntest-unit:\n\t./node_modules/.bin/jest\n\ntest-unit-watch:\n\t./node_modules/.bin/jest --watch\n\ntest: t"
},
{
"path": "cli/README.md",
"chars": 3374,
"preview": "[](https://badge.fury.io/js/comfygure) ;\n\nif (nodeVersion.major < 6) {\n console.error('Comfy"
},
{
"path": "cli/package.json",
"chars": 1443,
"preview": "{\n \"name\": \"comfygure\",\n \"version\": \"1.2.0\",\n \"description\": \"Encrypted and versioned configuration store built"
},
{
"path": "cli/src/client.js",
"chars": 1872,
"preview": "module.exports = request => {\n const defaultHeaders = {\n 'Content-Type': 'application/json',\n Accept: '"
},
{
"path": "cli/src/commands/admin.js",
"chars": 2266,
"preview": "const exec = require('child_process').exec;\nconst minimist = require('minimist');\n\nconst moduleAvailable = name => {\n "
},
{
"path": "cli/src/commands/diff.js",
"chars": 3179,
"preview": "const fs = require('fs');\nconst minimist = require('minimist');\nconst { exec } = require('child_process');\n\nconst help ="
},
{
"path": "cli/src/commands/env.js",
"chars": 2251,
"preview": "const help = ui => {\n const { bold, cyan } = ui.colors;\n\n ui.print(`\n${bold('NAME')}\n comfy env - Manage co"
},
{
"path": "cli/src/commands/get.js",
"chars": 2933,
"preview": "const minimist = require('minimist');\n\nconst help = ui => {\n const { bold, cyan, dim } = ui.colors;\n\n ui.print(`\n$"
},
{
"path": "cli/src/commands/help.js",
"chars": 2066,
"preview": "module.exports = ui => () => {\n const { bold, dim, gray, cyan } = ui.colors;\n\n ui.print(`\n${bold('NAME')}\n "
},
{
"path": "cli/src/commands/init.js",
"chars": 2996,
"preview": "const fs = require('fs');\nconst path = require('path');\nconst minimist = require('minimist');\n\nconst help = ui => {\n "
},
{
"path": "cli/src/commands/log.js",
"chars": 1681,
"preview": "const minimist = require('minimist');\n\nconst help = ui => {\n const { bold } = ui.colors;\n\n ui.print(`\n${bold('NAME"
},
{
"path": "cli/src/commands/project.js",
"chars": 3213,
"preview": "const minimist = require('minimist');\n\nconst help = ui => {\n const { bold, cyan } = ui.colors;\n\n ui.print(`\n${bold"
},
{
"path": "cli/src/commands/set.js",
"chars": 2494,
"preview": "const minimist = require('minimist');\nconst set = require('lodash.set');\n\nconst { parseFlat } = require('../format');\n\nc"
},
{
"path": "cli/src/commands/setall.js",
"chars": 2857,
"preview": "const fs = require('fs');\nconst path = require('path');\nconst minimist = require('minimist');\nconst { parseYAML, guessFo"
},
{
"path": "cli/src/commands/tag.js",
"chars": 5744,
"preview": "const minimist = require(\"minimist\");\n\nconst help = (ui) => {\n const { bold, dim, cyan } = ui.colors;\n\n ui.print(`\n${b"
},
{
"path": "cli/src/commands/token.js",
"chars": 5434,
"preview": "const minimist = require(\"minimist\");\nconst humanize = require(\"humanize-duration\");\n\nconst help = (ui) => {\n const { b"
},
{
"path": "cli/src/commands/version.js",
"chars": 120,
"preview": "const printVersion = require('../domain/printVersion');\n\nmodule.exports = ui => {\n printVersion();\n ui.exit();\n};\n"
},
{
"path": "cli/src/crypto/index.js",
"chars": 2351,
"preview": "const crypto = require('crypto');\nconst { serialize, unserialize } = require('./serialization');\nconst { sign, isSignatu"
},
{
"path": "cli/src/crypto/index.spec.js",
"chars": 1952,
"preview": "const { encrypt, decrypt, generateNewPrivateKey, generateNewHmacKey } = require('./');\n\ndescribe('Crypto Features', () ="
},
{
"path": "cli/src/crypto/serialization.js",
"chars": 113,
"preview": "const serialize = JSON.stringify;\n\nconst unserialize = JSON.parse;\n\nmodule.exports = { serialize, unserialize };\n"
},
{
"path": "cli/src/crypto/serialization.spec.js",
"chars": 897,
"preview": "const { serialize, unserialize } = require('./serialization');\n\ndescribe('Serialization', () => {\n it('should keep th"
},
{
"path": "cli/src/crypto/signature.js",
"chars": 452,
"preview": "const crypto = require('crypto');\n\nconst ALGORITHM = 'SHA256';\n\nconst sign = (cipherText, iv, hmacKey) => {\n const hm"
},
{
"path": "cli/src/domain/config.js",
"chars": 3844,
"preview": "const { parseFlat, toJSON, toYAML, toEnvVars, toJavascript, toFlat } = require('../format');\nconst { JSON, YAML, JAVASCR"
},
{
"path": "cli/src/domain/constants.js",
"chars": 276,
"preview": "const CONFIG_FOLDER = '.comfy';\nconst CONFIG_PATH = '.comfy/config';\nconst DEFAULT_ORIGIN = 'https://comfy.marmelab.com'"
},
{
"path": "cli/src/domain/environment.js",
"chars": 769,
"preview": "module.exports = (client, ui) => {\n const list = function*(project) {\n const url = `${project.origin}/projects"
},
{
"path": "cli/src/domain/printVersion.js",
"chars": 159,
"preview": "const { name, version } = require('../../package.json');\n\nmodule.exports = () => {\n console.log(`${name} ${version}`)"
},
{
"path": "cli/src/domain/project.js",
"chars": 5355,
"preview": "const fs = require('fs');\nconst ini = require('ini');\nconst path = require('path');\nconst { CONFIG_FOLDER, CONFIG_PATH, "
},
{
"path": "cli/src/domain/project.spec.js",
"chars": 2057,
"preview": "const projectFactory = require('./project');\nconst { CREDENTIALS_VARIABLE } = require('./constants');\n\nconst client = nu"
},
{
"path": "cli/src/domain/tag.js",
"chars": 1491,
"preview": "module.exports = (client, ui) => {\n const add = function*(project, environment, configName, name, selector) {\n "
},
{
"path": "cli/src/domain/token.js",
"chars": 1080,
"preview": "module.exports = (client, ui) => {\n const list = function*(project, all = false) {\n let url = `${project.origin}/pro"
},
{
"path": "cli/src/format/constants.js",
"chars": 174,
"preview": "const ENVVARS = 'envvars';\nconst JSON = 'json';\nconst YAML = 'yaml';\nconst JAVASCRIPT = 'javascript';\n\nmodule.exports = "
},
{
"path": "cli/src/format/guessFormat.js",
"chars": 293,
"preview": "const { JSON, YAML, ENVVARS } = require('./constants');\n\nmodule.exports = ext => {\n switch ((ext || '').toLowerCase()"
},
{
"path": "cli/src/format/guessFormat.spec.js",
"chars": 457,
"preview": "const { JSON, YAML, ENVVARS } = require('./constants');\nconst guessFormat = require('./guessFormat');\n\ndescribe('Format "
},
{
"path": "cli/src/format/index.js",
"chars": 1487,
"preview": "const deepSet = require('lodash.set');\nconst yaml = require('js-yaml');\nconst toFlat = require('./toFlat');\nconst guessF"
},
{
"path": "cli/src/format/index.spec.js",
"chars": 1529,
"preview": "const { toEnvVars, toJavascript } = require('./');\n\ndescribe('Format', () => {\n describe('toEnvVars', () => {\n "
},
{
"path": "cli/src/format/toFlat.js",
"chars": 980,
"preview": "const deepGet = require('lodash.get');\n\nconst traverse = (obj, parentKey = '') => {\n const list = [];\n\n if (Array."
},
{
"path": "cli/src/index.js",
"chars": 1945,
"preview": "/* eslint-disable global-require */\nconst clientFactory = require(\"./client\");\nconst projectModuleFactory = require(\"./d"
},
{
"path": "cli/src/ui/console.js",
"chars": 1913,
"preview": "const chalk = require(\"chalk\");\nconst readline = require(\"readline\");\nconst Table = require(\"cli-table\");\n\nconst print ="
},
{
"path": "docs/HostYourOwn.md",
"chars": 3955,
"preview": "---\nlayout: default\ntitle: 'Host Your Own Comfygure Origin Server'\n---\n\n# Host Your Own Comfygure Origin Server\n\nMarmela"
},
{
"path": "docs/HowItWorks.md",
"chars": 2127,
"preview": "---\nlayout: default\ntitle: 'How It Works'\n---\n\n## The Problem: Managing Application Settings\n\nHow do you store the setti"
},
{
"path": "docs/Usage.md",
"chars": 6263,
"preview": "---\nlayout: default\ntitle: 'Usage'\n---\n\n## Initialization\n\nInitialize comfygure in a project directory with `comfy init`"
},
{
"path": "docs/_config.yml",
"chars": 91,
"preview": "name: comfygure documentation\nmarkdown: kramdown\nkramdown:\n input: GFM\nhighlighter: rouge\n"
},
{
"path": "docs/_layouts/default.html",
"chars": 6336,
"preview": "<!DOCTYPE HTML>\n<html lang=\"en-US\">\n\n<head>\n <title>Comfygure - {{ page.title }}</title>\n <meta charset=\"UTF-8\">\n "
},
{
"path": "docs/css/normalize.css",
"chars": 8026,
"preview": "/*! normalize.css v4.2.0 | MIT License | github.com/necolas/normalize.css */\n\n/**\n * 1. Change the default font family i"
},
{
"path": "docs/css/style.css",
"chars": 11055,
"preview": ".book-summary {\n font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;\n position: absolute;\n top: 0;\n "
},
{
"path": "docs/css/syntax.css",
"chars": 4324,
"preview": ".highlight .c {\n /* Comment */\n color: #999988;\n font-style: italic;\n}\n.highlight .err {\n /* Error */\n co"
},
{
"path": "docs/index.md",
"chars": 3905,
"preview": "---\nlayout: default\ntitle: 'Documentation'\n---\n\n<p><a href=\"https://badge.fury.io/js/comfygure\"><img class=\"no-margin\" s"
},
{
"path": "package.json",
"chars": 591,
"preview": "{\n \"name\": \"comfygure\",\n \"version\": \"1.2.0\",\n \"description\": \"Configure without the pain\",\n \"main\": \"index.js\",\n \"p"
},
{
"path": "test/Makefile",
"chars": 1033,
"preview": "export CI ?= false\nexport PM2_HOME ?= .pm2\n\ninstall:\n\tnpm install\n\nrun-test-api:\nifeq (${CI},false)\n\techo 'Starting test"
},
{
"path": "test/cli.js",
"chars": 730,
"preview": "const { exec } = require('child_process');\n\nconst COMFY_BIN = '../../cli/bin/comfy.js';\nconst DEFAULT_CWD = './.env/';\nc"
},
{
"path": "test/package.json",
"chars": 198,
"preview": "{\n \"name\": \"comfygure-test\",\n \"private\": true,\n \"devDependencies\": {\n \"co-mocha\": \"^1.2.2\",\n \"expect\": \"^25.2.7"
},
{
"path": "test/pm2_configuration.json",
"chars": 328,
"preview": "{\n \"apps\": [\n {\n \"name\": \"comfy-api\",\n \"cwd\": \"../api\",\n \"script\": \"node_modu"
},
{
"path": "test/setup.js",
"chars": 769,
"preview": "const fs = require('fs');\nconst path = require('path');\n\nconst deleteFolderContent = function* (dir, deleteFolder = fals"
},
{
"path": "test/specs/basicUsage.js",
"chars": 2885,
"preview": "const expect = require('expect');\nconst { run, createProject } = require('../cli');\n\ndescribe('Basic Usages', () => {\n "
},
{
"path": "test/specs/commands.js",
"chars": 13360,
"preview": "const expect = require(\"expect\");\nconst { run, createProject } = require(\"../cli\");\n\ndescribe(\"Commands\", () => {\n befo"
},
{
"path": "test/specs/formats.js",
"chars": 1272,
"preview": "const expect = require('expect');\nconst yaml = require('js-yaml');\nconst { run, createProject } = require('../cli');\n\nde"
},
{
"path": "test/specs/init.js",
"chars": 2337,
"preview": "const expect = require('expect');\nconst run = require('../cli').run;\n\ndescribe('Project initialization', () => {\n it("
},
{
"path": "test/specs/scenarios.js",
"chars": 2028,
"preview": "const expect = require('expect');\nconst { run, createProject } = require('../cli');\n\ndescribe('Scenarios', () => {\n b"
}
]
About this extraction
This page contains the full source code of the marmelab/comfygure GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 145 files (222.2 KB), approximately 58.3k tokens, and a symbol index with 34 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.