forj 0.0.34 → 0.0.35

Sign up to get free protection for your applications and to get access to all the features.
data/lib/repositories.rb CHANGED
@@ -22,8 +22,8 @@ require 'require_relative'
22
22
 
23
23
  require_relative 'yaml_parse.rb'
24
24
  include YamlParse
25
- require_relative 'log.rb'
26
- include Logging
25
+ #require_relative 'log.rb'
26
+ #include Logging
27
27
 
28
28
  #
29
29
  # Repositories module
@@ -40,18 +40,17 @@ module Repositories
40
40
  if File.directory?(path + 'maestro')
41
41
  FileUtils.rm_r path + 'maestro'
42
42
  end
43
- Logging.info('cloning the maestro repo')
44
43
  Git.clone(maestro_url, 'maestro', :path => path)
45
44
  end
46
45
  rescue => e
46
+ Logging.error("%s\n%s" % [e.message, e.backtrace.join("\n")])
47
47
  puts 'Error while cloning the repo from %s' % [maestro_url]
48
48
  puts 'If this error persist you could clone the repo manually in ~/.forj/'
49
- Logging.error(e.message)
50
49
  end
51
50
  Dir.chdir(current_dir)
52
51
  end
53
-
54
- def create_infra
52
+
53
+ def create_infra(maestro_repo)
55
54
  home = File.expand_path('~')
56
55
  path = home + '/.forj/'
57
56
  infra = path + 'infra/'
@@ -64,9 +63,7 @@ module Repositories
64
63
  command = 'cp -rp ~/.forj/maestro/templates/infra/cloud-init ~/.forj/infra/'
65
64
  Kernel.system(command)
66
65
 
67
- Dir.chdir('build_tmpl')
68
-
69
- fill_template = 'python build-env.py -p ~/.forj/infra --maestro-path ~/.forj/maestro'
66
+ fill_template = 'python %s/build_tmpl/build-env.py -p ~/.forj/infra --maestro-path %s' % [$LIB_PATH, maestro_repo]
70
67
  Kernel.system(fill_template)
71
68
  end
72
69
  end
data/lib/security.rb CHANGED
@@ -18,65 +18,68 @@
18
18
  require 'rubygems'
19
19
  require 'require_relative'
20
20
 
21
- require_relative 'connection.rb'
22
- include Connection
23
- require_relative 'log.rb'
24
- include Logging
25
-
26
21
  #
27
22
  # SecurityGroup module
28
23
  #
29
24
  module SecurityGroup
30
25
 
31
- def get_or_create_security_group(name)
32
- Logging.info('getting or creating security group for %s' % [name])
33
- security_group = get_security_group(name)
34
- if security_group == nil
35
- security_group = create_security_group(name)
36
- end
26
+ def get_or_create_security_group(oFC, name)
27
+ Logging.state("Searching for security group '%s'..." % [name])
28
+ security_group = get_security_group(oFC, name)
29
+ security_group = create_security_group(oFC, name) if not security_group
37
30
  security_group
38
31
  end
39
32
 
40
- def create_security_group(name)
41
- sec_group = nil
33
+ def create_security_group(oFC, name)
34
+ Logging.debug("creating security group '%s'" % [name])
42
35
  begin
43
- sec_groups = get_security_group(name)
44
- if sec_groups.length >= 1
45
- sec_group = sec_groups[0]
46
- else
47
- description = 'Security group for blueprint %s' % [name]
48
- Logging.info(description)
49
- sec_group = Connection.network.security_groups.create(
36
+ description = "Security group for blueprint '%s'" % [name]
37
+ oFC.oNetwork.security_groups.create(
50
38
  :name => name,
51
39
  :description => description
52
40
  )
53
- end
54
41
  rescue => e
55
- Logging.error(e.message)
42
+ Logging.error("%s\n%s" % [e.message, e.backtrace.join("\n")])
56
43
  end
57
- sec_group
58
44
  end
59
45
 
60
- def get_security_group(name)
46
+ def get_security_group(oFC, name)
47
+ Logging.state("Searching for security group '%s'" % [name])
48
+ oSSLError=SSLErrorMgt.new
61
49
  begin
62
- Connection.network.security_groups.all({:name => name})[0]
50
+ sgroups = oFC.oNetwork.security_groups.all({:name => name})
63
51
  rescue => e
64
- Logging.error(e.message)
52
+ if not oSSLError.ErrorDetected(e.message,e.backtrace)
53
+ retry
54
+ end
55
+ end
56
+ case sgroups.length()
57
+ when 0
58
+ Logging.debug("No security group '%s' found" % [name] )
59
+ nil
60
+ when 1
61
+ Logging.debug("Found security group '%s'" % [sgroups[0].name])
62
+ sgroups[0]
65
63
  end
66
64
  end
67
65
 
68
- def delete_security_group(security_group)
66
+ def delete_security_group(oFC, security_group)
67
+ oSSLError=SSLErrorMgt.new
69
68
  begin
70
- sec_group = get_security_group(security_group)
71
- Connection.network.security_groups.get(sec_group.id).destroy
69
+ sec_group = get_security_group(oFC, security_group)
70
+ oFC.oNetwork.security_groups.get(sec_group.id).destroy
72
71
  rescue => e
73
- Logging.error(e.message)
72
+ if not oSSLError.ErrorDetected(e.message,e.backtrace)
73
+ retry
74
+ end
74
75
  end
75
76
  end
76
77
 
77
- def create_security_group_rule(security_group_id, protocol, port_min, port_max)
78
+ def create_security_group_rule(oFC, security_group_id, protocol, port_min, port_max)
79
+ Logging.debug("Creating ingress rule '%s:%s - %s to 0.0.0.0/0'" % [protocol, port_min, port_max])
80
+ oSSLError=SSLErrorMgt.new
78
81
  begin
79
- Connection.network.security_group_rules.create(
82
+ oFC.oNetwork.security_group_rules.create(
80
83
  :security_group_id => security_group_id,
81
84
  :direction => 'ingress',
82
85
  :protocol => protocol,
@@ -85,33 +88,49 @@ module SecurityGroup
85
88
  :remote_ip_prefix => '0.0.0.0/0'
86
89
  )
87
90
  rescue StandardError => e
91
+ if not oSSLError.ErrorDetected(e.message,e.backtrace)
92
+ retry
93
+ end
88
94
  msg = 'error creating the rule for port %s' % [port_min]
89
- puts msg
90
- Logging.error(e.message)
95
+ Logging.error msg
91
96
  end
92
97
  end
93
98
 
94
- def delete_security_group_rule(rule_id)
99
+ def delete_security_group_rule(oFC, rule_id)
100
+ oSSLError=SSLErrorMgt.new
95
101
  begin
96
- Connection.network.security_group_rules.get(rule_id).destroy
102
+ oFC.oNetwork.security_group_rules.get(rule_id).destroy
97
103
  rescue => e
98
- Logging.error(e.message)
104
+ if not oSSLError.ErrorDetected(e.message,e.backtrace)
105
+ retry
106
+ end
99
107
  end
100
108
  end
101
109
 
102
- def get_security_group_rule(port)
110
+ def get_security_group_rule(oFC, security_group_id, port_min, port_max)
111
+ Logging.state("Searching for rule '%s - %s'" % [ port_min, port_max])
112
+ oSSLError = SSLErrorMgt.new
103
113
  begin
104
- Connection.network.security_group_rules.all({:port_range_min => port, :port_range_max => port})[0]
105
- rescue => e
106
- Logging.error(e.message)
114
+ sgroups = oFC.oNetwork.security_group_rules.all({:port_range_min => port_min, :port_range_max => port_max, :security_group_id => security_group_id})
115
+ case sgroups.length()
116
+ when 0
117
+ Logging.debug("No security rule '%s - %s' found" % [ port_min, port_max ] )
118
+ nil
119
+ else
120
+ Logging.debug("Found security rule '%s - %s'." % [ port_min, port_max ])
121
+ sgroups
122
+ end
123
+ rescue => e
124
+ if not oSSLError.ErrorDetected(e.message,e.backtrace)
125
+ retry
126
+ end
107
127
  end
108
128
  end
109
129
 
110
- def get_or_create_rule(security_group_id, protocol, port_min, port_max)
111
- Logging.info('getting or creating rule %s' % [port_min])
112
- rule = get_security_group_rule(port_min)
113
- if rule == nil
114
- rule = create_security_group_rule(security_group_id, protocol, port_min, port_max)
130
+ def get_or_create_rule(oFC, security_group_id, protocol, port_min, port_max)
131
+ rule = get_security_group_rule(oFC, security_group_id, port_min, port_max)
132
+ if not rule
133
+ rule = create_security_group_rule(oFC, security_group_id, protocol, port_min, port_max)
115
134
  end
116
135
  rule
117
136
  end
data/lib/setup.rb CHANGED
@@ -28,54 +28,92 @@ include Helpers
28
28
  # Setup module call the hpcloud functions
29
29
  #
30
30
  module Setup
31
- def setup
32
- # delegate the initial configuration to hpcloud (unix_cli)
33
- Kernel.system('hpcloud account:setup')
34
- setup_credentials
35
- save_cloud_fog
36
- #Kernel.system('hpcloud keypairs:add nova')
31
+ def setup(sProvider, oConfig, options )
32
+ begin
33
+
34
+ raise 'No provider specified.' if not sProvider
35
+
36
+ sAccountName = sProvider # By default, the account name uses the same provider name.
37
+ sAccountName = options[:account_name] if options[:account_name]
38
+
39
+ if sProvider != 'hpcloud'
40
+ raise "forj setup support only hpcloud. '%s' is currently not supported." % sProvider
41
+ end
42
+
43
+ # TODO: Support of multiple providers thanks to fog.
44
+ # TODO: Replace this code by our own forj account setup, inspired/derived from hpcloud account::setup
45
+
46
+ # delegate the initial configuration to hpcloud (unix_cli)
47
+ hpcloud_data=File.expand_path('~/.hpcloud/accounts')
48
+ if File.exists?(File.join(hpcloud_data, 'hp')) and not File.exists?(File.join(hpcloud_data, sAccountName)) and sAccountName != 'hp'
49
+ Logging.info("hpcloud: Copying 'hp' account setup to '%s'" % sAccountName)
50
+ Kernel.system('hpcloud account:copy hp %s' % [sAccountName])
51
+ end
52
+
53
+ case Kernel.system('hpcloud account:setup %s' % [sAccountName] )
54
+ when false
55
+ raise "Unable to setup your hpcloud account"
56
+ when nil
57
+ raise "Unable to execute 'hpcloud' cli. Please check hpcloud installation."
58
+ end
59
+
60
+ if not oConfig.yConfig['default'].has_key?('account')
61
+ oConfig.LocalSet('account',sAccountName)
62
+ oConfig.SaveConfig
63
+ end
64
+
65
+ # Implementation of simple credential encoding for build.sh/maestro
66
+ save_maestro_creds(sAccountName)
67
+ rescue RuntimeError => e
68
+ Logging.fatal(1,e.message)
69
+ rescue => e
70
+ Logging.fatal(1,"%s\n%s" % [e.message,e.backtrace.join("\n")])
71
+ end
37
72
  end
38
73
  end
39
74
 
40
- def setup_credentials
41
- hpcloud_os_user = ask('Enter hpcloud username: ')
42
- hpcloud_os_key = ask('Enter hpcloud password: ') { |q| q.echo = '*'}
75
+ def save_maestro_creds(sAccountName)
43
76
 
44
- home = File.expand_path('~')
45
- Helpers.create_directory('%s/.cache/forj/' % [home])
46
- creds = '%s/.cache/forj/creds' % [home]
47
-
48
- values = {:credentials => {:hpcloud_os_user=> hpcloud_os_user, :hpcloud_os_key=> hpcloud_os_key}}
77
+ # TODO Be able to load the previous username if the g64 file exists.
78
+ hpcloud_os_user = ask('Enter hpcloud username: ') do |q|
79
+ q.validate = /\w+/
80
+ q.default = ''
81
+ end
49
82
 
50
- YamlParse.dump_values(values, creds)
83
+ hpcloud_os_key = ask('Enter hpcloud password: ') do |q|
84
+ q.echo = '*'
85
+ q.validate = /.+/
86
+ end
51
87
 
52
- end
88
+ add_creds = {:credentials => {:hpcloud_os_user=> hpcloud_os_user, :hpcloud_os_key=> hpcloud_os_key}}
53
89
 
90
+ sForjCache=File.expand_path('~/.cache/forj/')
91
+ cloud_fog = '%s/%s.g64' % [sForjCache, sAccountName]
54
92
 
55
- def save_cloud_fog
56
- home = File.expand_path('~')
57
93
 
58
- cloud_fog = '%s/.cache/forj/master.forj-13.5' % [home]
59
- local_creds = '%s/.cache/forj/creds' % [home]
94
+ Helpers.create_directory(sForjCache) if not File.directory?(sForjCache)
60
95
 
61
- creds = '%s/.hpcloud/accounts/hp' % [home]
62
- template = YAML.load_file(creds)
63
- local_template = YAML.load_file(local_creds)
96
+ # Security fix: Remove old temp file with clear password.
97
+ old_file = '%s/master.forj-13.5' % [sForjCache]
98
+ File.delete(old_file) if File.exists?(old_file)
99
+ old_file = '%s/creds' % [sForjCache]
100
+ File.delete(old_file) if File.exists?(old_file)
64
101
 
102
+ hpcloud_creds = File.expand_path('~/.hpcloud/accounts/%s' % [sAccountName])
103
+ creds = YAML.load_file(hpcloud_creds)
65
104
 
66
- access_key = template[:credentials][:account_id]
67
- secret_key = template[:credentials][:secret_key]
105
+ access_key = creds[:credentials][:account_id]
106
+ secret_key = creds[:credentials][:secret_key]
68
107
 
69
- os_user = local_template[:credentials][:hpcloud_os_user]
70
- os_key = local_template[:credentials][:hpcloud_os_key]
108
+ os_user = add_creds[:credentials][:hpcloud_os_user]
109
+ os_key = add_creds[:credentials][:hpcloud_os_key]
71
110
 
72
- File.open(cloud_fog, 'w') {|file|
73
- file.write('HPCLOUD_OS_USER=%s' % [os_user] + "\n")
74
- file.write('HPCLOUD_OS_KEY=%s' % [os_key] + "\n")
75
- file.write('DNS_KEY=%s' % [access_key] + "\n")
76
- file.write('DNS_SECRET=%s' % [secret_key])
111
+ IO.popen('gzip -c | base64 -w0 > %s' % [cloud_fog], 'r+') {|pipe|
112
+ pipe.puts('HPCLOUD_OS_USER=%s' % [os_user] )
113
+ pipe.puts('HPCLOUD_OS_KEY=%s' % [os_key] )
114
+ pipe.puts('DNS_KEY=%s' % [access_key] )
115
+ pipe.puts('DNS_SECRET=%s' % [secret_key])
116
+ pipe.close_write
77
117
  }
78
-
79
- command = 'cat %s | gzip -c | base64 -w0 > %s.g64' % [cloud_fog, cloud_fog]
80
- Kernel.system(command)
81
- end
118
+ Logging.info("'%s' written." % cloud_fog)
119
+ end
data/lib/ssh.rb CHANGED
@@ -18,8 +18,8 @@
18
18
  require 'rubygems'
19
19
  require 'require_relative'
20
20
 
21
- require_relative 'log.rb'
22
- include Logging
21
+ #require_relative 'log.rb'
22
+ #include Logging
23
23
 
24
24
  #
25
25
  # ssh module
@@ -40,4 +40,4 @@ module Ssh
40
40
  # connect to the server
41
41
  Kernel.system(connection)
42
42
  end
43
- end
43
+ end
data/lib/yaml_parse.rb CHANGED
@@ -17,8 +17,8 @@
17
17
 
18
18
  require 'rubygems'
19
19
  require 'yaml'
20
- require_relative 'log.rb'
21
- include Logging
20
+ #require_relative 'log.rb'
21
+ #include Logging
22
22
 
23
23
  #
24
24
  # YamlParse module
@@ -29,7 +29,7 @@ module YamlParse
29
29
  Logging.info('getting values from defaults.yaml, this will be a service catalog.forj.io')
30
30
  YAML.load_file(path_to_yaml)
31
31
  rescue => e
32
- Logging.error(e.message)
32
+ Logging.error("%s\n%s" % [e.message, e.backtrace.join("\n")])
33
33
  end
34
34
  end
35
35
 
@@ -39,7 +39,7 @@ module YamlParse
39
39
  YAML.dump(string, out)
40
40
  end
41
41
  rescue => e
42
- Logging.error(e.message)
42
+ Logging.error("%s\n%s" % [e.message, e.backtrace.join("\n")])
43
43
  end
44
44
  end
45
45
  end
@@ -20,37 +20,29 @@ require 'spec_helper'
20
20
 
21
21
  require 'fog'
22
22
 
23
- require_relative '../lib/connection.rb'
24
- include Connection
23
+ $APP_PATH = File.dirname(__FILE__)
24
+ $LIB_PATH = File.expand_path(File.join(File.dirname($APP_PATH),'lib'))
25
+ $FORJ_DATA_PATH= File.expand_path('~/.forj')
25
26
 
26
- class TestClass
27
- end
27
+ $LOAD_PATH << './lib'
28
28
 
29
- describe 'network' do
30
- it 'is connecting to hpcloud' do
31
- @test_class = TestClass.new
32
- @test_class.extend(Connection)
29
+ require 'forj-config.rb' # Load class ForjConfig
30
+ require 'log.rb' # Load default loggers
33
31
 
34
- Fog.mock!
32
+ include Logging
35
33
 
36
- conn = @test_class.network
37
- expect(conn).to be
34
+ $FORJ_LOGGER=ForjLog.new('forj-rspec.log', Logger::FATAL)
38
35
 
39
- Fog::Mock.reset
40
- end
41
- end
36
+ require 'connection.rb' # Load class ForjConnection
42
37
 
38
+ describe 'Module: forj-connection' do
43
39
 
44
- describe 'compute' do
45
- it 'is connecting to hpcloud' do
46
- @test_class = TestClass.new
47
- @test_class.extend(Connection)
40
+ it 'should connect to hpcloud (smoke test)' do
48
41
 
49
42
  Fog.mock!
50
-
51
- conn = @test_class.compute
43
+ conn = ForjConnection.new(ForjConfig.new())
52
44
  expect(conn).to be
53
45
 
54
46
  Fog::Mock.reset
55
47
  end
56
- end
48
+ end
@@ -15,17 +15,21 @@
15
15
  # See the License for the specific language governing permissions and
16
16
  # limitations under the License.
17
17
 
18
- class TestClass
19
- end
20
-
21
- require_relative '../lib/forj-config.rb'
22
18
  $APP_PATH = File.dirname(__FILE__)
23
19
  $LIB_PATH = File.expand_path(File.join(File.dirname($APP_PATH),'lib'))
24
20
  $FORJ_DATA_PATH= File.expand_path('~/.forj')
25
21
 
26
- describe 'forj cli' do
27
- describe ".forj-config" do
28
- context "new instance" do
22
+ $LOAD_PATH << './lib'
23
+
24
+ require 'forj-config.rb' # Load class ForjConfig
25
+ require 'log.rb' # Load default loggers
26
+
27
+ include Logging
28
+
29
+ $FORJ_LOGGER=ForjLog.new('forj-rspec.log', Logger::FATAL)
30
+
31
+ describe "class: forj-config" do
32
+ context "when creating a new instance" do
29
33
 
30
34
  it 'should be loaded' do
31
35
  @test_config=ForjConfig.new()
@@ -34,46 +38,46 @@ describe 'forj cli' do
34
38
 
35
39
  end
36
40
 
37
- context "Config in memory" do
41
+ context "when starting, forj-config" do
38
42
  before(:all) do
39
43
  @config=ForjConfig.new()
40
44
  end
41
45
 
42
46
  it 'should be able to create a key/value in local config' do
43
- @config.ConfigSet('test1','value')
44
- @config.yConfig['default']['test1'].should == 'value'
47
+ @config.LocalSet('test1','value')
48
+ expect(@config.yConfig['default']['test1']).to eq('value')
45
49
  end
46
50
 
47
51
  it 'should be able to remove the previously created key/value from local config' do
48
- @config.ConfigDel('test1')
49
- @config.yConfig['default'].key?('test1').should == false
52
+ @config.LocalDel('test1')
53
+ expect(@config.yConfig['default'].key?('test1')).to equal(false)
50
54
  end
51
55
  end
52
56
 
53
57
 
54
- context "Updating local config file" do
58
+ context "while updating local config file, forj-config" do
55
59
  before(:all) do
56
60
  @config=ForjConfig.new()
57
61
  end
58
62
 
59
63
  after(:all) do
60
- @config.ConfigDel('test1')
64
+ @config.LocalDel('test1')
61
65
  @config.SaveConfig()
62
66
  end
63
67
 
64
68
  it 'should save a key/value in local config' do
65
- @config.ConfigSet('test1','value')
66
- @config.SaveConfig().should == true
69
+ @config.LocalSet('test1','value')
70
+ expect(@config.SaveConfig()).to equal(true)
67
71
  end
68
72
 
69
73
  it 'should get the saved value from local config' do
70
74
  oConfig=ForjConfig.new()
71
- @config.yConfig['default']['test1'].should == 'value'
75
+ expect(@config.yConfig['default']['test1']).to eq('value')
72
76
  end
73
77
 
74
78
  end
75
79
 
76
- context "Updating another config file from .forj" do
80
+ context "With another config file - test1.yaml, forj-config" do
77
81
 
78
82
  before(:all) do
79
83
  if File.exists?('~/.forj/test.yaml')
@@ -88,25 +92,26 @@ describe 'forj cli' do
88
92
  File.delete(File.expand_path('~/.forj/test1.yaml'))
89
93
  end
90
94
 
91
- it 'If file do not exist, warning! and file is not created.' do
92
- File.exists?(File.expand_path('~/.forj/test.yaml')).should == false
95
+ it 'won\'t create a new file If we request to load \'test.yaml\'' do
96
+ expect(File.exists?(File.expand_path('~/.forj/test.yaml'))).to equal(false)
93
97
  end
94
98
 
95
- it 'Then, default config is loaded.' do
96
- File.basename(@config.sConfigName).should == 'config.yaml'
99
+ it 'will load the default config file if we request to load \'test.yaml\'' do
100
+ expect(File.basename(@config.sConfigName)).to eq('config.yaml')
97
101
  end
98
102
 
99
- it 'test1.yaml config is loaded.' do
100
- File.basename(@config2.sConfigName).should == 'test1.yaml'
103
+ it 'will confirm \'test1.yaml\' config to be loaded.' do
104
+ expect(File.basename(@config2.sConfigName)).to eq('test1.yaml')
101
105
  end
102
106
 
103
- it 'should save a key/value in test2 config' do
104
- @config2.ConfigSet('test2','value')
105
- @config2.SaveConfig().should == true
107
+ it 'can save \'test2=value\'' do
108
+ @config2.LocalSet('test2','value')
109
+ expect(@config2.SaveConfig()).to equal(true)
110
+ config3=ForjConfig.new('test1.yaml')
111
+ expect(config3.yConfig['default']['test2']).to eq('value')
106
112
  end
107
113
 
108
114
 
109
115
  end
110
- end
111
116
 
112
117
  end
@@ -18,6 +18,7 @@
18
18
  require 'rubygems'
19
19
  require 'spec_helper'
20
20
 
21
+ require 'ansi'
21
22
  require 'fog'
22
23
 
23
24
  require_relative '../lib/repositories.rb'
@@ -31,7 +32,7 @@ describe 'repositories' do
31
32
  @test_class = TestClass.new
32
33
  @test_class.extend(Repositories)
33
34
 
34
- repo = @test_class.clone_repo('https://github.com/forj-oss/maestro')
35
+ repo = @test_class.clone_repo('https://github.com/forj-oss/cli')
35
36
  expect(repo).to be
36
37
  end
37
38
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forj
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.34
4
+ version: 0.0.35
5
5
  platform: ruby
6
6
  authors:
7
7
  - forj team
@@ -108,6 +108,20 @@ dependencies:
108
108
  - - ~>
109
109
  - !ruby/object:Gem::Version
110
110
  version: 1.6.21
111
+ - !ruby/object:Gem::Dependency
112
+ name: ansi
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - '>='
116
+ - !ruby/object:Gem::Version
117
+ version: 1.4.3
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - '>='
123
+ - !ruby/object:Gem::Version
124
+ version: 1.4.3
111
125
  description: forj command line
112
126
  email:
113
127
  - forj@forj.io