wisco 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ require 'fileutils'
2
+ require 'base64'
3
+ require 'json'
4
+ require_relative '../config'
5
+ require_relative '../connector'
6
+ require_relative '../workato_api'
7
+
8
+ module Wisco
9
+ module Commands
10
+ module Pull
11
+ VALID_WHAT = %w[all logo code versions meta].freeze
12
+ LOGO_FILENAME = 'logo.png'.freeze
13
+ VERSION_KEYS = %w[latest_version latest_version_note latest_released_version
14
+ latest_released_version_note latest_shared_version
15
+ latest_shared_version_note oem_shared_version
16
+ oem_shared_at recent_released_versions].freeze
17
+
18
+ module_function
19
+
20
+ def run(target_dir, what:, title:, debug:)
21
+ target_dir = File.expand_path(target_dir)
22
+ config_path = Wisco.config_path(target_dir)
23
+
24
+ unless File.exist?(config_path)
25
+ warn "Error: No #{Wisco::WISCO_DIR}/#{Wisco::CONFIG_FILENAME} found in #{target_dir}."
26
+ warn " Run '#{Wisco::CLI_NAME} init' first."
27
+ exit 1
28
+ end
29
+
30
+ config = Wisco::Config.load_config(config_path)
31
+ config = Wisco::Config.ensure_api_config(config, config_path)
32
+ what_list = parse_what(what)
33
+ title ||= derive_title(target_dir)
34
+
35
+ pull_dir = File.join(target_dir, Wisco::WISCO_DIR, 'pull')
36
+ FileUtils.mkdir_p(pull_dir)
37
+
38
+ api = Wisco::WorkatoApi.new(
39
+ hostname: config.dig('workato_developer_api', 'hostname'),
40
+ api_token: config.dig('workato_developer_api', 'api_token'),
41
+ debug: debug
42
+ )
43
+
44
+ datetime = Time.now.localtime.strftime('%Y%m%d_%H%M%S')
45
+
46
+ # Step 1: Search
47
+ warn "[pull] Searching for connector: #{title}" if debug
48
+ status, search_data, raw_body = api.search_connector(title: title)
49
+ handle_http_error(status, 'search', title, body: raw_body)
50
+
51
+ connector = extract_single(search_data, title)
52
+ maybe_save_title(connector, config, config_path)
53
+ search_file = File.join(pull_dir, 'meta.json')
54
+ write_json(search_file, search_data)
55
+ puts "Meta: \t#{search_file}"
56
+
57
+ # Logo
58
+ if what_list.include?('logo') || what_list.include?('all')
59
+ connector_root = config.dig('connector', 'path')
60
+ root_logo = File.join(connector_root, LOGO_FILENAME)
61
+ if File.exist?(root_logo)
62
+ logo_file = save_logo(pull_dir, connector['logo'])
63
+ puts "Logo: \t#{logo_file}" if logo_file
64
+ puts " (saved to pull dir — #{LOGO_FILENAME} already exists in connector root)"
65
+ else
66
+ logo_file = save_logo(connector_root, connector['logo'])
67
+ puts "Logo: \t#{logo_file}" if logo_file
68
+ end
69
+ end
70
+
71
+ # Versions
72
+ if what_list.include?('versions') || what_list.include?('all')
73
+ versions_file = save_versions(pull_dir, connector)
74
+ puts "Versions: \t#{versions_file}"
75
+ end
76
+
77
+ # Code
78
+ if what_list.include?('code') || what_list.include?('all')
79
+ warn "[pull] Fetching code for id=#{connector['id']}" if debug
80
+ status, code_data, raw_body = api.get_connector_code(id: connector['id'])
81
+ handle_http_error(status, 'code', connector['id'], body: raw_body)
82
+
83
+ rb_file = File.join(pull_dir, "#{connector['name']}.rb")
84
+ File.write(rb_file, code_data.dig('data', 'code').to_s)
85
+ puts "Code: \t#{rb_file}"
86
+ end
87
+ end
88
+
89
+ # ── helpers ──────────────────────────────────────────────────────────
90
+
91
+ def derive_title(target_dir)
92
+ connector = Wisco::Connector.load_connector_from_config(target_dir)
93
+ title = connector[:title]
94
+ if title.nil? || title.strip.empty?
95
+ warn "Error: Could not derive connector title from connector file."
96
+ warn " Use --title to specify it explicitly."
97
+ exit 1
98
+ end
99
+ title
100
+ end
101
+
102
+ def parse_what(what_str)
103
+ values = what_str.to_s.split(',').map(&:strip).map(&:downcase)
104
+ invalid = values - VALID_WHAT
105
+ unless invalid.empty?
106
+ warn "Error: Invalid --what value(s): #{invalid.join(', ')}"
107
+ warn " Valid values: #{VALID_WHAT.join(', ')}"
108
+ exit 1
109
+ end
110
+ values
111
+ end
112
+
113
+ def extract_single(data, title)
114
+ results = case data
115
+ when Hash then Array(data['data'] || data)
116
+ when Array then data
117
+ else []
118
+ end
119
+
120
+ if results.empty?
121
+ warn "Error: No connector found matching '#{title}'."
122
+ warn " Check the title and try again, or use --title with a different value."
123
+ exit 1
124
+ end
125
+
126
+ if results.size > 1
127
+ warn "Error: Multiple connectors matched '#{title}':"
128
+ results.each { |r| warn " - #{r['title']} (id: #{r['id']}, name: #{r['name']})" }
129
+ warn " Use --title with a more specific name."
130
+ exit 1
131
+ end
132
+
133
+ results.first
134
+ end
135
+
136
+ def handle_http_error(status, context, identifier, body: nil)
137
+ return if status == 200
138
+
139
+ warn "Error: #{context} request failed for '#{identifier}' (HTTP #{status})."
140
+ warn " Response body: #{body}" unless body.nil? || body.strip.empty?
141
+ exit 1
142
+ end
143
+
144
+ def save_logo(pull_dir, base64_logo)
145
+ return nil if base64_logo.nil? || base64_logo.strip.empty?
146
+
147
+ logo_file = File.join(pull_dir, LOGO_FILENAME)
148
+ File.binwrite(logo_file, Base64.decode64(base64_logo))
149
+ logo_file
150
+ end
151
+
152
+ def save_versions(pull_dir, connector)
153
+ versions = connector.select { |k, _| VERSION_KEYS.include?(k) }
154
+ versions_file = File.join(pull_dir, 'versions.json')
155
+ write_json(versions_file, versions)
156
+ versions_file
157
+ end
158
+
159
+ def maybe_save_title(connector, config, config_path)
160
+ stored = config.dig('connector', 'title')
161
+ return unless stored.nil? || stored.strip.empty?
162
+
163
+ api_title = connector['title']
164
+ return unless api_title && !api_title.strip.empty?
165
+
166
+ config['connector'] ||= {}
167
+ config['connector']['title'] = api_title
168
+ Wisco::Config.save_config(config_path, config)
169
+ end
170
+
171
+ def write_json(path, data)
172
+ File.write(path, JSON.pretty_generate(data) + "\n")
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,129 @@
1
+ require 'workato/cli/push_command'
2
+ require_relative '../config'
3
+ require_relative '../connector'
4
+
5
+ module Wisco
6
+ module Commands
7
+ module Push
8
+ REQUIRED_ASSETS = [
9
+ ->(connector_path, connector_file) { File.join(connector_path, connector_file) },
10
+ ->(connector_path, _) { File.join(connector_path, 'logo.png') },
11
+ ->(connector_path, _) { File.join(connector_path, 'README.md') }
12
+ ].freeze
13
+
14
+ module_function
15
+
16
+ def run(target_dir, title:, notes:, folder:, verbose:, debug:)
17
+ target_dir = File.expand_path(target_dir)
18
+ config_path = Wisco.config_path(target_dir)
19
+
20
+ unless File.exist?(config_path)
21
+ warn "Error: No #{Wisco::WISCO_DIR}/#{Wisco::CONFIG_FILENAME} found in #{target_dir}."
22
+ warn " Run '#{Wisco::CLI_NAME} init' first."
23
+ exit 1
24
+ end
25
+
26
+ config = Wisco::Config.load_config(config_path)
27
+ config = Wisco::Config.ensure_api_config(config, config_path)
28
+
29
+ connector_path = config.dig('connector', 'path')
30
+ connector_file = config.dig('connector', 'file')
31
+
32
+ if connector_path.nil? || connector_file.nil?
33
+ warn "Error: #{Wisco::WISCO_DIR}/#{Wisco::CONFIG_FILENAME} is missing connector path/file. Run '#{Wisco::CLI_NAME} init' again."
34
+ exit 1
35
+ end
36
+
37
+ # Check all required assets exist before attempting the push
38
+ check_assets(connector_path, connector_file)
39
+
40
+ # Resolve title — save to config if obtained from option or prompt
41
+ title = resolve_title(title, config, config_path, target_dir)
42
+
43
+ # Resolve notes — prompt if not provided via --notes
44
+ notes = resolve_notes(notes)
45
+
46
+ options = {
47
+ environment: "https://#{config.dig('workato_developer_api', 'hostname')}",
48
+ api_token: config.dig('workato_developer_api', 'api_token'),
49
+ connector: connector_file,
50
+ title: title,
51
+ notes: notes,
52
+ verbose: verbose
53
+ }
54
+ options[:folder] = folder if folder
55
+
56
+ if debug
57
+ warn "[push] environment: #{options[:environment]}"
58
+ warn "[push] connector: #{options[:connector]}"
59
+ warn "[push] title: #{options[:title]}"
60
+ warn "[push] notes: #{options[:notes]}"
61
+ warn "[push] folder: #{folder.inspect}"
62
+ warn "[push] verbose: #{verbose}"
63
+ end
64
+
65
+ Dir.chdir(connector_path) do
66
+ Workato::CLI::PushCommand.new(options: options).call
67
+ end
68
+ end
69
+
70
+ # ── helpers ──────────────────────────────────────────────────────────
71
+
72
+ def check_assets(connector_path, connector_file)
73
+ missing = REQUIRED_ASSETS
74
+ .map { |fn| fn.call(connector_path, connector_file) }
75
+ .reject { |path| File.exist?(path) }
76
+
77
+ return if missing.empty?
78
+
79
+ missing.each { |path| warn "Error: Required asset not found: #{path}" }
80
+ exit 1
81
+ end
82
+
83
+ def resolve_title(title, config, config_path, target_dir)
84
+ # 1. Explicit --title option
85
+ if title
86
+ save_title(title, config, config_path)
87
+ return title
88
+ end
89
+
90
+ # 2. Stored in config
91
+ stored = config.dig('connector', 'title')
92
+ return stored if stored && !stored.strip.empty?
93
+
94
+ # 3. From connector code
95
+ connector = Wisco::Connector.load_connector_from_config(target_dir)
96
+ code_title = connector[:title]
97
+ return code_title if code_title && !code_title.strip.empty?
98
+
99
+ # 4. Prompt user
100
+ print 'Connector title not found. Enter a title: '
101
+ title = $stdin.gets.strip
102
+ if title.empty?
103
+ warn 'Error: A connector title is required.'
104
+ exit 1
105
+ end
106
+ save_title(title, config, config_path)
107
+ title
108
+ end
109
+
110
+ def save_title(title, config, config_path)
111
+ config['connector'] ||= {}
112
+ config['connector']['title'] = title
113
+ Wisco::Config.save_config(config_path, config)
114
+ end
115
+
116
+ def resolve_notes(notes)
117
+ return notes if notes && !notes.strip.empty?
118
+
119
+ loop do
120
+ print 'Version notes (required): '
121
+ value = $stdin.gets.strip
122
+ return value unless value.empty?
123
+
124
+ warn 'Version notes cannot be blank.'
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,43 @@
1
+ module Wisco
2
+ module Config
3
+ module_function
4
+
5
+ # Ensures workato_developer_api hostname and api_token are present in config.
6
+ # Prompts the user for any missing values and saves the updated config.
7
+ def ensure_api_config(config, config_path)
8
+ api_cfg = config['workato_developer_api'] ||= {}
9
+
10
+ if api_cfg['hostname'].nil? || api_cfg['hostname'].strip.empty?
11
+ print 'Workato API hostname not configured. Enter hostname (e.g. app.au.workato.com): '
12
+ api_cfg['hostname'] = $stdin.gets.strip
13
+ end
14
+
15
+ if api_cfg['api_token'].nil? || api_cfg['api_token'].strip.empty?
16
+ print 'Workato API token not configured. Enter your API token: '
17
+ api_cfg['api_token'] = $stdin.gets.strip
18
+ end
19
+
20
+ save_config(config_path, config)
21
+ config
22
+ end
23
+
24
+ def load_config(path)
25
+ return {} unless File.exist?(path)
26
+
27
+ begin
28
+ JSON.parse(File.read(path))
29
+ rescue JSON::ParserError => e
30
+ warn "Warning: Existing #{Wisco::WISCO_DIR}/#{Wisco::CONFIG_FILENAME} contains invalid JSON (#{e.message})."
31
+ warn ' Connector section will be overwritten; other content may be lost.'
32
+ {}
33
+ end
34
+ end
35
+
36
+ def save_config(path, config)
37
+ File.write(path, JSON.pretty_generate(config) + "\n")
38
+ rescue SystemCallError => e
39
+ warn "Error: Could not write #{Wisco::WISCO_DIR}/#{Wisco::CONFIG_FILENAME}: #{e.message}"
40
+ exit 1
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,91 @@
1
+ require_relative 'config'
2
+
3
+ module Wisco
4
+ module Connector
5
+ module_function
6
+
7
+ def detect_connector(dir)
8
+ candidates = candidate_files(dir)
9
+
10
+ if candidates.empty?
11
+ warn " No .rb files found in #{dir}"
12
+ return nil
13
+ end
14
+
15
+ candidates.each do |filename|
16
+ puts " Checking #{filename}..."
17
+ return filename if valid_connector?(File.join(dir, filename))
18
+ end
19
+
20
+ nil
21
+ end
22
+
23
+ def load_connector_from_config(target_dir)
24
+ target_dir = File.expand_path(target_dir)
25
+ config_path = Wisco.config_path(target_dir)
26
+
27
+ unless File.exist?(config_path)
28
+ warn "Error: No #{Wisco::WISCO_DIR}/#{Wisco::CONFIG_FILENAME} found in #{target_dir}."
29
+ warn " Run '#{Wisco::CLI_NAME} init' first."
30
+ exit 1
31
+ end
32
+
33
+ config = Wisco::Config.load_config(config_path)
34
+ connector_file = config.dig('connector', 'file')
35
+ connector_path = config.dig('connector', 'path')
36
+
37
+ if connector_file.nil? || connector_path.nil?
38
+ warn "Error: #{Wisco::WISCO_DIR}/#{Wisco::CONFIG_FILENAME} is missing connector path/file. Run '#{Wisco::CLI_NAME} init' again."
39
+ exit 1
40
+ end
41
+
42
+ full_path = File.join(connector_path, connector_file)
43
+ unless File.exist?(full_path)
44
+ warn "Error: Connector file not found: #{full_path}"
45
+ exit 1
46
+ end
47
+
48
+ code = File.read(full_path)
49
+ original_verbose = $VERBOSE
50
+ $VERBOSE = nil
51
+ begin
52
+ result = eval(code) # rubocop:disable Security/Eval
53
+ rescue Exception => e
54
+ raise "\nConnector contains errors:\n#{e.message}"
55
+ warn "Error: Failed to load connector: #{e.message}"
56
+ exit 1
57
+ ensure
58
+ $VERBOSE = original_verbose
59
+ end
60
+
61
+ result
62
+ end
63
+
64
+ def candidate_files(dir)
65
+ primary = 'connector.rb'
66
+ others = Dir.glob(File.join(dir, '*.rb'))
67
+ .map { |f| File.basename(f) }
68
+ .reject { |f| f == primary }
69
+ .sort
70
+
71
+ result = []
72
+ result << primary if File.exist?(File.join(dir, primary))
73
+ result.concat(others)
74
+ result
75
+ end
76
+
77
+ def valid_connector?(path)
78
+ code = File.read(path)
79
+ original_verbose = $VERBOSE
80
+ $VERBOSE = nil
81
+ begin
82
+ result = eval(code) # rubocop:disable Security/Eval
83
+ result.is_a?(Hash) && result.key?(:title)
84
+ rescue Exception => e
85
+ false
86
+ ensure
87
+ $VERBOSE = original_verbose
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,57 @@
1
+ module Wisco
2
+ module PathUtils
3
+ VALID_SECTIONS = %w[actions triggers].freeze
4
+
5
+ module_function
6
+
7
+ # Returns an array of [section, key] pairs derived from path_arg.
8
+ #
9
+ # Accepted forms:
10
+ # "section.key" — one specific key in a known section
11
+ # "section" — all keys in that section
12
+ # "key" — auto-detect section; error if found in both or neither
13
+ def parse_path(path_arg, connector)
14
+ parts = path_arg.split('.', 2)
15
+
16
+ if parts.size == 2
17
+ section, key = parts
18
+ unless VALID_SECTIONS.include?(section)
19
+ warn "Error: Invalid section '#{section}'. Valid sections: #{VALID_SECTIONS.join(', ')}."
20
+ exit 1
21
+ end
22
+ items = connector[section.to_sym]
23
+ unless items&.key?(key.to_sym)
24
+ warn "Error: '#{key}' not found in #{section}."
25
+ exit 1
26
+ end
27
+ [[section, key]]
28
+
29
+ elsif VALID_SECTIONS.include?(path_arg)
30
+ section = path_arg
31
+ items = connector[section.to_sym]
32
+ if items.nil? || items.empty?
33
+ warn "Error: No keys found in #{section}."
34
+ exit 1
35
+ end
36
+ items.keys.map { |k| [section, k.to_s] }
37
+
38
+ else
39
+ key = path_arg
40
+ in_actions = connector[:actions]&.key?(key.to_sym) || false
41
+ in_triggers = connector[:triggers]&.key?(key.to_sym) || false
42
+
43
+ case [in_actions, in_triggers]
44
+ when [true, false] then [['actions', key]]
45
+ when [false, true] then [['triggers', key]]
46
+ when [true, true]
47
+ warn "Error: '#{key}' exists in both actions and triggers."
48
+ warn " Qualify with section, e.g. 'actions.#{key}'."
49
+ exit 1
50
+ else
51
+ warn "Error: '#{key}' not found in actions or triggers."
52
+ exit 1
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,3 @@
1
+ module Wisco
2
+ VERSION = '0.1.7'
3
+ end
@@ -0,0 +1,75 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'cgi'
4
+ require 'json'
5
+
6
+ module Wisco
7
+ class WorkatoApi
8
+ def initialize(hostname:, api_token:, debug: false)
9
+ @hostname = hostname
10
+ @api_token = api_token
11
+ @debug = debug
12
+ end
13
+
14
+ # Returns [status_int, parsed_hash_or_nil, raw_body_string]
15
+ def search_connector(title:)
16
+ request(:get, '/api/custom_connectors/search', params: { title: title })
17
+ end
18
+
19
+ def get_connector_code(id:)
20
+ request(:get, "/api/custom_connectors/#{id}/code")
21
+ end
22
+
23
+ private
24
+
25
+ def request(_method, path, params: nil, body: nil)
26
+ # Use URI only for host/port; build path+query as a raw string so
27
+ # Net::HTTP sends it without any re-encoding by the URI class.
28
+ base_uri = URI("https://#{@hostname}")
29
+ query = params.map { |k, v| "#{k}=#{v.to_s.gsub(' ', '%20')}" }.join('&') if params
30
+ request_path = query ? "#{path}?#{query}" : path
31
+
32
+ req = Net::HTTP::Get.new(request_path)
33
+ req['Authorization'] = "Bearer #{@api_token}"
34
+ req['Accept'] = '*/*'
35
+ req['User-Agent'] = 'wisco'
36
+ if body
37
+ req['Content-Type'] = 'application/json'
38
+ req.body = body.to_json
39
+ end
40
+
41
+ if @debug
42
+ warn "[api] → GET https://#{@hostname}#{request_path}"
43
+ warn "[api] Authorization: Bearer #{masked_token}"
44
+ warn "[api] Accept: */*"
45
+ warn "[api] User-Agent: wisco"
46
+ warn "[api] Content-Type: application/json" if body
47
+ warn "[api] Body: #{req.body}" if body
48
+ end
49
+
50
+ http = Net::HTTP.new(base_uri.host, base_uri.port)
51
+ http.use_ssl = true
52
+ resp = http.request(req)
53
+ raw_body = resp.body.to_s
54
+
55
+ if @debug
56
+ warn "[api] ← #{resp.code} #{resp.message}"
57
+ warn "[api] Body: #{raw_body}"
58
+ end
59
+
60
+ begin
61
+ parsed = JSON.parse(raw_body)
62
+ rescue JSON::ParserError
63
+ parsed = nil
64
+ end
65
+ [resp.code.to_i, parsed, raw_body]
66
+ end
67
+
68
+ def masked_token
69
+ return '(empty)' if @api_token.nil? || @api_token.empty?
70
+
71
+ visible = [@api_token.length, 6].min
72
+ "#{@api_token[0, visible]}..."
73
+ end
74
+ end
75
+ end