codex-notify 1.0.0
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 +50 -0
- data/LICENSE +21 -0
- data/README.md +782 -0
- data/bin/codex-notify +6 -0
- data/bin/codex-notify-hook +6 -0
- data/lib/codex_notify/cli.rb +112 -0
- data/lib/codex_notify/config.rb +113 -0
- data/lib/codex_notify/config_diagnostics.rb +61 -0
- data/lib/codex_notify/config_migrator.rb +148 -0
- data/lib/codex_notify/config_support.rb +157 -0
- data/lib/codex_notify/destination_name.rb +18 -0
- data/lib/codex_notify/destination_resolver.rb +68 -0
- data/lib/codex_notify/durable_slack_publisher.rb +70 -0
- data/lib/codex_notify/env_source_loader.rb +91 -0
- data/lib/codex_notify/hook_cli.rb +90 -0
- data/lib/codex_notify/hook_config.rb +110 -0
- data/lib/codex_notify/hook_event.rb +16 -0
- data/lib/codex_notify/hook_formatter.rb +61 -0
- data/lib/codex_notify/hook_input_validator.rb +241 -0
- data/lib/codex_notify/hook_runner.rb +181 -0
- data/lib/codex_notify/hook_store.rb +133 -0
- data/lib/codex_notify/hook_thread_publisher.rb +107 -0
- data/lib/codex_notify/log_event_parser.rb +230 -0
- data/lib/codex_notify/message_formatter.rb +115 -0
- data/lib/codex_notify/outbox_commands.rb +43 -0
- data/lib/codex_notify/secret_protection.rb +37 -0
- data/lib/codex_notify/session_log.rb +63 -0
- data/lib/codex_notify/slack_client.rb +117 -0
- data/lib/codex_notify/slack_delivery_worker.rb +269 -0
- data/lib/codex_notify/slack_outbox.rb +206 -0
- data/lib/codex_notify/stream_processor.rb +141 -0
- data/lib/codex_notify/trusted_config_loader.rb +159 -0
- data/lib/codex_notify/version.rb +5 -0
- data/lib/codex_notify.rb +36 -0
- metadata +96 -0
data/bin/codex-notify
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'time'
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative 'log_event_parser'
|
|
8
|
+
require_relative 'message_formatter'
|
|
9
|
+
require_relative 'session_log'
|
|
10
|
+
require_relative 'slack_client'
|
|
11
|
+
require_relative 'stream_processor'
|
|
12
|
+
require_relative 'hook_store'
|
|
13
|
+
require_relative 'slack_outbox'
|
|
14
|
+
require_relative 'durable_slack_publisher'
|
|
15
|
+
require_relative 'outbox_commands'
|
|
16
|
+
|
|
17
|
+
module CodexNotify
|
|
18
|
+
module CLI
|
|
19
|
+
PostClient = Data.define(:token, :channel, :post_callable) do
|
|
20
|
+
def post(text, thread_ts: nil)
|
|
21
|
+
post_callable.call(token, channel, text, thread_ts)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def slack_post(token, channel, text, thread_ts = nil)
|
|
28
|
+
SlackClient.new(token:, channel:).post(text, thread_ts:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def main(argv = nil, stdin: nil, stderr: $stderr, stdout: $stdout, legacy_checkout_root: nil)
|
|
32
|
+
args = CodexNotify::Config.parse_args(argv, stderr:, legacy_checkout_root:)
|
|
33
|
+
if args.migrate_config
|
|
34
|
+
env_path = args.env_file if args.env_file_explicit
|
|
35
|
+
return ConfigMigrator.new(legacy_checkout_root:, stdout:, stderr:).run(
|
|
36
|
+
env_path:,
|
|
37
|
+
config_path: args.config_file
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
if args.outbox_action
|
|
41
|
+
return OutboxCommands.run(
|
|
42
|
+
action: args.outbox_action,
|
|
43
|
+
id: args.outbox_id,
|
|
44
|
+
outbox_dir: args.outbox_dir,
|
|
45
|
+
token: args.token,
|
|
46
|
+
channel: args.channel,
|
|
47
|
+
state_file: Pathname(args.outbox_dir).join('thread-state.json'),
|
|
48
|
+
stdout:,
|
|
49
|
+
stderr:
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
unless args.token && args.channel
|
|
53
|
+
stderr.puts('ERROR: need --token/--channel or a Slack destination from environment or config file')
|
|
54
|
+
return 2
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
cwd = Dir.pwd
|
|
58
|
+
title = args.title || "Codex run: #{File.basename(cwd)}"
|
|
59
|
+
session_file = args.session_file ? Pathname(args.session_file) : CodexNotify::SessionLog.find_latest_session_file(Pathname(args.sessions_dir))
|
|
60
|
+
unless session_file&.exist?
|
|
61
|
+
stderr.puts('ERROR: no Codex session log file found')
|
|
62
|
+
return 2
|
|
63
|
+
end
|
|
64
|
+
root_message = CodexNotify::MessageFormatter.build_root_message(
|
|
65
|
+
title,
|
|
66
|
+
cwd,
|
|
67
|
+
user_name: args.user_name,
|
|
68
|
+
session_id: CodexNotify::SessionLog.session_id_from_log(session_file) ||
|
|
69
|
+
CodexNotify::SessionLog.session_id_from_path(session_file)
|
|
70
|
+
)
|
|
71
|
+
outbox = SlackOutbox.new(args.outbox_dir)
|
|
72
|
+
store = HookStore.new(Pathname(args.outbox_dir).join('thread-state.json'))
|
|
73
|
+
publisher = DurableSlackPublisher.new(
|
|
74
|
+
client: PostClient.new(args.token, args.channel, method(:slack_post)),
|
|
75
|
+
store:,
|
|
76
|
+
outbox:,
|
|
77
|
+
channel: args.channel,
|
|
78
|
+
throttle_sec: args.throttle_sec
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
code = CodexNotify::StreamProcessor.process_codex_log_stream(
|
|
82
|
+
CodexNotify::SessionLog.iter_follow_lines(
|
|
83
|
+
session_file,
|
|
84
|
+
poll_sec: args.poll_sec,
|
|
85
|
+
once: args.once,
|
|
86
|
+
start_at_end: !args.once,
|
|
87
|
+
sleep_func: method(:sleep)
|
|
88
|
+
),
|
|
89
|
+
token: args.token,
|
|
90
|
+
channel: args.channel,
|
|
91
|
+
root_message:,
|
|
92
|
+
initial_prompt: args.prompt,
|
|
93
|
+
user_name: args.user_name,
|
|
94
|
+
include_tools: args.include_tools,
|
|
95
|
+
throttle_sec: args.throttle_sec,
|
|
96
|
+
post_func: method(:slack_post),
|
|
97
|
+
publisher:
|
|
98
|
+
)
|
|
99
|
+
stderr.puts('ERROR: Slack delivery requires outbox review; run --outbox-status') if code == 1
|
|
100
|
+
code
|
|
101
|
+
rescue Interrupt
|
|
102
|
+
stderr.puts('Stopped.')
|
|
103
|
+
0
|
|
104
|
+
rescue SlackOutbox::Error => e
|
|
105
|
+
stderr.puts("ERROR: #{e.message}")
|
|
106
|
+
1
|
|
107
|
+
rescue Config::Error, ConfigMigrator::Error, OptionParser::ParseError => e
|
|
108
|
+
stderr.puts("ERROR: #{e.message}")
|
|
109
|
+
2
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require_relative 'config_support'
|
|
6
|
+
require_relative 'config_migrator'
|
|
7
|
+
require_relative 'destination_resolver'
|
|
8
|
+
require_relative 'env_source_loader'
|
|
9
|
+
|
|
10
|
+
module CodexNotify
|
|
11
|
+
module Config
|
|
12
|
+
extend ConfigSupport
|
|
13
|
+
|
|
14
|
+
DEFAULT_ENV_PATH = ConfigSupport::DEFAULT_ENV_PATH
|
|
15
|
+
DEFAULT_SESSIONS_DIR = Pathname(File.expand_path('~/.codex/sessions'))
|
|
16
|
+
DEFAULT_OUTBOX_DIR = Pathname(File.expand_path('~/.codex-notify/outbox'))
|
|
17
|
+
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
Args = Struct.new(
|
|
21
|
+
:env_file,
|
|
22
|
+
:env_file_explicit,
|
|
23
|
+
:config_file,
|
|
24
|
+
:migrate_config,
|
|
25
|
+
:token,
|
|
26
|
+
:channel,
|
|
27
|
+
:destination,
|
|
28
|
+
:user_name,
|
|
29
|
+
:prompt,
|
|
30
|
+
:title,
|
|
31
|
+
:include_tools,
|
|
32
|
+
:throttle_sec,
|
|
33
|
+
:sessions_dir,
|
|
34
|
+
:session_file,
|
|
35
|
+
:poll_sec,
|
|
36
|
+
:once,
|
|
37
|
+
:outbox_dir,
|
|
38
|
+
:outbox_action,
|
|
39
|
+
:outbox_id,
|
|
40
|
+
:token_from_cli,
|
|
41
|
+
keyword_init: true
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
def build_parser
|
|
47
|
+
options = Args.new(
|
|
48
|
+
env_file: DEFAULT_ENV_PATH,
|
|
49
|
+
env_file_explicit: false,
|
|
50
|
+
config_file: nil,
|
|
51
|
+
migrate_config: false,
|
|
52
|
+
token: nil,
|
|
53
|
+
channel: nil,
|
|
54
|
+
destination: nil,
|
|
55
|
+
user_name: nil,
|
|
56
|
+
prompt: nil,
|
|
57
|
+
title: nil,
|
|
58
|
+
include_tools: false,
|
|
59
|
+
throttle_sec: 1.05,
|
|
60
|
+
sessions_dir: DEFAULT_SESSIONS_DIR.to_s,
|
|
61
|
+
session_file: nil,
|
|
62
|
+
poll_sec: 1.0,
|
|
63
|
+
once: false,
|
|
64
|
+
outbox_dir: nil,
|
|
65
|
+
outbox_action: nil,
|
|
66
|
+
outbox_id: nil,
|
|
67
|
+
token_from_cli: false
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
parser = OptionParser.new do |opts|
|
|
71
|
+
opts.banner = 'Usage: codex-notify [options]'
|
|
72
|
+
add_common_options(opts, options)
|
|
73
|
+
opts.on('--prompt PROMPT') { |v| options.prompt = v }
|
|
74
|
+
opts.on('--include-tools') { options.include_tools = true }
|
|
75
|
+
opts.on('--throttle-sec FLOAT', Float) { |v| options.throttle_sec = v }
|
|
76
|
+
opts.on('--sessions-dir PATH') { |v| options.sessions_dir = v }
|
|
77
|
+
opts.on('--session-file PATH') { |v| options.session_file = v }
|
|
78
|
+
opts.on('--poll-sec FLOAT', Float) { |v| options.poll_sec = v }
|
|
79
|
+
opts.on('--once') { options.once = true }
|
|
80
|
+
opts.on('--outbox-dir PATH') { |v| options.outbox_dir = v }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
[parser, options]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_args(argv = nil, stderr: $stderr, legacy_checkout_root: nil)
|
|
87
|
+
parser, options = build_parser
|
|
88
|
+
parser.parse!(argv || [])
|
|
89
|
+
return options if options.migrate_config
|
|
90
|
+
|
|
91
|
+
ConfigDiagnostics.warn_deprecated_cli_token(stderr:) if options.token_from_cli
|
|
92
|
+
|
|
93
|
+
sources = EnvSourceLoader.new(legacy_checkout_root:, stderr:).load(
|
|
94
|
+
path: options.env_file,
|
|
95
|
+
explicit: options.env_file_explicit,
|
|
96
|
+
config_path: options.config_file
|
|
97
|
+
)
|
|
98
|
+
policy = resolve_policy(sources, stderr:)
|
|
99
|
+
warn_ignored_repository_values(sources, policy:, stderr:)
|
|
100
|
+
apply_destination(options, sources, policy:, stderr:)
|
|
101
|
+
|
|
102
|
+
eligible = eligible_sources(sources, policy:)
|
|
103
|
+
trusted = trusted_sources(sources)
|
|
104
|
+
options.user_name ||= eligible.lookup('CODEX_NOTIFY_USER_NAME')&.value || system_user_name
|
|
105
|
+
options.title ||= eligible.lookup('CODEX_NOTIFY_TITLE')&.value
|
|
106
|
+
options.prompt ||= eligible.lookup('CODEX_PROMPT')&.value
|
|
107
|
+
options.outbox_dir ||= trusted.lookup('CODEX_NOTIFY_OUTBOX_DIR')&.value || DEFAULT_OUTBOX_DIR.to_s
|
|
108
|
+
options
|
|
109
|
+
rescue ConfigSupport::Error, EnvSourceLoader::Error, TrustedConfigLoader::Error, DestinationResolver::Error => e
|
|
110
|
+
raise Error, e.message
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CodexNotify
|
|
4
|
+
module ConfigDiagnostics
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def warn_if_file_insecure(path, label:, stderr:)
|
|
8
|
+
stat = File.stat(path)
|
|
9
|
+
return unless stat.file?
|
|
10
|
+
return if (stat.mode & 0o077).zero?
|
|
11
|
+
|
|
12
|
+
permissions = format('%04o', stat.mode & 0o777)
|
|
13
|
+
stderr.puts(
|
|
14
|
+
"WARNING: #{label} #{path} has permissions #{permissions}; " \
|
|
15
|
+
"use `chmod 600 #{path}` to restrict access to secrets."
|
|
16
|
+
)
|
|
17
|
+
rescue NotImplementedError, SystemCallError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def warn_if_env_file_insecure(path, stderr:)
|
|
22
|
+
warn_if_file_insecure(path, label: 'env file', stderr:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def warn_deprecated_cli_token(stderr:)
|
|
26
|
+
stderr.puts(
|
|
27
|
+
'WARNING: --token is deprecated because command-line arguments may be visible in process lists ' \
|
|
28
|
+
'and shell history; use SLACK_BOT_TOKEN or a permission-restricted env file.'
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def warn_deprecated_repository_credentials(path, keys, stderr:)
|
|
33
|
+
stderr.puts(
|
|
34
|
+
"WARNING: insecure legacy mode loaded repository Slack settings #{keys.join(' and ')} from automatically " \
|
|
35
|
+
"discovered env file #{path}; this temporary compatibility mode will be removed in a future major release. " \
|
|
36
|
+
'Configure a trusted destination profile and use CODEX_NOTIFY_DESTINATION.'
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def warn_ignored_repository_policy(path, stderr:)
|
|
41
|
+
stderr.puts(
|
|
42
|
+
"WARNING: ignored CODEX_NOTIFY_ENV_POLICY from automatically discovered repository env file #{path}; " \
|
|
43
|
+
'the policy must come from trusted configuration.'
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def warn_ignored_repository_credentials(path, keys, policy: nil, stderr:)
|
|
48
|
+
reason = policy ? " under the #{policy} policy" : ''
|
|
49
|
+
stderr.puts(
|
|
50
|
+
"WARNING: ignored #{keys.join(', ')} from automatically discovered repository env file #{path}#{reason}."
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def warn_deprecated_tool_config(path, keys, stderr:)
|
|
55
|
+
stderr.puts(
|
|
56
|
+
"WARNING: #{keys.join(', ')} loaded from legacy codex-notify env file #{path}; " \
|
|
57
|
+
'move trusted settings to the XDG config file.'
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dotenv'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require 'tempfile'
|
|
7
|
+
require 'yaml'
|
|
8
|
+
require_relative 'config_diagnostics'
|
|
9
|
+
require_relative 'destination_name'
|
|
10
|
+
require_relative 'trusted_config_loader'
|
|
11
|
+
|
|
12
|
+
module CodexNotify
|
|
13
|
+
class ConfigMigrator
|
|
14
|
+
PROFILE_KEYS = {
|
|
15
|
+
'SLACK_BOT_TOKEN__' => 'token',
|
|
16
|
+
'SLACK_CHANNEL__' => 'channel'
|
|
17
|
+
}.freeze
|
|
18
|
+
ENV_POLICIES = %w[legacy restricted].freeze
|
|
19
|
+
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
|
|
22
|
+
def initialize(legacy_checkout_root: nil, environment: ENV, stdout: $stdout, stderr: $stderr)
|
|
23
|
+
@legacy_checkout_root = Pathname(legacy_checkout_root).expand_path if legacy_checkout_root
|
|
24
|
+
@environment = environment
|
|
25
|
+
@stdout = stdout
|
|
26
|
+
@stderr = stderr
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run(env_path: nil, config_path: nil)
|
|
30
|
+
source = source_path(env_path)
|
|
31
|
+
target = target_path(config_path)
|
|
32
|
+
validate_paths(source, target)
|
|
33
|
+
|
|
34
|
+
ConfigDiagnostics.warn_if_env_file_insecure(source, stderr: @stderr)
|
|
35
|
+
values = Dotenv.parse(source.to_s)
|
|
36
|
+
document = build_document(values)
|
|
37
|
+
write_config(target, YAML.dump(document))
|
|
38
|
+
@stdout.puts("Created trusted config file #{target}.")
|
|
39
|
+
@stdout.puts('Verify the destination settings, then remove migrated secrets from the legacy env file manually.')
|
|
40
|
+
0
|
|
41
|
+
rescue TrustedConfigLoader::Error => e
|
|
42
|
+
raise Error, e.message
|
|
43
|
+
rescue SystemCallError => e
|
|
44
|
+
raise Error, "could not migrate configuration: #{e.class}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def source_path(path)
|
|
50
|
+
return Pathname(path).expand_path if path
|
|
51
|
+
|
|
52
|
+
unless @legacy_checkout_root
|
|
53
|
+
raise Error, '--migrate-config requires --env-file PATH outside a source checkout'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@legacy_checkout_root.join('.env')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def target_path(path)
|
|
60
|
+
return Pathname(path).expand_path if path
|
|
61
|
+
|
|
62
|
+
TrustedConfigLoader.new(environment: @environment, stderr: @stderr).default_path
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_paths(source, target)
|
|
66
|
+
raise Error, "legacy env file does not exist: #{source}" unless source.exist?
|
|
67
|
+
raise Error, "legacy env path is not a file: #{source}" unless source.file?
|
|
68
|
+
raise Error, 'legacy env file and config output path must be different' if source == target
|
|
69
|
+
raise Error, "config file already exists: #{target}" if target.exist?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_document(values)
|
|
73
|
+
document = {}
|
|
74
|
+
add_policy(document, values['CODEX_NOTIFY_ENV_POLICY'])
|
|
75
|
+
add_default_destination(document, values)
|
|
76
|
+
add_destinations(document, values)
|
|
77
|
+
raise Error, 'legacy env file contains no trusted settings to migrate' if document.empty?
|
|
78
|
+
|
|
79
|
+
document
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add_policy(document, raw_policy)
|
|
83
|
+
return if empty?(raw_policy)
|
|
84
|
+
|
|
85
|
+
policy = raw_policy.strip.downcase
|
|
86
|
+
unless ENV_POLICIES.include?(policy)
|
|
87
|
+
raise Error, "environment policy must be one of: #{ENV_POLICIES.join(', ')}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
document['env_policy'] = policy
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def add_default_destination(document, values)
|
|
94
|
+
destination = {}
|
|
95
|
+
destination['token'] = values['SLACK_BOT_TOKEN'] unless empty?(values['SLACK_BOT_TOKEN'])
|
|
96
|
+
destination['channel'] = values['SLACK_CHANNEL'] unless empty?(values['SLACK_CHANNEL'])
|
|
97
|
+
document['default_destination'] = destination unless destination.empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def add_destinations(document, values)
|
|
101
|
+
destinations = {}
|
|
102
|
+
original_names = {}
|
|
103
|
+
values.each do |key, value|
|
|
104
|
+
next if empty?(value)
|
|
105
|
+
|
|
106
|
+
prefix, field = PROFILE_KEYS.find { |candidate, _| key.start_with?(candidate) }
|
|
107
|
+
next unless prefix
|
|
108
|
+
|
|
109
|
+
raw_name = key.delete_prefix(prefix)
|
|
110
|
+
name = DestinationName.normalize(raw_name)
|
|
111
|
+
previous = original_names[name]
|
|
112
|
+
if previous && previous != raw_name
|
|
113
|
+
raise Error, "destination name is duplicated after normalization: #{name}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
original_names[name] = raw_name
|
|
117
|
+
destinations[name] ||= {}
|
|
118
|
+
destinations[name][field] = value
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
destinations.sort.each do |name, destination|
|
|
122
|
+
raise Error, "destination #{name} must define channel" unless destination.key?('channel')
|
|
123
|
+
end
|
|
124
|
+
document['destinations'] = destinations.sort.to_h unless destinations.empty?
|
|
125
|
+
rescue DestinationName::Error => e
|
|
126
|
+
raise Error, e.message
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def write_config(path, contents)
|
|
130
|
+
FileUtils.mkdir_p(path.dirname, mode: 0o700)
|
|
131
|
+
raise Error, "config file already exists: #{path}" if path.exist?
|
|
132
|
+
|
|
133
|
+
Tempfile.create(['codex-notify-config', '.tmp'], path.dirname.to_s) do |file|
|
|
134
|
+
file.chmod(0o600)
|
|
135
|
+
file.write(contents)
|
|
136
|
+
file.flush
|
|
137
|
+
file.fsync
|
|
138
|
+
File.link(file.path, path.to_s)
|
|
139
|
+
rescue Errno::EEXIST
|
|
140
|
+
raise Error, "config file already exists: #{path}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def empty?(value)
|
|
145
|
+
!value || value.strip.empty?
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dotenv'
|
|
4
|
+
require 'etc'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require_relative 'config_diagnostics'
|
|
7
|
+
|
|
8
|
+
module CodexNotify
|
|
9
|
+
module ConfigSupport
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
DEFAULT_ENV_PATH = '.env'
|
|
13
|
+
DEFAULT_ENV_POLICY = 'restricted'
|
|
14
|
+
ENV_POLICIES = %w[legacy restricted].freeze
|
|
15
|
+
REPOSITORY_ALLOWED_KEYS = %w[
|
|
16
|
+
CODEX_NOTIFY_DESTINATION
|
|
17
|
+
CODEX_NOTIFY_TITLE
|
|
18
|
+
CODEX_NOTIFY_USER_NAME
|
|
19
|
+
CODEX_NOTIFY_MODE
|
|
20
|
+
].freeze
|
|
21
|
+
REPOSITORY_CREDENTIAL_PATTERN = /\ASLACK_(?:BOT_TOKEN|CHANNEL)(?:__.*)?\z/
|
|
22
|
+
def resolve_env_paths(path = DEFAULT_ENV_PATH, legacy_checkout_root: nil)
|
|
23
|
+
env_path = Pathname(path)
|
|
24
|
+
return [env_path] if env_path.absolute?
|
|
25
|
+
|
|
26
|
+
candidates = [Pathname(Dir.pwd).join(env_path)]
|
|
27
|
+
candidates << Pathname(legacy_checkout_root).join(env_path) if legacy_checkout_root
|
|
28
|
+
candidates.map(&:expand_path).select(&:exist?).each_with_object([]) do |candidate, paths|
|
|
29
|
+
paths << candidate unless paths.any? { |path| File.identical?(path, candidate) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def load_env_file(path = DEFAULT_ENV_PATH, override: false, stderr: $stderr, legacy_checkout_root: nil)
|
|
34
|
+
env_paths = resolve_env_paths(path, legacy_checkout_root:)
|
|
35
|
+
return if env_paths.empty?
|
|
36
|
+
|
|
37
|
+
env_paths.each { |env_path| ConfigDiagnostics.warn_if_env_file_insecure(env_path, stderr:) }
|
|
38
|
+
loader = override ? Dotenv.method(:overload) : Dotenv.method(:load)
|
|
39
|
+
loader.call(*env_paths.map(&:to_s))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def system_user_name
|
|
43
|
+
ENV['USER'] || ENV['USERNAME'] || Etc.getlogin || 'user'
|
|
44
|
+
rescue StandardError
|
|
45
|
+
ENV['USER'] || ENV['USERNAME'] || 'user'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def getenv_any(keys)
|
|
49
|
+
keys.each do |key|
|
|
50
|
+
value = ENV[key]
|
|
51
|
+
return value if value && !value.empty?
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_common_options(parser, options)
|
|
57
|
+
parser.on('--config PATH') do |value|
|
|
58
|
+
options.config_file = value
|
|
59
|
+
end
|
|
60
|
+
parser.on('--migrate-config', 'Create trusted YAML from a legacy env file') do
|
|
61
|
+
options.migrate_config = true
|
|
62
|
+
end
|
|
63
|
+
parser.on('--env-file PATH') do |value|
|
|
64
|
+
options.env_file = value
|
|
65
|
+
options.env_file_explicit = true if options.respond_to?(:env_file_explicit=)
|
|
66
|
+
end
|
|
67
|
+
parser.on('--token TOKEN', 'Deprecated: use the XDG config file or SLACK_BOT_TOKEN') do |value|
|
|
68
|
+
options.token = value
|
|
69
|
+
options.token_from_cli = true
|
|
70
|
+
end
|
|
71
|
+
parser.on('--channel CHANNEL') { |value| options.channel = value }
|
|
72
|
+
parser.on('--destination NAME') { |value| options.destination = value } if options.respond_to?(:destination=)
|
|
73
|
+
parser.on('--user-name NAME') { |value| options.user_name = value }
|
|
74
|
+
parser.on('--title TITLE') { |value| options.title = value }
|
|
75
|
+
if options.respond_to?(:outbox_action=)
|
|
76
|
+
parser.on('--outbox-status') { options.outbox_action = :status }
|
|
77
|
+
parser.on('--drain-outbox') { options.outbox_action = :drain }
|
|
78
|
+
parser.on('--retry-outbox ID') do |value|
|
|
79
|
+
options.outbox_action = :retry
|
|
80
|
+
options.outbox_id = value
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolve_policy(sources, stderr: $stderr)
|
|
86
|
+
result = trusted_sources(sources).lookup('CODEX_NOTIFY_ENV_POLICY')
|
|
87
|
+
policy = result ? result.value.strip.downcase : DEFAULT_ENV_POLICY
|
|
88
|
+
raise Error, "environment policy must be one of: #{ENV_POLICIES.join(', ')}" unless ENV_POLICIES.include?(policy)
|
|
89
|
+
|
|
90
|
+
warn_if_legacy_tool_source(result&.source, ['CODEX_NOTIFY_ENV_POLICY'], stderr:)
|
|
91
|
+
policy
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def apply_destination(options, sources, policy:, stderr: $stderr)
|
|
95
|
+
eligible = eligible_sources(sources, policy:)
|
|
96
|
+
resolution = DestinationResolver.new(
|
|
97
|
+
selection_sources: sources,
|
|
98
|
+
profile_sources: trusted_sources(sources),
|
|
99
|
+
default_sources: eligible
|
|
100
|
+
).resolve(destination: options.destination, token: options.token, channel: options.channel)
|
|
101
|
+
|
|
102
|
+
options.destination = resolution.destination
|
|
103
|
+
options.token = resolution.token
|
|
104
|
+
options.channel = resolution.channel
|
|
105
|
+
|
|
106
|
+
used_sources = [resolution.token_source, resolution.channel_source].compact
|
|
107
|
+
source_keys = [
|
|
108
|
+
[resolution.token_source, 'SLACK_BOT_TOKEN'],
|
|
109
|
+
[resolution.channel_source, 'SLACK_CHANNEL']
|
|
110
|
+
]
|
|
111
|
+
repository_keys = source_keys.filter_map { |source, key| key if source&.kind == :repository }
|
|
112
|
+
if repository_keys.any?
|
|
113
|
+
source = used_sources.find { |candidate| candidate.kind == :repository }
|
|
114
|
+
ConfigDiagnostics.warn_deprecated_repository_credentials(source.path, repository_keys, stderr:)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
tool_keys = [
|
|
118
|
+
[resolution.token_source, resolution.destination ? "destination #{resolution.destination} token" : 'SLACK_BOT_TOKEN'],
|
|
119
|
+
[resolution.channel_source, resolution.destination ? "destination #{resolution.destination} channel" : 'SLACK_CHANNEL']
|
|
120
|
+
].filter_map { |source, key| key if source&.kind == :tool }
|
|
121
|
+
warn_if_legacy_tool_source(used_sources.find { |source| source.kind == :tool }, tool_keys, stderr:)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def eligible_sources(sources, policy:)
|
|
125
|
+
return sources unless policy == 'restricted'
|
|
126
|
+
|
|
127
|
+
sources.restrict_kind(:repository, keys: REPOSITORY_ALLOWED_KEYS)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def trusted_sources(sources)
|
|
131
|
+
sources.excluding_kind(:repository)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def warn_ignored_repository_values(sources, policy:, stderr:)
|
|
135
|
+
sources.select { |source| source.kind == :repository }.each do |source|
|
|
136
|
+
policy_value = source.values['CODEX_NOTIFY_ENV_POLICY']
|
|
137
|
+
if policy_value && !policy_value.empty?
|
|
138
|
+
ConfigDiagnostics.warn_ignored_repository_policy(source.path, stderr:)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
ignored = source.values.keys.grep(REPOSITORY_CREDENTIAL_PATTERN)
|
|
142
|
+
ignored.select! { |key| key.include?('__') } if policy == 'legacy'
|
|
143
|
+
next if ignored.empty?
|
|
144
|
+
|
|
145
|
+
reason = policy == 'restricted' ? policy : nil
|
|
146
|
+
ConfigDiagnostics.warn_ignored_repository_credentials(source.path, ignored.sort, policy: reason, stderr:)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def warn_if_legacy_tool_source(source, keys, stderr:)
|
|
151
|
+
return unless source&.kind == :tool && keys.any?
|
|
152
|
+
|
|
153
|
+
ConfigDiagnostics.warn_deprecated_tool_config(source.path, keys, stderr:)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CodexNotify
|
|
4
|
+
module DestinationName
|
|
5
|
+
PATTERN = /\A[A-Z0-9_]+\z/
|
|
6
|
+
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def normalize(value)
|
|
12
|
+
normalized = value.to_s.strip.upcase
|
|
13
|
+
return normalized if PATTERN.match?(normalized)
|
|
14
|
+
|
|
15
|
+
raise Error, 'destination must contain only A-Z, 0-9, and _'
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'destination_name'
|
|
4
|
+
|
|
5
|
+
module CodexNotify
|
|
6
|
+
class DestinationResolver
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
Resolution = Struct.new(
|
|
10
|
+
:destination,
|
|
11
|
+
:token,
|
|
12
|
+
:channel,
|
|
13
|
+
:token_source,
|
|
14
|
+
:channel_source,
|
|
15
|
+
keyword_init: true
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def initialize(selection_sources:, profile_sources:, default_sources:)
|
|
19
|
+
@selection_sources = selection_sources
|
|
20
|
+
@profile_sources = profile_sources
|
|
21
|
+
@default_sources = default_sources
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resolve(destination:, token:, channel:)
|
|
25
|
+
selected = destination || selection_sources.lookup('CODEX_NOTIFY_DESTINATION')&.value
|
|
26
|
+
return resolve_default(token:, channel:) unless selected
|
|
27
|
+
|
|
28
|
+
resolve_profile(DestinationName.normalize(selected), token:, channel:)
|
|
29
|
+
rescue DestinationName::Error => e
|
|
30
|
+
raise Error, e.message
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :selection_sources, :profile_sources, :default_sources
|
|
36
|
+
|
|
37
|
+
def resolve_profile(destination, token:, channel:)
|
|
38
|
+
channel_key = "SLACK_CHANNEL__#{destination}"
|
|
39
|
+
profile_channel = profile_sources.lookup(channel_key)
|
|
40
|
+
raise Error, "destination #{destination} is not configured: missing #{channel_key}" unless profile_channel
|
|
41
|
+
|
|
42
|
+
profile_token = profile_sources.lookup("SLACK_BOT_TOKEN__#{destination}") unless token
|
|
43
|
+
default_token = profile_sources.lookup('SLACK_BOT_TOKEN') unless token || profile_token
|
|
44
|
+
resolved_token = token || profile_token&.value || default_token&.value
|
|
45
|
+
raise Error, "destination #{destination} has no Slack bot token" unless resolved_token
|
|
46
|
+
|
|
47
|
+
Resolution.new(
|
|
48
|
+
destination:,
|
|
49
|
+
token: resolved_token,
|
|
50
|
+
channel: channel || profile_channel.value,
|
|
51
|
+
token_source: token ? nil : (profile_token || default_token)&.source,
|
|
52
|
+
channel_source: channel ? nil : profile_channel.source
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve_default(token:, channel:)
|
|
57
|
+
token_result = default_sources.lookup('SLACK_BOT_TOKEN') unless token
|
|
58
|
+
channel_result = default_sources.lookup('SLACK_CHANNEL') unless channel
|
|
59
|
+
Resolution.new(
|
|
60
|
+
destination: nil,
|
|
61
|
+
token: token || token_result&.value,
|
|
62
|
+
channel: channel || channel_result&.value,
|
|
63
|
+
token_source: token_result&.source,
|
|
64
|
+
channel_source: channel_result&.source
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|