space-architect 8.0.0 → 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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +53 -243
- data/lib/space_architect/bug_report.rb +2 -1
- data/lib/space_architect/cli/architect.rb +20 -7
- data/lib/space_architect/cli.rb +9 -14
- data/lib/space_architect/session_sync/bin_path.rb +1 -1
- data/lib/space_architect/session_sync/cursor.rb +1 -1
- data/lib/space_architect/session_sync/plist.rb +1 -1
- data/lib/{space_core → space_architect}/version.rb +2 -2
- data/lib/space_architect.rb +1 -1
- data/skill/architect/SKILL.md +3 -2
- data/skill/architect-vocabulary/SKILL.md +2 -2
- metadata +18 -177
- data/exe/space +0 -13
- data/exe/src +0 -13
- data/lib/space_architect/cli/space.rb +0 -29
- data/lib/space_architect/cli/src.rb +0 -38
- data/lib/space_core/atomic_write.rb +0 -21
- data/lib/space_core/cli/base_command.rb +0 -19
- data/lib/space_core/cli/build.rb +0 -27
- data/lib/space_core/cli/config.rb +0 -49
- data/lib/space_core/cli/current.rb +0 -16
- data/lib/space_core/cli/help.rb +0 -195
- data/lib/space_core/cli/helpers.rb +0 -115
- data/lib/space_core/cli/init.rb +0 -29
- data/lib/space_core/cli/list.rb +0 -24
- data/lib/space_core/cli/loop_status.rb +0 -47
- data/lib/space_core/cli/new.rb +0 -38
- data/lib/space_core/cli/pack.rb +0 -29
- data/lib/space_core/cli/path.rb +0 -16
- data/lib/space_core/cli/repeatable_options.rb +0 -84
- data/lib/space_core/cli/repo.rb +0 -76
- data/lib/space_core/cli/run.rb +0 -44
- data/lib/space_core/cli/shell.rb +0 -125
- data/lib/space_core/cli/show.rb +0 -21
- data/lib/space_core/cli/status.rb +0 -74
- data/lib/space_core/cli/use.rb +0 -17
- data/lib/space_core/cli.rb +0 -177
- data/lib/space_core/commands.rb +0 -51
- data/lib/space_core/config.rb +0 -152
- data/lib/space_core/errors.rb +0 -14
- data/lib/space_core/git_client.rb +0 -49
- data/lib/space_core/mise_client.rb +0 -37
- data/lib/space_core/oci_builder.rb +0 -56
- data/lib/space_core/oci_packer.rb +0 -103
- data/lib/space_core/oci_runner.rb +0 -89
- data/lib/space_core/paths.rb +0 -58
- data/lib/space_core/repo_reference.rb +0 -19
- data/lib/space_core/repo_resolver.rb +0 -167
- data/lib/space_core/shell_integration.rb +0 -438
- data/lib/space_core/slugger.rb +0 -16
- data/lib/space_core/space.rb +0 -174
- data/lib/space_core/space_store.rb +0 -319
- data/lib/space_core/state.rb +0 -86
- data/lib/space_core/templates/oci/dockerfile.erb +0 -73
- data/lib/space_core/templates/oci/dockerignore.erb +0 -17
- data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
- data/lib/space_core/terminal.rb +0 -161
- data/lib/space_core/warnings.rb +0 -13
- data/lib/space_core/xdg.rb +0 -33
- data/lib/space_core.rb +0 -26
- data/lib/space_src/cli/clone.rb +0 -54
- data/lib/space_src/cli/config.rb +0 -65
- data/lib/space_src/cli/daemon.rb +0 -362
- data/lib/space_src/cli/options.rb +0 -21
- data/lib/space_src/cli/org.rb +0 -199
- data/lib/space_src/cli/repo.rb +0 -169
- data/lib/space_src/cli/shell.rb +0 -121
- data/lib/space_src/cli/status.rb +0 -75
- data/lib/space_src/cli/sync.rb +0 -161
- data/lib/space_src/cli.rb +0 -159
- data/lib/space_src/cloner.rb +0 -77
- data/lib/space_src/config/contract.rb +0 -54
- data/lib/space_src/config/duration.rb +0 -79
- data/lib/space_src/config/model.rb +0 -49
- data/lib/space_src/config/store.rb +0 -156
- data/lib/space_src/forge/client.rb +0 -31
- data/lib/space_src/forge/github.rb +0 -98
- data/lib/space_src/launchd/agent.rb +0 -195
- data/lib/space_src/launchd/plist.rb +0 -129
- data/lib/space_src/log_rotator.rb +0 -46
- data/lib/space_src/migration.rb +0 -43
- data/lib/space_src/nav.rb +0 -99
- data/lib/space_src/paths.rb +0 -72
- data/lib/space_src/scm/client.rb +0 -87
- data/lib/space_src/scm/git.rb +0 -232
- data/lib/space_src/scm/status.rb +0 -24
- data/lib/space_src/shell.rb +0 -90
- data/lib/space_src/shell_integration.rb +0 -321
- data/lib/space_src/state/lock.rb +0 -59
- data/lib/space_src/state/store.rb +0 -140
- data/lib/space_src/sync/engine.rb +0 -503
- data/lib/space_src/sync/repo_plan.rb +0 -215
- data/lib/space_src/sync/report.rb +0 -18
- data/lib/space_src/ui/interactive_reporter.rb +0 -280
- data/lib/space_src/ui/json_reporter.rb +0 -39
- data/lib/space_src/ui/mode.rb +0 -68
- data/lib/space_src/ui/plain_reporter.rb +0 -53
- data/lib/space_src/ui/reporter.rb +0 -48
- data/lib/space_src/version.rb +0 -7
- data/lib/space_src.rb +0 -38
|
@@ -1,49 +0,0 @@
|
|
|
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
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
|
@@ -1,56 +0,0 @@
|
|
|
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
|
|
@@ -1,103 +0,0 @@
|
|
|
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_architect?
|
|
88
|
-
space.path.join("repos/space-architect").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
|
|
@@ -1,89 +0,0 @@
|
|
|
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
|
data/lib/space_core/paths.rb
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
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
|
|
@@ -1,19 +0,0 @@
|
|
|
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
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
|
-
module Space::Core
|
|
6
|
-
class RepoResolver
|
|
7
|
-
SCP_LIKE_PATTERN = /\A(?:[^@\/]+@)?(?<provider>[^:\/]+):(?<path>.+)\z/
|
|
8
|
-
URL_PATTERN = %r{\A[A-Za-z][A-Za-z0-9+\-.]*://}
|
|
9
|
-
|
|
10
|
-
attr_reader :config
|
|
11
|
-
|
|
12
|
-
def initialize(config)
|
|
13
|
-
@config = config
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def resolve(spec)
|
|
17
|
-
value = spec.to_s.strip
|
|
18
|
-
raise RepoResolutionError, "Repo cannot be blank" if value.empty?
|
|
19
|
-
|
|
20
|
-
if url_like?(value)
|
|
21
|
-
resolve_url(value)
|
|
22
|
-
elsif (match = value.match(SCP_LIKE_PATTERN))
|
|
23
|
-
reference_from_parts(
|
|
24
|
-
provider: match[:provider],
|
|
25
|
-
path_parts: split_repo_path(match[:path]),
|
|
26
|
-
clone_url: value,
|
|
27
|
-
source: value
|
|
28
|
-
)
|
|
29
|
-
else
|
|
30
|
-
resolve_shorthand(value)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def resolve_url(value)
|
|
37
|
-
uri = URI.parse(value)
|
|
38
|
-
provider = uri.host
|
|
39
|
-
path = uri.path.to_s.delete_prefix("/")
|
|
40
|
-
raise RepoResolutionError, "Could not determine provider from '#{value}'" if provider.to_s.empty?
|
|
41
|
-
|
|
42
|
-
reference_from_parts(
|
|
43
|
-
provider: provider,
|
|
44
|
-
path_parts: split_repo_path(path),
|
|
45
|
-
clone_url: value,
|
|
46
|
-
source: value
|
|
47
|
-
)
|
|
48
|
-
rescue URI::InvalidURIError
|
|
49
|
-
raise RepoResolutionError, "Could not parse repo URL '#{value}'"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def resolve_shorthand(value)
|
|
53
|
-
parts = split_repo_path(value)
|
|
54
|
-
|
|
55
|
-
if parts.length == 1
|
|
56
|
-
resolve_default_organization_repo(parts.first, value)
|
|
57
|
-
elsif provider_like?(parts.first) && parts.length >= 3
|
|
58
|
-
reference_from_parts(
|
|
59
|
-
provider: parts.first,
|
|
60
|
-
path_parts: parts[1..],
|
|
61
|
-
clone_url: nil,
|
|
62
|
-
source: value
|
|
63
|
-
)
|
|
64
|
-
else
|
|
65
|
-
resolve_default_provider_repo(parts, value)
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def resolve_default_organization_repo(name, source)
|
|
70
|
-
provider = require_default_provider(source)
|
|
71
|
-
owner = config.default_organization
|
|
72
|
-
unless owner
|
|
73
|
-
raise RepoResolutionError,
|
|
74
|
-
"Repo '#{source}' needs an organization. Set one with: space config set default_organization ORG"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
reference(provider:, owner:, name:, clone_url: nil, source:)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def resolve_default_provider_repo(parts, source)
|
|
81
|
-
provider = require_default_provider(source)
|
|
82
|
-
reference_from_parts(provider:, path_parts: parts, clone_url: nil, source:)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def reference_from_parts(provider:, path_parts:, clone_url:, source:)
|
|
86
|
-
if path_parts.length < 2
|
|
87
|
-
raise RepoResolutionError, "Repo '#{source}' must include an organization and repo name"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
name = path_parts.last
|
|
91
|
-
owner = path_parts[0...-1].join("/")
|
|
92
|
-
reference(provider:, owner:, name:, clone_url:, source:)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def reference(provider:, owner:, name:, clone_url:, source:)
|
|
96
|
-
normalized_provider = normalize_provider(provider)
|
|
97
|
-
normalized_owner = normalize_path_part(owner)
|
|
98
|
-
normalized_name = normalize_repo_name(name)
|
|
99
|
-
|
|
100
|
-
RepoReference.new(
|
|
101
|
-
provider: normalized_provider,
|
|
102
|
-
owner: normalized_owner,
|
|
103
|
-
name: normalized_name,
|
|
104
|
-
clone_url: clone_url || clone_url_for(normalized_provider, normalized_owner, normalized_name),
|
|
105
|
-
source: source
|
|
106
|
-
)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def clone_url_for(provider, owner, name)
|
|
110
|
-
case config.git_clone_protocol
|
|
111
|
-
when "ssh"
|
|
112
|
-
"git@#{provider}:#{owner}/#{name}.git"
|
|
113
|
-
when "https"
|
|
114
|
-
"https://#{provider}/#{owner}/#{name}.git"
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def split_repo_path(value)
|
|
119
|
-
normalized = value.to_s.strip.delete_prefix("/").delete_suffix("/")
|
|
120
|
-
normalized = normalized.delete_suffix(".git")
|
|
121
|
-
parts = normalized.split("/").reject(&:empty?)
|
|
122
|
-
raise RepoResolutionError, "Repo '#{value}' must include a repo name" if parts.empty?
|
|
123
|
-
|
|
124
|
-
parts
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def require_default_provider(source)
|
|
128
|
-
config.default_provider || raise(
|
|
129
|
-
RepoResolutionError,
|
|
130
|
-
"Repo '#{source}' needs a provider. Set one with: space config set default_provider PROVIDER"
|
|
131
|
-
)
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def normalize_provider(value)
|
|
135
|
-
normalized = value.to_s.strip
|
|
136
|
-
normalized = normalized.delete_prefix("https://")
|
|
137
|
-
normalized = normalized.delete_prefix("http://")
|
|
138
|
-
normalized = normalized.delete_prefix("ssh://")
|
|
139
|
-
normalized = normalized.delete_suffix("/")
|
|
140
|
-
raise RepoResolutionError, "Provider cannot be blank" if normalized.empty?
|
|
141
|
-
|
|
142
|
-
normalized
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def normalize_path_part(value)
|
|
146
|
-
normalized = value.to_s.strip.delete_prefix("/").delete_suffix("/")
|
|
147
|
-
raise RepoResolutionError, "Organization cannot be blank" if normalized.empty?
|
|
148
|
-
|
|
149
|
-
normalized
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def normalize_repo_name(value)
|
|
153
|
-
normalized = value.to_s.strip.delete_suffix(".git")
|
|
154
|
-
raise RepoResolutionError, "Repo name cannot be blank" if normalized.empty?
|
|
155
|
-
|
|
156
|
-
normalized
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def url_like?(value)
|
|
160
|
-
value.match?(URL_PATTERN)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def provider_like?(value)
|
|
164
|
-
value.include?(".") || value.include?(":") || value == "localhost"
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
end
|