space-cadet 9.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 +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +279 -0
- data/exe/space +13 -0
- data/lib/space_core/atomic_write.rb +21 -0
- data/lib/space_core/cli/base_command.rb +19 -0
- data/lib/space_core/cli/build.rb +27 -0
- data/lib/space_core/cli/config.rb +49 -0
- data/lib/space_core/cli/current.rb +16 -0
- data/lib/space_core/cli/help.rb +195 -0
- data/lib/space_core/cli/helpers.rb +115 -0
- data/lib/space_core/cli/init.rb +29 -0
- data/lib/space_core/cli/list.rb +24 -0
- data/lib/space_core/cli/loop_status.rb +47 -0
- data/lib/space_core/cli/new.rb +38 -0
- data/lib/space_core/cli/pack.rb +29 -0
- data/lib/space_core/cli/path.rb +16 -0
- data/lib/space_core/cli/repeatable_options.rb +84 -0
- data/lib/space_core/cli/repo.rb +76 -0
- data/lib/space_core/cli/run.rb +44 -0
- data/lib/space_core/cli/shell.rb +125 -0
- data/lib/space_core/cli/show.rb +21 -0
- data/lib/space_core/cli/status.rb +74 -0
- data/lib/space_core/cli/use.rb +17 -0
- data/lib/space_core/cli.rb +177 -0
- data/lib/space_core/cloner.rb +77 -0
- data/lib/space_core/commands.rb +51 -0
- data/lib/space_core/config.rb +152 -0
- data/lib/space_core/errors.rb +14 -0
- data/lib/space_core/git_client.rb +49 -0
- data/lib/space_core/mise_client.rb +37 -0
- data/lib/space_core/oci_builder.rb +56 -0
- data/lib/space_core/oci_packer.rb +103 -0
- data/lib/space_core/oci_runner.rb +89 -0
- data/lib/space_core/paths.rb +58 -0
- data/lib/space_core/repo_reference.rb +19 -0
- data/lib/space_core/repo_resolver.rb +167 -0
- data/lib/space_core/scm/client.rb +87 -0
- data/lib/space_core/scm/git.rb +232 -0
- data/lib/space_core/scm/status.rb +24 -0
- data/lib/space_core/shell.rb +90 -0
- data/lib/space_core/shell_integration.rb +438 -0
- data/lib/space_core/slugger.rb +16 -0
- data/lib/space_core/space.rb +174 -0
- data/lib/space_core/space_store.rb +319 -0
- data/lib/space_core/state.rb +86 -0
- data/lib/space_core/templates/oci/dockerfile.erb +73 -0
- data/lib/space_core/templates/oci/dockerignore.erb +17 -0
- data/lib/space_core/templates/oci/entrypoint.sh.erb +16 -0
- data/lib/space_core/terminal.rb +161 -0
- data/lib/space_core/version.rb +7 -0
- data/lib/space_core/warnings.rb +13 -0
- data/lib/space_core/xdg.rb +33 -0
- data/lib/space_core.rb +26 -0
- metadata +199 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Space::Core
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULT_DATA = {
|
|
9
|
+
"version" => 1,
|
|
10
|
+
"base_dir" => "~/architect",
|
|
11
|
+
"default_provider" => "github.com",
|
|
12
|
+
"default_organization" => nil,
|
|
13
|
+
"git_clone_protocol" => "ssh"
|
|
14
|
+
}.freeze
|
|
15
|
+
EDITABLE_KEYS = %w[
|
|
16
|
+
base_dir
|
|
17
|
+
spaces_dir
|
|
18
|
+
src_dir
|
|
19
|
+
default_provider
|
|
20
|
+
default_organization
|
|
21
|
+
git_clone_protocol
|
|
22
|
+
].freeze
|
|
23
|
+
VALID_GIT_CLONE_PROTOCOLS = %w[ssh https].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :path, :data, :env
|
|
26
|
+
|
|
27
|
+
def self.default_path(env: ENV)
|
|
28
|
+
XDG.config_home(env: env).join("space-cadet", "config.yml")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.load(env: ENV, path: default_path(env: env))
|
|
32
|
+
new(env:, path:).load
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(env: ENV, path: self.class.default_path(env: env), data: nil)
|
|
36
|
+
@path = Pathname.new(path)
|
|
37
|
+
@env = env
|
|
38
|
+
@data = data ? DEFAULT_DATA.merge(stringify_keys(data)) : DEFAULT_DATA.dup
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def load
|
|
42
|
+
@data = if path.exist?
|
|
43
|
+
parsed = YAML.safe_load(path.read, aliases: false) || {}
|
|
44
|
+
unless parsed.is_a?(Hash)
|
|
45
|
+
raise Error, "Config file must contain a YAML mapping: #{path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
DEFAULT_DATA.merge(stringify_keys(parsed))
|
|
49
|
+
else
|
|
50
|
+
DEFAULT_DATA.dup
|
|
51
|
+
end
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ensure_exists!
|
|
56
|
+
save unless path.exist?
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def save
|
|
61
|
+
AtomicWrite.write(path, YAML.dump(data))
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def base_dir
|
|
66
|
+
Pathname.new(XDG.expand_user(data.fetch("base_dir"), env: env))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def spaces_dir
|
|
70
|
+
value = normalized_value(data["spaces_dir"])
|
|
71
|
+
return base_dir.join("spaces") unless value
|
|
72
|
+
|
|
73
|
+
Pathname.new(XDG.expand_user(value, env: env))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def src_dir
|
|
77
|
+
return base_dir.join("src") unless data.key?("src_dir")
|
|
78
|
+
|
|
79
|
+
value = normalized_value(data["src_dir"])
|
|
80
|
+
return nil unless value
|
|
81
|
+
|
|
82
|
+
Pathname.new(XDG.expand_user(value, env: env))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def default_provider
|
|
86
|
+
normalize_provider(data["default_provider"])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def default_organization
|
|
90
|
+
normalized_value(data["default_organization"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def git_clone_protocol
|
|
94
|
+
protocol = normalized_value(data["git_clone_protocol"]) || "ssh"
|
|
95
|
+
unless VALID_GIT_CLONE_PROTOCOLS.include?(protocol)
|
|
96
|
+
raise Error, "Invalid git_clone_protocol '#{protocol}'. Expected one of: #{VALID_GIT_CLONE_PROTOCOLS.join(', ')}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
protocol
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def set(key, value)
|
|
103
|
+
normalized_key = key.to_s
|
|
104
|
+
unless EDITABLE_KEYS.include?(normalized_key)
|
|
105
|
+
raise InvalidConfigKeyError, "Unknown config key '#{key}'. Expected one of: #{EDITABLE_KEYS.join(', ')}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
data[normalized_key] = normalize_config_value(normalized_key, value)
|
|
109
|
+
save
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def normalize_config_value(key, value)
|
|
115
|
+
case key
|
|
116
|
+
when "default_provider"
|
|
117
|
+
normalize_provider(value)
|
|
118
|
+
when "default_organization"
|
|
119
|
+
normalized_value(value)
|
|
120
|
+
when "git_clone_protocol"
|
|
121
|
+
protocol = normalized_value(value)
|
|
122
|
+
unless VALID_GIT_CLONE_PROTOCOLS.include?(protocol)
|
|
123
|
+
raise Error, "Invalid git_clone_protocol '#{value}'. Expected one of: #{VALID_GIT_CLONE_PROTOCOLS.join(', ')}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
protocol
|
|
127
|
+
else
|
|
128
|
+
value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def normalize_provider(value)
|
|
133
|
+
normalized = normalized_value(value)
|
|
134
|
+
return nil unless normalized
|
|
135
|
+
|
|
136
|
+
normalized
|
|
137
|
+
.delete_prefix("https://")
|
|
138
|
+
.delete_prefix("http://")
|
|
139
|
+
.delete_prefix("ssh://")
|
|
140
|
+
.delete_suffix("/")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def normalized_value(value)
|
|
144
|
+
normalized = value.to_s.strip
|
|
145
|
+
normalized.empty? ? nil : normalized
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def stringify_keys(hash)
|
|
149
|
+
hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class NotFoundError < Error; end
|
|
6
|
+
class AmbiguousSpaceError < Error; end
|
|
7
|
+
class InvalidStatusError < Error; end
|
|
8
|
+
class CurrentSpaceMissingError < Error; end
|
|
9
|
+
class InvalidConfigKeyError < Error; end
|
|
10
|
+
class RepoResolutionError < Error; end
|
|
11
|
+
class RepoExistsError < Error; end
|
|
12
|
+
class GitError < Error; end
|
|
13
|
+
class MiseError < Error; end
|
|
14
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "async/process"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
|
|
8
|
+
module Space::Core
|
|
9
|
+
class GitClient
|
|
10
|
+
def init(path)
|
|
11
|
+
path = Pathname.new(path)
|
|
12
|
+
stdout, stderr, status = capture("git", "-C", path.to_s, "init")
|
|
13
|
+
return true if status.success?
|
|
14
|
+
|
|
15
|
+
output = [stdout, stderr].reject(&:empty?).join("\n").strip
|
|
16
|
+
message = "git init failed for #{path}"
|
|
17
|
+
message = "#{message}: #{output}" unless output.empty?
|
|
18
|
+
raise GitError, message
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Best-effort: a missing git identity (user.name/user.email) must not abort
|
|
22
|
+
# space creation. Returns false on failure, leaving the repo initialized but
|
|
23
|
+
# uncommitted.
|
|
24
|
+
def commit_all(path, message)
|
|
25
|
+
path = Pathname.new(path)
|
|
26
|
+
_, _, add_status = capture("git", "-C", path.to_s, "add", "-A")
|
|
27
|
+
return false unless add_status.success?
|
|
28
|
+
|
|
29
|
+
_, _, commit_status = capture("git", "-C", path.to_s, "commit", "-m", message)
|
|
30
|
+
commit_status.success?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def capture(*command)
|
|
36
|
+
stdout = Tempfile.new("project-spaces-git-stdout")
|
|
37
|
+
stderr = Tempfile.new("project-spaces-git-stderr")
|
|
38
|
+
status = Async::Process.spawn(*command, out: stdout, err: stderr)
|
|
39
|
+
|
|
40
|
+
stdout.rewind
|
|
41
|
+
stderr.rewind
|
|
42
|
+
[stdout.read, stderr.read, status]
|
|
43
|
+
ensure
|
|
44
|
+
stdout&.close!
|
|
45
|
+
stderr&.close!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/process"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Space::Core
|
|
8
|
+
class MiseClient
|
|
9
|
+
def trust(path)
|
|
10
|
+
path = Pathname.new(path)
|
|
11
|
+
stdout, stderr, status = capture("mise", "trust", "--yes", "--quiet", "--cd", path.to_s)
|
|
12
|
+
return true if status.success?
|
|
13
|
+
|
|
14
|
+
output = [stdout, stderr].reject(&:empty?).join("\n").strip
|
|
15
|
+
message = "mise trust failed for #{path}"
|
|
16
|
+
message = "#{message}: #{output}" unless output.empty?
|
|
17
|
+
raise MiseError, message
|
|
18
|
+
rescue Errno::ENOENT
|
|
19
|
+
raise MiseError, "mise executable not found; install mise or make sure it is on PATH"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def capture(*command)
|
|
25
|
+
stdout = Tempfile.new("project-spaces-mise-stdout")
|
|
26
|
+
stderr = Tempfile.new("project-spaces-mise-stderr")
|
|
27
|
+
status = Async::Process.spawn(*command, out: stdout, err: stderr)
|
|
28
|
+
|
|
29
|
+
stdout.rewind
|
|
30
|
+
stderr.rewind
|
|
31
|
+
[stdout.read, stderr.read, status]
|
|
32
|
+
ensure
|
|
33
|
+
stdout&.close!
|
|
34
|
+
stderr&.close!
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "dry/monads"
|
|
6
|
+
|
|
7
|
+
module Space::Core
|
|
8
|
+
class OciBuilder
|
|
9
|
+
include Dry::Monads[:result]
|
|
10
|
+
|
|
11
|
+
def initialize(space:, output_dir:)
|
|
12
|
+
@space = space
|
|
13
|
+
@output_dir = Pathname.new(output_dir)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def version
|
|
17
|
+
compute_version.value!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def image
|
|
21
|
+
"#{space.id}:#{version}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def latest
|
|
25
|
+
"#{space.id}:latest"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def command
|
|
29
|
+
compute_version.fmap do |ver|
|
|
30
|
+
[
|
|
31
|
+
"container", "build",
|
|
32
|
+
"-f", @output_dir.join("Dockerfile").to_s,
|
|
33
|
+
"-t", "#{space.id}:#{ver}",
|
|
34
|
+
"-t", latest,
|
|
35
|
+
space.path.to_s
|
|
36
|
+
]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :space
|
|
43
|
+
|
|
44
|
+
def compute_version
|
|
45
|
+
sha_out, _, sha_status = Open3.capture3("git", "-C", space.path.to_s, "rev-parse", "--short=12", "HEAD")
|
|
46
|
+
unless sha_status.success?
|
|
47
|
+
return Failure("space is not a git repository with a commit; cannot compute a version tag")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sha = sha_out.strip
|
|
51
|
+
status_out, _, _ = Open3.capture3("git", "-C", space.path.to_s, "status", "--porcelain")
|
|
52
|
+
dirty = !status_out.strip.empty?
|
|
53
|
+
Success(dirty ? "#{sha}-dirty" : sha)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "dry/monads"
|
|
7
|
+
|
|
8
|
+
module Space::Core
|
|
9
|
+
class OciPacker
|
|
10
|
+
include Dry::Monads[:result]
|
|
11
|
+
|
|
12
|
+
TEMPLATE_DIR = Pathname.new(__dir__).join("templates", "oci").freeze
|
|
13
|
+
|
|
14
|
+
def initialize(space:, output_dir:)
|
|
15
|
+
@space = space
|
|
16
|
+
@output_dir = Pathname.new(output_dir)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def generate
|
|
20
|
+
FileUtils.mkdir_p(@output_dir)
|
|
21
|
+
validated = validate_provision_scripts(space.provision_scripts)
|
|
22
|
+
return validated if validated.failure?
|
|
23
|
+
validated = validate_persist_paths(space.persist_paths)
|
|
24
|
+
return validated if validated.failure?
|
|
25
|
+
write("Dockerfile", render_template("dockerfile.erb"))
|
|
26
|
+
write("entrypoint.sh", entrypoint_content, mode: 0o755)
|
|
27
|
+
write("Dockerfile.dockerignore", render_template("dockerignore.erb"))
|
|
28
|
+
Success(@output_dir)
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
Failure(e)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :space, :output_dir
|
|
36
|
+
|
|
37
|
+
def space_id
|
|
38
|
+
@space.id
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def repos
|
|
42
|
+
@space.repos
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def provision_scripts
|
|
46
|
+
@space.provision_scripts
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def persist_paths
|
|
50
|
+
@space.persist_paths
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_provision_scripts(scripts)
|
|
54
|
+
scripts.each do |rel_path|
|
|
55
|
+
if Pathname.new(rel_path).absolute?
|
|
56
|
+
return Failure("provision script '#{rel_path}' must not be an absolute path")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
resolved = space.path.join(rel_path).cleanpath
|
|
60
|
+
space_root = space.path.cleanpath
|
|
61
|
+
unless resolved.to_s.start_with?("#{space_root}/") || resolved == space_root
|
|
62
|
+
return Failure("provision script '#{rel_path}' escapes the space root")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return Failure("provision script '#{rel_path}' does not exist under the space root") unless resolved.exist?
|
|
66
|
+
end
|
|
67
|
+
Success(scripts)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_persist_paths(paths)
|
|
71
|
+
paths.each do |path|
|
|
72
|
+
unless Pathname.new(path).absolute?
|
|
73
|
+
return Failure("persist path '#{path}' must be an absolute path")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
Success(paths)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def entrypoint_content
|
|
80
|
+
@entrypoint_content ||= render_template("entrypoint.sh.erb")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def entrypoint_b64
|
|
84
|
+
[entrypoint_content].pack("m0")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def local_space_cadet?
|
|
88
|
+
space.path.join("repos/space-cadet").directory?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_template(name)
|
|
92
|
+
template_path = TEMPLATE_DIR.join(name)
|
|
93
|
+
ERB.new(template_path.read, trim_mode: "-").result(binding)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def write(filename, content, mode: 0o644)
|
|
97
|
+
path = @output_dir.join(filename)
|
|
98
|
+
File.write(path, content)
|
|
99
|
+
File.chmod(mode, path)
|
|
100
|
+
path
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
|
|
6
|
+
module Space::Core
|
|
7
|
+
class OciRunner
|
|
8
|
+
include Dry::Monads[:result]
|
|
9
|
+
|
|
10
|
+
# Always-forwarded substrate auth (the in-image architect/claude reach for these).
|
|
11
|
+
# Payload-specific vars are declared per-space via `run.env:` or the --env flag.
|
|
12
|
+
AUTH_ENV = %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_BASE_URL].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(space:, env: ENV.to_h, interactive: true, env_vars: [])
|
|
15
|
+
@space = space
|
|
16
|
+
@env = env
|
|
17
|
+
@interactive = interactive
|
|
18
|
+
@env_vars = (space.run_env + Array(env_vars)).map(&:to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def image
|
|
22
|
+
"#{space.id}:latest"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mounts
|
|
26
|
+
space.persist_paths.map do |guest|
|
|
27
|
+
[space.path.join(".state" + guest), guest]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def host_dirs
|
|
32
|
+
mounts.map(&:first)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def command(extra = [])
|
|
36
|
+
validated = validate_persist_paths(space.persist_paths)
|
|
37
|
+
return validated if validated.failure?
|
|
38
|
+
|
|
39
|
+
argv = [
|
|
40
|
+
"container", "run", "--rm",
|
|
41
|
+
*(@interactive ? ["-i", "-t"] : []),
|
|
42
|
+
*env_flags,
|
|
43
|
+
*mount_flags,
|
|
44
|
+
image,
|
|
45
|
+
*extra
|
|
46
|
+
]
|
|
47
|
+
Success(argv)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Explicitly-requested vars (run.env: + --env) that are unset/empty in the host
|
|
51
|
+
# env, so the caller can warn — silence here would let a missing key fail opaquely
|
|
52
|
+
# inside the guest. AUTH_ENV is opportunistic and intentionally excluded.
|
|
53
|
+
def missing_env
|
|
54
|
+
@env_vars.uniq.reject { |var| present?(var) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
attr_reader :space, :env, :interactive
|
|
60
|
+
|
|
61
|
+
# Bare `-e VAR` passthrough (no =value on the command line, so the secret never
|
|
62
|
+
# lands in argv/ps): substrate auth first, then the space's declared payload vars.
|
|
63
|
+
def env_flags
|
|
64
|
+
(AUTH_ENV + @env_vars).uniq.each_with_object([]) do |var, flags|
|
|
65
|
+
flags.push("-e", var) if present?(var)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def present?(var)
|
|
70
|
+
val = env[var]
|
|
71
|
+
val && !val.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def mount_flags
|
|
75
|
+
mounts.each_with_object([]) do |(host, guest), flags|
|
|
76
|
+
flags.push("-v", "#{host}:#{guest}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_persist_paths(paths)
|
|
81
|
+
paths.each do |path|
|
|
82
|
+
unless Pathname.new(path).absolute?
|
|
83
|
+
return Failure("persist path '#{path}' must be an absolute path")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
Success(paths)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Space::Core
|
|
6
|
+
module Paths
|
|
7
|
+
# paths:exempt-file - the shared module itself — this is the one home the guard defends
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Flags for matching a changed path against a lane's touch_set globs.
|
|
11
|
+
# PATHNAME keeps a single `*` from crossing `/`; EXTGLOB enables `{a,b}`;
|
|
12
|
+
# DOTMATCH lets a glob reach dotfile segments, so a `dir/**` touch set covers
|
|
13
|
+
# `dir/.github/workflows/ci.yml` — the standard deliverable for a lane preparing
|
|
14
|
+
# a directory to become a repo root.
|
|
15
|
+
TOUCH_FNM = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
|
|
16
|
+
|
|
17
|
+
# Every path beneath root, at every depth: files, directories, dotfiles, and
|
|
18
|
+
# dot-directory contents all included. `File::FNM_DOTMATCH` is what makes
|
|
19
|
+
# dotfiles visible to `Dir.glob`, but it also emits a bogus `root/.`
|
|
20
|
+
# self-entry — that trap is absorbed here so no callsite has to know about it.
|
|
21
|
+
# Returns Array<String> — Dir.glob's native return shape.
|
|
22
|
+
def content_tree(root)
|
|
23
|
+
Dir.glob(File.join(root.to_s, "**", "*"), File::FNM_DOTMATCH).reject { |p| File.basename(p) == "." }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The direct children of a fixed-depth structured directory layout (one
|
|
27
|
+
# entry per iteration/skill/lane/space) where a leading dot never names a
|
|
28
|
+
# real layout member — only tooling junk (.git, .DS_Store). Excludes them.
|
|
29
|
+
# Returns Array<Pathname> — Pathname#children's native return shape.
|
|
30
|
+
def layout_children(dir)
|
|
31
|
+
Pathname.new(dir).children.reject { |c| c.basename.to_s.start_with?(".") }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Does a single touch_set glob match path? A trailing `dir/**` is matched
|
|
35
|
+
# twice: bare (PATHNAME stops it at direct children) and as `dir/**/*`,
|
|
36
|
+
# whose whole-component `**/` does cross `/`.
|
|
37
|
+
def touch_match?(glob, path)
|
|
38
|
+
File.fnmatch(glob, path, TOUCH_FNM) ||
|
|
39
|
+
(glob.end_with?("/**") && File.fnmatch("#{glob}/*", path, TOUCH_FNM))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def contract(path, env: ENV)
|
|
43
|
+
value = path.to_s
|
|
44
|
+
home = XDG.home(env: env)
|
|
45
|
+
[home, realpath_or_nil(home)].compact.uniq.each do |h|
|
|
46
|
+
return "~" if value == h
|
|
47
|
+
return "~#{value.delete_prefix(h)}" if value.start_with?("#{h}/")
|
|
48
|
+
end
|
|
49
|
+
value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def realpath_or_nil(path)
|
|
53
|
+
File.realpath(path)
|
|
54
|
+
rescue SystemCallError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Space::Core
|
|
6
|
+
RepoReference = Data.define(:provider, :owner, :name, :clone_url, :source) do
|
|
7
|
+
def full_name
|
|
8
|
+
"#{provider}/#{owner}/#{name}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def directory_name
|
|
12
|
+
name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def src_path(root)
|
|
16
|
+
Pathname.new(root).join(provider, owner, name)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|