envless-env 0.0.1
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/README.md +117 -0
- data/exe/envless-env +6 -0
- data/lib/envless/env/api.rb +35 -0
- data/lib/envless/env/binding.rb +26 -0
- data/lib/envless/env/cache.rb +54 -0
- data/lib/envless/env/cache_writer.rb +34 -0
- data/lib/envless/env/coerce.rb +53 -0
- data/lib/envless/env/credentials.rb +27 -0
- data/lib/envless/env/crypto.rb +35 -0
- data/lib/envless/env/decryptor.rb +35 -0
- data/lib/envless/env/environment_writer.rb +43 -0
- data/lib/envless/env/errors.rb +47 -0
- data/lib/envless/env/files.rb +44 -0
- data/lib/envless/env/injected.rb +50 -0
- data/lib/envless/env/isolated.rb +45 -0
- data/lib/envless/env/keyring.rb +54 -0
- data/lib/envless/env/load.rb +5 -0
- data/lib/envless/env/loader.rb +83 -0
- data/lib/envless/env/messages.rb +36 -0
- data/lib/envless/env/override.rb +17 -0
- data/lib/envless/env/rails.rb +21 -0
- data/lib/envless/env/serialisation.rb +50 -0
- data/lib/envless/env/settings.rb +39 -0
- data/lib/envless/env/store.rb +91 -0
- data/lib/envless/env/target.rb +19 -0
- data/lib/envless/env/types/collect.rb +59 -0
- data/lib/envless/env/types/command.rb +64 -0
- data/lib/envless/env/types/declared.rb +43 -0
- data/lib/envless/env/types/render.rb +52 -0
- data/lib/envless/env/types.rb +6 -0
- data/lib/envless/env/update_notice.rb +64 -0
- data/lib/envless/env/values.rb +90 -0
- data/lib/envless/env/version.rb +7 -0
- data/lib/envless/env.rb +63 -0
- data/sig/envless/env/loading.rbs +84 -0
- data/sig/envless/env/settings.rbs +84 -0
- data/sig/envless/env/state.rbs +70 -0
- data/sig/envless/env/types.rbs +56 -0
- data/sig/envless/env/values.rbs +75 -0
- data/sig/envless/env.rbs +66 -0
- data/sig/manifest.yaml +2 -0
- metadata +101 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
class Keyring
|
|
6
|
+
PINNED = /\Av2:(\d+):/
|
|
7
|
+
DEFAULT_ITERATIONS = Envless::ENCRYPTION_PARAMETERS[:pbkdf2Iterations]
|
|
8
|
+
|
|
9
|
+
def self.from_environment(api)
|
|
10
|
+
raw = Files.variable(Settings::ENV_VARS[:KEY])
|
|
11
|
+
return imported(raw) if raw && !raw.empty?
|
|
12
|
+
|
|
13
|
+
passphrase = Files.variable(Settings::ENV_VARS[:PASSPHRASE])
|
|
14
|
+
raise LoadError, Messages::KEY_MATERIAL_MISSING if passphrase.nil? || passphrase.empty?
|
|
15
|
+
|
|
16
|
+
derived(passphrase, api.workspace_id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.imported(raw)
|
|
20
|
+
new(Envless.import_workspace_key(raw))
|
|
21
|
+
rescue ArgumentError => error
|
|
22
|
+
raise LoadError, format(Messages::KEY_INVALID, reason: error.message)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.derived(passphrase, workspace_id)
|
|
26
|
+
raise LoadError, Messages::WORKSPACE_UNRESOLVED if workspace_id.nil? || workspace_id.empty?
|
|
27
|
+
|
|
28
|
+
new(Envless.derive_workspace_key(passphrase, workspace_id), passphrase:, workspace_id:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(key, passphrase: nil, workspace_id: nil)
|
|
32
|
+
@key = key
|
|
33
|
+
@passphrase = passphrase
|
|
34
|
+
@workspace_id = workspace_id
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def decrypt(value)
|
|
39
|
+
pinned = PINNED.match(value)
|
|
40
|
+
passphrase = @passphrase
|
|
41
|
+
workspace_id = @workspace_id
|
|
42
|
+
if pinned && passphrase && workspace_id && Integer(pinned[1], 10) != DEFAULT_ITERATIONS
|
|
43
|
+
return Envless.decrypt_value(value, passphrase, workspace_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Envless.decrypt_with_key(@key, value)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def inspect
|
|
50
|
+
"#<#{self.class.name} [redacted]>"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
class Loader
|
|
6
|
+
def initialize(store, writer)
|
|
7
|
+
@store = store
|
|
8
|
+
@writer = writer
|
|
9
|
+
@lock = Mutex.new
|
|
10
|
+
@memo = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load(revalidate: false)
|
|
14
|
+
UpdateNotice.check
|
|
15
|
+
return @store.snapshot if @store.injected? && !revalidate
|
|
16
|
+
|
|
17
|
+
@lock.synchronize do
|
|
18
|
+
memo = @memo
|
|
19
|
+
if memo.nil? || revalidate
|
|
20
|
+
memo = fresh(revalidate)
|
|
21
|
+
@memo = memo
|
|
22
|
+
end
|
|
23
|
+
memo.dup
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def fresh(revalidate)
|
|
30
|
+
token = Credentials.machine_token
|
|
31
|
+
target = Target.resolve
|
|
32
|
+
api = Api.new(token)
|
|
33
|
+
begin
|
|
34
|
+
keyring = Keyring.from_environment(api)
|
|
35
|
+
cached = revalidate ? nil : from_cache(target, keyring)
|
|
36
|
+
cached || fetched(api, target, keyring)
|
|
37
|
+
ensure
|
|
38
|
+
api.close
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def from_cache(target, keyring)
|
|
43
|
+
cached = Cache.read(target)
|
|
44
|
+
return nil if cached.nil? || !Cache.fresh?(cached[:cached_at])
|
|
45
|
+
|
|
46
|
+
outcome = Decryptor.call(cached[:variables], keyring)
|
|
47
|
+
outcome.entries && finish(outcome.entries, cached[:variables])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fetched(api, target, keyring)
|
|
51
|
+
variables = fetch(api, target)
|
|
52
|
+
begin
|
|
53
|
+
CacheWriter.write(target, variables)
|
|
54
|
+
rescue SystemCallError, IOError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
outcome = Decryptor.call(variables, keyring)
|
|
58
|
+
raise LoadError, format(Messages::DECRYPT_FAILED, name: outcome.failed) if outcome.entries.nil?
|
|
59
|
+
|
|
60
|
+
finish(outcome.entries, variables)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def fetch(api, target)
|
|
64
|
+
api.variables(target)
|
|
65
|
+
rescue Envless::Error, ArgumentError => error
|
|
66
|
+
raise LoadError, format(Messages::FETCH_FAILED, project: target.project, environment: target.environment,
|
|
67
|
+
reason: error.message)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def finish(entries, sources)
|
|
71
|
+
record = entries.to_h
|
|
72
|
+
# @type var meta: Hash[String, meta]
|
|
73
|
+
meta = sources.to_h do |source|
|
|
74
|
+
[source[:name], {type: source[:type] || Settings::DEFAULT_TYPE,
|
|
75
|
+
visibility: source[:visibility] || Settings::DEFAULT_VISIBILITY}]
|
|
76
|
+
end
|
|
77
|
+
@store.replace(record, meta)
|
|
78
|
+
@writer.write(record)
|
|
79
|
+
record
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Messages
|
|
6
|
+
TOKEN_MISSING = "Missing ENVLESS_TOKEN. Set it to an Envless machine key (ev_sk_…) to load variables."
|
|
7
|
+
TOKEN_NOT_MACHINE = "ENVLESS_TOKEN must be a machine key (ev_sk_…). Create one in the Envless dashboard."
|
|
8
|
+
PROJECT_MISSING = "Missing ENVLESS_PROJECT. Set it to your project slug, or add a .envless file."
|
|
9
|
+
ENVIRONMENT_MISSING = "Missing ENVLESS_ENV. Set it to your environment slug, or add a .envless file."
|
|
10
|
+
KEY_MATERIAL_MISSING = "Set ENVLESS_KEY (a workspace key) or ENVLESS_PASSPHRASE to decrypt in machine mode."
|
|
11
|
+
KEY_INVALID = "ENVLESS_KEY is invalid: %<reason>s"
|
|
12
|
+
WORKSPACE_UNRESOLVED = "ENVLESS_PASSPHRASE is set but the workspace could not be resolved to derive the key."
|
|
13
|
+
FETCH_FAILED = "Could not load variables for %<project>s/%<environment>s: %<reason>s"
|
|
14
|
+
DECRYPT_FAILED = "Could not decrypt %<name>s. Check ENVLESS_KEY or ENVLESS_PASSPHRASE."
|
|
15
|
+
NOT_LOADED = "Envless env is not loaded yet. Call Envless::Env.load, or require \"envless/env/load\", first."
|
|
16
|
+
MISSING_VARIABLE = "Missing env var: %<name>s"
|
|
17
|
+
READ_ONLY = "Envless env is read-only."
|
|
18
|
+
SERVER_ONLY = "%<name>s is server only. Read it through Envless.env, not Envless.env.client."
|
|
19
|
+
NOT_SERIALISABLE = "Refusing to serialise the Envless env, because it holds decrypted secrets and this would copy " \
|
|
20
|
+
"every one of them into the output. Read the names you need, or call Envless::Env.load for a plain Hash you own."
|
|
21
|
+
UNSAFE_REFUSED = "#{Settings::LOG_PREFIX} These variables were decrypted but not written into the process " \
|
|
22
|
+
"environment, because their names change how this process or its children execute code, so storing one turns " \
|
|
23
|
+
"write access to a variable into code execution: %<names>s"
|
|
24
|
+
UNWRITABLE = "#{Settings::LOG_PREFIX} These variables were decrypted but not written into the process environment, " \
|
|
25
|
+
"because the operating system cannot hold their name or value in one: %<names>s"
|
|
26
|
+
ISOLATED_NO_TOKEN = "Pass a machine key as token:. A side-effect-free load reads no .envless file and no config."
|
|
27
|
+
ISOLATED_NO_TARGET = "Pass project: and environment:. A side-effect-free load has no .envless file to resolve them from."
|
|
28
|
+
ISOLATED_NO_KEY_MATERIAL = "Pass passphrase: or key:. Without one the values cannot be decrypted."
|
|
29
|
+
ISOLATED_NO_WORKSPACE = "The workspace could not be resolved, so the passphrase cannot be turned into a key. " \
|
|
30
|
+
"Pass workspace_id: explicitly."
|
|
31
|
+
ISOLATED_DECRYPT_FAILED = "Could not decrypt %<name>s"
|
|
32
|
+
UPDATE_AVAILABLE = "ℹ envless-env %<latest>s is available, you are on %<current>s. Upgrade with " \
|
|
33
|
+
"bundle update envless-env. %<changelog>s."
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../env"
|
|
4
|
+
|
|
5
|
+
module Envless
|
|
6
|
+
module Env
|
|
7
|
+
module ViewHelper
|
|
8
|
+
def envless
|
|
9
|
+
Envless.env.client.snapshot
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Railtie < ::Rails::Railtie
|
|
14
|
+
config.before_configuration { Envless::Env.load }
|
|
15
|
+
|
|
16
|
+
initializer "envless.view_helper" do
|
|
17
|
+
ActiveSupport.on_load(:action_view) { include Envless::Env::ViewHelper }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Serialisation
|
|
6
|
+
def to_h
|
|
7
|
+
raise SerialisationError unless client?
|
|
8
|
+
|
|
9
|
+
keys.to_h { |name| [name, self[name]] }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def snapshot
|
|
13
|
+
to_h
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def as_json(*)
|
|
17
|
+
to_h
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_json(*)
|
|
21
|
+
to_h.to_json(*)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def marshal_dump
|
|
25
|
+
raise SerialisationError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def encode_with(_coder)
|
|
29
|
+
raise SerialisationError
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def dup
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clone(freeze: nil)
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inspect
|
|
41
|
+
count = keys.size
|
|
42
|
+
"#<#{self.class.name}#{" client" if client?}#{" optional" if optional?} #{count} variable#{"s" unless count == 1}>"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_s
|
|
46
|
+
inspect
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Settings
|
|
6
|
+
ENV_VARS = {
|
|
7
|
+
TOKEN: "ENVLESS_TOKEN", KEY: "ENVLESS_KEY", PASSPHRASE: "ENVLESS_PASSPHRASE", PRODUCT: "ENVLESS_PRODUCT",
|
|
8
|
+
PROJECT: "ENVLESS_PROJECT", ENV: "ENVLESS_ENV", DISABLE_UPDATE_NOTICE: "ENVLESS_DISABLE_UPDATE_NOTICE"
|
|
9
|
+
}
|
|
10
|
+
MACHINE_TOKEN_PREFIX = "ev_sk_"
|
|
11
|
+
VAR_TYPES = {STRING: "string", NUMBER: "number", BOOLEAN: "boolean"}
|
|
12
|
+
VISIBILITY = {SERVER: "server", CLIENT: "client"}
|
|
13
|
+
DEFAULT_TYPE = VAR_TYPES[:STRING]
|
|
14
|
+
DEFAULT_VISIBILITY = VISIBILITY[:SERVER]
|
|
15
|
+
CACHE_MAX_AGE = 900
|
|
16
|
+
CACHE_NO_PRODUCT = "_"
|
|
17
|
+
CACHE_UNSAFE_CHARACTERS = /[^a-zA-Z0-9._-]/
|
|
18
|
+
CONFIG_DIR = ".envless"
|
|
19
|
+
CONFIG_FILE = "config.json"
|
|
20
|
+
CACHE_DIR = "cache"
|
|
21
|
+
ENVLESS_FILE = ".envless"
|
|
22
|
+
DIR_MODE = 0o700
|
|
23
|
+
FILE_MODE = 0o600
|
|
24
|
+
PAGE_LIMIT = 100
|
|
25
|
+
INJECTED = {NAME: "ENVLESS_INJECTED", TYPE_KEY: "t", VISIBILITY_KEY: "v"}
|
|
26
|
+
UNSAFE_PROCESS_ENV_NAMES = %w[
|
|
27
|
+
NODE_OPTIONS NODE_PATH NODE_REPL_EXTERNAL_MODULE ELECTRON_RUN_AS_NODE LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT
|
|
28
|
+
DYLD_INSERT_LIBRARIES DYLD_LIBRARY_PATH DYLD_FRAMEWORK_PATH PATH BUN_INSPECT BUN_INSPECT_CONNECT_TO
|
|
29
|
+
RUBYOPT RUBYLIB BUNDLE_GEMFILE GEM_HOME GEM_PATH PYTHONPATH PYTHONHOME PYTHONSTARTUP PYTHONUSERBASE PHPRC
|
|
30
|
+
PHP_INI_SCAN_DIR ENVLESS_CLIENT_ENV ENVLESS_INJECTED
|
|
31
|
+
].freeze
|
|
32
|
+
UNSAFE_NAMES = UNSAFE_PROCESS_ENV_NAMES.map(&:downcase).freeze
|
|
33
|
+
USER_AGENT = "envless-env-ruby/#{VERSION}"
|
|
34
|
+
LOG_PREFIX = "[envless]"
|
|
35
|
+
|
|
36
|
+
constants.each { |name| Ractor.make_shareable(const_get(name)) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
class Store
|
|
6
|
+
def initialize
|
|
7
|
+
@values = {}
|
|
8
|
+
@meta = {}
|
|
9
|
+
@overrides = []
|
|
10
|
+
@loaded = false
|
|
11
|
+
@injected = false
|
|
12
|
+
@seeded = false
|
|
13
|
+
@lock = Monitor.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def replace(record, meta)
|
|
17
|
+
guarded do
|
|
18
|
+
@values = record.dup
|
|
19
|
+
@meta = record.keys.to_h { |name| [name, meta.fetch(name) { default_meta }] }
|
|
20
|
+
@loaded = true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def has?(name)
|
|
25
|
+
guarded { @values.key?(name) || @overrides.any? { |layer| layer.key?(name) } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def raw(name)
|
|
29
|
+
guarded do
|
|
30
|
+
layer = @overrides.reverse_each.find { |candidate| candidate.key?(name) }
|
|
31
|
+
layer ? layer[name] : @values[name]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def meta_of(name)
|
|
36
|
+
guarded { @meta.fetch(name) { default_meta } }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def keys
|
|
40
|
+
guarded { (@values.keys + @overrides.flat_map(&:keys)).uniq }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def readable?
|
|
44
|
+
guarded { @loaded || !keys.empty? }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def injected?
|
|
48
|
+
guarded { @injected }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def snapshot
|
|
52
|
+
guarded { keys.filter_map { |name| (value = raw(name)) && [name, value] }.to_h }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def push(layer)
|
|
56
|
+
guarded { @overrides << layer }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def pop(layer)
|
|
60
|
+
guarded do
|
|
61
|
+
index = @overrides.rindex { |candidate| candidate.equal?(layer) }
|
|
62
|
+
@overrides.delete_at(index) if index
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def guarded
|
|
70
|
+
@lock.synchronize do
|
|
71
|
+
seed unless @seeded
|
|
72
|
+
yield
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def default_meta
|
|
77
|
+
{type: Settings::DEFAULT_TYPE, visibility: Settings::DEFAULT_VISIBILITY}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def seed
|
|
81
|
+
@seeded = true
|
|
82
|
+
injected = Injected.seed
|
|
83
|
+
return if injected.nil?
|
|
84
|
+
|
|
85
|
+
@values, @meta = injected
|
|
86
|
+
@loaded = true
|
|
87
|
+
@injected = true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
Target = Data.define(:workspace, :product, :project, :environment)
|
|
6
|
+
|
|
7
|
+
class Target
|
|
8
|
+
def self.resolve(binding = Binding.read)
|
|
9
|
+
product = Files.variable(Settings::ENV_VARS[:PRODUCT]) || binding&.product
|
|
10
|
+
project = Files.variable(Settings::ENV_VARS[:PROJECT]) || binding&.project
|
|
11
|
+
environment = Files.variable(Settings::ENV_VARS[:ENV]) || binding&.environments&.first
|
|
12
|
+
raise LoadError, Messages::PROJECT_MISSING if project.nil? || project.empty?
|
|
13
|
+
raise LoadError, Messages::ENVIRONMENT_MISSING if environment.nil? || environment.empty?
|
|
14
|
+
|
|
15
|
+
new(workspace: binding&.workspace, product: Files.text?(product) ? product : nil, project:, environment:)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Types
|
|
6
|
+
module Collect
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def call(project: nil, product: nil, env: nil)
|
|
10
|
+
manifest = from_manifest
|
|
11
|
+
return manifest if manifest
|
|
12
|
+
|
|
13
|
+
token = Credentials.machine_token
|
|
14
|
+
binding = Binding.read
|
|
15
|
+
product = first(product, Files.variable(Settings::ENV_VARS[:PRODUCT]), binding&.product)
|
|
16
|
+
project = first(project, Files.variable(Settings::ENV_VARS[:PROJECT]), binding&.project)
|
|
17
|
+
slugs = slugs(env) || slugs(Files.variable(Settings::ENV_VARS[:ENV])) || binding&.environments
|
|
18
|
+
raise LoadError, Messages::PROJECT_MISSING if project.nil?
|
|
19
|
+
raise LoadError, Messages::ENVIRONMENT_MISSING if slugs.nil? || slugs.empty?
|
|
20
|
+
|
|
21
|
+
from_api(token, project, slugs, product)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def from_manifest
|
|
25
|
+
seeded = Injected.seed
|
|
26
|
+
return nil if seeded.nil?
|
|
27
|
+
|
|
28
|
+
record, meta = seeded
|
|
29
|
+
record.keys.map { |name| Declared.build(name, meta.fetch(name)[:type], meta.fetch(name)[:visibility]) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def from_api(token, project, slugs, product)
|
|
33
|
+
api = Api.new(token)
|
|
34
|
+
begin
|
|
35
|
+
slugs.flat_map { |slug| declared(api, Target.new(workspace: nil, product:, project:, environment: slug)) }
|
|
36
|
+
ensure
|
|
37
|
+
api.close
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def declared(api, target)
|
|
42
|
+
api.variables(target).map { |variable| Declared.build(variable[:name], variable[:type], variable[:visibility]) }
|
|
43
|
+
rescue Envless::Error, ArgumentError => error
|
|
44
|
+
raise LoadError, format(Messages::FETCH_FAILED, project: target.project, environment: target.environment,
|
|
45
|
+
reason: error.message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def first(*candidates)
|
|
49
|
+
candidates.find { |candidate| Files.text?(candidate) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def slugs(value)
|
|
53
|
+
listed = value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
54
|
+
listed.empty? ? nil : listed
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Types
|
|
6
|
+
module Command
|
|
7
|
+
OUTPUT = "sig/envless-env.rbs"
|
|
8
|
+
PREFIX = "envless-env types:"
|
|
9
|
+
USAGE = "Usage: envless-env types [--output PATH] [--project SLUG] [--product SLUG] [--env SLUGS] [--check]"
|
|
10
|
+
RUN_HINT = "Or run it under envless run, which needs no machine key: envless run -- bundle exec envless-env types"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def call(arguments, out: $stdout, err: $stderr)
|
|
15
|
+
command, *rest = arguments
|
|
16
|
+
return report(out, USAGE, 0) if %w[-h --help help].include?(command)
|
|
17
|
+
return report(out, VERSION, 0) if %w[-v --version].include?(command)
|
|
18
|
+
return report(err, USAGE, 2) unless command == "types"
|
|
19
|
+
|
|
20
|
+
run(parse(rest), out, err)
|
|
21
|
+
rescue OptionParser::ParseError => error
|
|
22
|
+
report(err, "#{PREFIX} #{error.message}\n#{USAGE}", 2)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse(arguments)
|
|
26
|
+
# @type var options: Hash[Symbol, untyped]
|
|
27
|
+
options = {output: OUTPUT, check: false}
|
|
28
|
+
OptionParser.new do |parser|
|
|
29
|
+
%i[output project product env].each { |name| parser.on("--#{name} VALUE", String) { |value| options[name] = value } }
|
|
30
|
+
parser.on("--check") { options[:check] = true }
|
|
31
|
+
end.parse!(arguments.dup)
|
|
32
|
+
options
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run(options, out, err)
|
|
36
|
+
declared = Collect.call(project: options[:project], product: options[:product], env: options[:env])
|
|
37
|
+
write(options[:output].to_s, Render.call(declared), Declared.merged(declared).size, options[:check] == true, out, err)
|
|
38
|
+
rescue LoadError => error
|
|
39
|
+
hint = error.message.include?(Settings::ENV_VARS[:TOKEN]) ? "\n#{RUN_HINT}" : ""
|
|
40
|
+
report(err, "#{PREFIX} #{error.message}#{hint}", 1)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def write(path, text, count, check, out, err)
|
|
44
|
+
current = File.file?(path) ? File.read(path, encoding: Encoding::UTF_8) : nil
|
|
45
|
+
if check
|
|
46
|
+
return report(out, "#{PREFIX} #{path} is current, #{count} variables.", 0) if current == text
|
|
47
|
+
|
|
48
|
+
return report(err, "#{PREFIX} #{path} is stale. Run bundle exec envless-env types.", 1)
|
|
49
|
+
end
|
|
50
|
+
unless current == text
|
|
51
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
52
|
+
File.write(path, text)
|
|
53
|
+
end
|
|
54
|
+
report(out, "#{PREFIX} #{count} variables in #{path}.", 0)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def report(stream, message, code)
|
|
58
|
+
stream.puts(message)
|
|
59
|
+
code
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Types
|
|
6
|
+
Declared = Data.define(:name, :type, :visibility)
|
|
7
|
+
|
|
8
|
+
class Declared
|
|
9
|
+
RUBY_TYPES = {"number" => "(Integer | Float)", "boolean" => "bool"}.freeze
|
|
10
|
+
LITERAL = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
11
|
+
|
|
12
|
+
def self.build(name, type, visibility)
|
|
13
|
+
new(name:, type: known(type, Settings::VAR_TYPES, Settings::DEFAULT_TYPE),
|
|
14
|
+
visibility: known(visibility, Settings::VISIBILITY, Settings::DEFAULT_VISIBILITY))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.known(value, allowed, fallback)
|
|
18
|
+
(value.is_a?(String) && allowed.value?(value)) ? value : fallback
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.merged(declared)
|
|
22
|
+
declared.uniq(&:name).sort_by(&:name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rbs(optional)
|
|
26
|
+
"#{RUBY_TYPES.fetch(type, "String")}#{"?" if optional}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def client?
|
|
30
|
+
visibility == Settings::VISIBILITY[:CLIENT]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def method?
|
|
34
|
+
Values::READABLE.match?(name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def literal?
|
|
38
|
+
LITERAL.match?(name)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Env
|
|
5
|
+
module Types
|
|
6
|
+
module Render
|
|
7
|
+
HEADER = ["# Generated by envless-env. Do not edit it by hand.",
|
|
8
|
+
"# Run `bundle exec envless-env types` to regenerate it."].freeze
|
|
9
|
+
VIEWS = {
|
|
10
|
+
"Server" => {client: false, optional: false, views: %w[Optional Client]},
|
|
11
|
+
"Optional" => {client: false, optional: true, views: %w[Optional ClientOptional]},
|
|
12
|
+
"Client" => {client: true, optional: false, views: %w[ClientOptional Client]},
|
|
13
|
+
"ClientOptional" => {client: true, optional: true, views: %w[ClientOptional ClientOptional]}
|
|
14
|
+
}
|
|
15
|
+
FALLBACK = "(String | Symbol name) -> value?"
|
|
16
|
+
|
|
17
|
+
constants.each { |name| Ractor.make_shareable(const_get(name)) }
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def call(declared)
|
|
22
|
+
entries = Declared.merged(declared)
|
|
23
|
+
[*HEADER, *untyped(entries), "", "module Envless", " def self.env: () -> Env::Typed::Server | ...", "",
|
|
24
|
+
" module Env", " def self.values: () -> Typed::Server | ...", " def self.optional: () -> Typed::Optional | ...",
|
|
25
|
+
" def self.client: () -> Typed::Client | ...", "", " module Typed",
|
|
26
|
+
*VIEWS.flat_map { |name, view| view(name, view, entries) }, " end", " end", "end", ""].join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def untyped(entries)
|
|
30
|
+
names = entries.reject(&:literal?).map(&:name)
|
|
31
|
+
names.empty? ? [] : ["# Read these through Envless.env[\"NAME\"], untyped: #{names.join(", ")}"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def view(name, view, entries)
|
|
35
|
+
shown = entries.select { |entry| !view[:client] || entry.client? }
|
|
36
|
+
optional = view[:optional]
|
|
37
|
+
optional_view, client_view = view[:views]
|
|
38
|
+
lines = [" class #{name} < Values", " def optional: () -> #{optional_view}",
|
|
39
|
+
" def client: () -> #{client_view}", *index(shown, optional)]
|
|
40
|
+
lines += shown.select(&:method?).map { |entry| " def #{entry.name}: () -> #{entry.rbs(optional)}" }
|
|
41
|
+
[*lines, " end", *((name == "ClientOptional") ? [] : [""])]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def index(entries, optional)
|
|
45
|
+
overloads = entries.select(&:literal?).map { |entry| "(:#{entry.name} | \"#{entry.name}\") -> #{entry.rbs(optional)}" }
|
|
46
|
+
first, *rest = [*overloads, FALLBACK]
|
|
47
|
+
[" def []: #{first}", *rest.map { |overload| " | #{overload}" }]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|