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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +117 -0
  4. data/exe/envless-env +6 -0
  5. data/lib/envless/env/api.rb +35 -0
  6. data/lib/envless/env/binding.rb +26 -0
  7. data/lib/envless/env/cache.rb +54 -0
  8. data/lib/envless/env/cache_writer.rb +34 -0
  9. data/lib/envless/env/coerce.rb +53 -0
  10. data/lib/envless/env/credentials.rb +27 -0
  11. data/lib/envless/env/crypto.rb +35 -0
  12. data/lib/envless/env/decryptor.rb +35 -0
  13. data/lib/envless/env/environment_writer.rb +43 -0
  14. data/lib/envless/env/errors.rb +47 -0
  15. data/lib/envless/env/files.rb +44 -0
  16. data/lib/envless/env/injected.rb +50 -0
  17. data/lib/envless/env/isolated.rb +45 -0
  18. data/lib/envless/env/keyring.rb +54 -0
  19. data/lib/envless/env/load.rb +5 -0
  20. data/lib/envless/env/loader.rb +83 -0
  21. data/lib/envless/env/messages.rb +36 -0
  22. data/lib/envless/env/override.rb +17 -0
  23. data/lib/envless/env/rails.rb +21 -0
  24. data/lib/envless/env/serialisation.rb +50 -0
  25. data/lib/envless/env/settings.rb +39 -0
  26. data/lib/envless/env/store.rb +91 -0
  27. data/lib/envless/env/target.rb +19 -0
  28. data/lib/envless/env/types/collect.rb +59 -0
  29. data/lib/envless/env/types/command.rb +64 -0
  30. data/lib/envless/env/types/declared.rb +43 -0
  31. data/lib/envless/env/types/render.rb +52 -0
  32. data/lib/envless/env/types.rb +6 -0
  33. data/lib/envless/env/update_notice.rb +64 -0
  34. data/lib/envless/env/values.rb +90 -0
  35. data/lib/envless/env/version.rb +7 -0
  36. data/lib/envless/env.rb +63 -0
  37. data/sig/envless/env/loading.rbs +84 -0
  38. data/sig/envless/env/settings.rbs +84 -0
  39. data/sig/envless/env/state.rbs +70 -0
  40. data/sig/envless/env/types.rbs +56 -0
  41. data/sig/envless/env/values.rbs +75 -0
  42. data/sig/envless/env.rbs +66 -0
  43. data/sig/manifest.yaml +2 -0
  44. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../env"
4
+
5
+ Envless::Env.load
@@ -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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ class Override
6
+ def initialize(store, layer)
7
+ @store = store
8
+ @layer = layer
9
+ freeze
10
+ end
11
+
12
+ def restore
13
+ @store.pop(@layer)
14
+ end
15
+ end
16
+ end
17
+ 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../env"
5
+
6
+ %w[declared collect render command].each { |name| require_relative "types/#{name}" }