space-cadet 9.1.0 → 9.2.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 +32 -0
- data/README.md +18 -225
- data/lib/space_core/cli/helpers.rb +43 -2
- data/lib/space_core/cli/new.rb +8 -3
- data/lib/space_core/cli/repo.rb +6 -3
- data/lib/space_core/scm/git.rb +29 -1
- data/lib/space_core/space_store.rb +63 -14
- data/lib/space_core/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c191a11ce1e7a132b55a094cf765ea168bc58a143c4d6239db28354349127ce9
|
|
4
|
+
data.tar.gz: a64cf8802acd3667b51783eaf3677223654a1cdb07f853d3ee9c72ef94ce55a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a752744e4deb343a3f59b8cc45b25b67769e26243299e9825256e186db05361a8b299723877d71bcc1599c4321509d3735b4564f4ce876703c85d8ccade8dc2
|
|
7
|
+
data.tar.gz: c88b462412b4a43316678886c5c19ca3dcdbbe5492cd9af31031a6fc0f19064c3dda41dbca437684460d41c3e81be5d02fc1bdfd4b156bcea005c2df22e35dc0
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [9.2.0] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`--trust-host-key` flag on `space repo add` and `space new`.** Appends
|
|
13
|
+
`-o StrictHostKeyChecking=accept-new` to the ssh command used for clones
|
|
14
|
+
(TOFU: first-contact host keys are accepted automatically; *changed* keys
|
|
15
|
+
still fail verification, so MITM protection is retained). Using the flag
|
|
16
|
+
prints a stderr warning before fetching begins. `space` never sets
|
|
17
|
+
`StrictHostKeyChecking=no`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **ssh clones never prompt.** Every network clone now runs the child with
|
|
22
|
+
`GIT_SSH_COMMAND` set to `<inherited GIT_SSH_COMMAND, or "ssh"> -o
|
|
23
|
+
BatchMode=yes` (options appended after an inherited value, assumed
|
|
24
|
+
ssh-compatible; git ignores the variable for https URLs), so host-key,
|
|
25
|
+
passphrase, and password prompts fail fast instead of hanging behind the
|
|
26
|
+
progress spinner. A failure containing `Host key verification failed`
|
|
27
|
+
surfaces guidance naming `--trust-host-key` and `known_hosts`; every other
|
|
28
|
+
failure surfaces the raw stderr as before.
|
|
29
|
+
- **Idempotent `space repo add` / `space new -r`.** Re-adding an
|
|
30
|
+
already-tracked repo is a no-op success (`Already present: …`, no clone,
|
|
31
|
+
no new `space.yaml` entry), and an untracked destination that already
|
|
32
|
+
contains a `.git` directory is adopted — mise-trusted and registered with
|
|
33
|
+
no fetch (`Registered existing checkout: …`) — so the corrected rerun
|
|
34
|
+
after a partially-failed batch succeeds instead of erroring on the
|
|
35
|
+
orphaned destination. A destination without `.git` is still refused
|
|
36
|
+
(not-a-git-repository error), path/name collisions with a *different*
|
|
37
|
+
tracked repo still error, and registration remains all-or-nothing: any
|
|
38
|
+
fetch failure exits non-zero and registers nothing.
|
|
39
|
+
|
|
8
40
|
## [9.1.0] - 2026-10-10
|
|
9
41
|
|
|
10
42
|
### Added
|
data/README.md
CHANGED
|
@@ -16,244 +16,37 @@ evergreen checkouts spaces provision from).
|
|
|
16
16
|
tool was absorbed into space-architect (up to 8.0.0) and is extracted back out
|
|
17
17
|
here as of 9.0.0.
|
|
18
18
|
|
|
19
|
-
## What's a space? 🪐
|
|
20
|
-
|
|
21
|
-
A space is just a regular directory with a tiny YAML identity file and room for
|
|
22
|
-
everything a task needs:
|
|
23
|
-
|
|
24
|
-
```text
|
|
25
|
-
~/architect/spaces/20260531-name-of-space/
|
|
26
|
-
space.yaml # identity: id, title, status, repos, notes, tags
|
|
27
|
-
README.md
|
|
28
|
-
repos/ # cloned (or copy-on-write'd) repositories
|
|
29
|
-
notes/ # scratch, prompts, logs
|
|
30
|
-
architecture/ # project files when the space runs an Architect project
|
|
31
|
-
build/ # OCI contexts + scratch (build/oci/)
|
|
32
|
-
tmp/ # workspace-local temp — use this instead of /tmp
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Run a command from anywhere inside a space and it just works — `space` walks up
|
|
36
|
-
from `$PWD` until it finds the nearest `space.yaml`. No "current space" state
|
|
37
|
-
to get out of sync; where you *are* is the space you mean. 🧭
|
|
38
|
-
|
|
39
19
|
## Installation 📦
|
|
40
20
|
|
|
41
|
-
Add it to your `Gemfile`:
|
|
42
|
-
|
|
43
|
-
```ruby
|
|
44
|
-
gem "space-cadet"
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
bundle install
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Or grab it yourself:
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
gem install space-cadet
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
The gem is `space-cadet`; it installs one executable, `space`. 🎀
|
|
58
|
-
|
|
59
|
-
## Quick start 🎀
|
|
60
|
-
|
|
61
|
-
```sh
|
|
62
|
-
space init # create XDG config + state files
|
|
63
|
-
space new "Name of Space" # blast off a new space 🚀
|
|
64
|
-
space new "Name of Space" -r org/repo -r org/lib # …with repos cloned in (repeat -r)
|
|
65
|
-
space list # see all your spaces
|
|
66
|
-
space show # show the space you're standing in
|
|
67
|
-
|
|
68
|
-
# Containers (run from inside a space) — a portable, reproducible-by-SHA image of the space
|
|
69
|
-
space pack # render build/oci/ (Dockerfile + entrypoint)
|
|
70
|
-
space build # pack + build & tag <space-id>:<git-sha>
|
|
71
|
-
space run # run it — login shell, auth from your env
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
## Commands 🌌
|
|
75
|
-
|
|
76
|
-
```sh
|
|
77
|
-
space init
|
|
78
|
-
space new "Name of Space"
|
|
79
|
-
space new "Name of Space" -r org/repo -r example-tools/alpha -r example-tools/beta
|
|
80
|
-
space list # alias: space ls
|
|
81
|
-
space show 20260531-name-of-space
|
|
82
|
-
space path 20260531-name-of-space
|
|
83
|
-
space current # based on $PWD
|
|
84
|
-
space show # based on $PWD
|
|
85
|
-
space status # report: metadata (+ project status when present)
|
|
86
|
-
space status 20260531-name-of-space # report another space
|
|
87
|
-
space status done # set: active | paused | done | archived
|
|
88
|
-
space status 20260531-name-of-space done
|
|
89
|
-
space config set default_provider github.com
|
|
90
|
-
space config set default_organization example-org
|
|
91
|
-
space repo add example-app # github.com/example-org/example-app
|
|
92
|
-
space repo add example-tools/alpha example-tools/beta
|
|
93
|
-
space repo add gitlab.com/example-org/api
|
|
94
|
-
space repo resolve example-app example-tools/async
|
|
95
|
-
space repo ls # alias: space repos ls
|
|
96
|
-
space use 20260531-name-of-space # records recent state, prints the path
|
|
97
|
-
space ls --color=always # auto | always | never (--colors also accepted)
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
`space status` is **report-or-set**: with no status keyword (bare, or with just
|
|
101
|
-
a space id) it *reports* the space — its metadata plus a compact project-status
|
|
102
|
-
block when the space declares one, quietly omitted otherwise; pass a status
|
|
103
|
-
keyword (`active`, `paused`, `done`, `archived`) to *set* it instead. 🔁
|
|
104
|
-
|
|
105
|
-
Repos are passed with a repeatable `-r` flag (`-r org/repo -r org/lib`); the
|
|
106
|
-
comma form (`-r a,b`) works too. Space ids are date-prefixed
|
|
107
|
-
(`20260531-name-of-space`) so they sort naturally, and duplicate names on the
|
|
108
|
-
same day get a counter (`…-name-of-space-2`). 📅
|
|
109
|
-
|
|
110
|
-
Full command reference: [docs/reference.md](docs/reference.md).
|
|
111
|
-
|
|
112
|
-
## Containerize a space: `pack` · `build` · `run` 📦
|
|
113
|
-
|
|
114
|
-
A space is self-describing enough to become a container. `space pack` renders a
|
|
115
|
-
portable OCI build context from the space; `space build` packs and builds it into
|
|
116
|
-
a **reproducible-by-SHA** image; `space run` runs that image with your auth
|
|
117
|
-
injected at runtime and stateful paths bind-mounted back to the host. 🐳
|
|
118
|
-
|
|
119
21
|
```sh
|
|
120
|
-
|
|
121
|
-
space pack -o /tmp/ctx # …to a different output directory
|
|
122
|
-
space build # pack, then build & tag <space-id>:<git-sha> and :latest
|
|
123
|
-
space run # run <space-id>:latest — login shell, auth from your env
|
|
124
|
-
space run architect status # …or run a one-off command instead of the login shell
|
|
125
|
-
space run --tty # force an interactive TTY (default: auto-detect)
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
**What lands in the image.** The context copies the whole space tree (filtered by
|
|
129
|
-
a generated `Dockerfile.dockerignore`) onto a `ruby:4.0.5` base with `git`, the
|
|
130
|
-
Claude Code CLI, and the `space-cadet` gem — installed from the in-space
|
|
131
|
-
`repos/space-cadet` checkout when present (a pinned build), else from
|
|
132
|
-
RubyGems. Secrets never enter the layers: `.env`, `*.key`, `*.pem`, ssh keys,
|
|
133
|
-
`build/`, and `tmp/` are all excluded by the generated ignore file. 🔒
|
|
134
|
-
|
|
135
|
-
**Reproducible by SHA.** `space build` tags the image `<space-id>:<sha>`, where
|
|
136
|
-
`<sha>` is the space repo's 12-char `HEAD` (suffixed `-dirty` when the tree has
|
|
137
|
-
uncommitted changes), plus a moving `:latest`. Same commit → same tag → same
|
|
138
|
-
image. It drives the `container` CLI, but the output is an ordinary OCI/Docker
|
|
139
|
-
build context, so `docker build -f build/oci/Dockerfile .` (from the space root)
|
|
140
|
-
works just as well.
|
|
141
|
-
|
|
142
|
-
**Auth stays out of the layers.** `space run` injects only the auth environment
|
|
143
|
-
variables that are actually set — `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`,
|
|
144
|
-
`ANTHROPIC_BASE_URL` — with `-e` at run time, so credentials live in your shell,
|
|
145
|
-
never in the image. 🗝️
|
|
146
|
-
|
|
147
|
-
**Forwarding payload credentials.** Beyond the always-on auth trio, a space can declare
|
|
148
|
-
`run.env:` in `space.yaml` — a list of host env var names forwarded into the container at
|
|
149
|
-
run time. You can also pass `space run --env VAR` (repeatable) for ad hoc additions. All
|
|
150
|
-
forwarding is bare `-e VAR` passthrough: values never appear in argv, `ps`, or the image.
|
|
151
|
-
A requested-but-unset var warns on stderr instead of silently failing inside the guest.
|
|
152
|
-
|
|
153
|
-
```yaml
|
|
154
|
-
run:
|
|
155
|
-
env: # run-time: host var names, forwarded as bare -e VAR
|
|
156
|
-
- FIREWORKS_API_KEY
|
|
157
|
-
- OPENAI_API_KEY
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
**Declaring provisioning & persistence** — two optional keys in `space.yaml`:
|
|
161
|
-
|
|
162
|
-
```yaml
|
|
163
|
-
pack:
|
|
164
|
-
provision: # build-time: relative scripts, baked in by `space build`
|
|
165
|
-
- scripts/setup-toolchain.sh
|
|
166
|
-
persist: # run-time: absolute guest paths, bind-mounted per run
|
|
167
|
-
- /root/.claude
|
|
168
|
-
- /root/.local/state
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
`provision` scripts must live under the space root and be executable. Each script is
|
|
172
|
-
copied into the image individually (`COPY <script> /space/<script>`) and then invoked
|
|
173
|
-
(`RUN /space/<script>`) **before** the full space tree lands and **before** the gem is
|
|
174
|
-
installed. This ordering is the cache-hygiene guarantee: editing any other space file
|
|
175
|
-
leaves the provision and gem-install layers cached, so a rebuild completes in seconds
|
|
176
|
-
instead of minutes. Scripts must therefore be self-contained — they run with only the
|
|
177
|
-
base system layers and any earlier provision scripts' outputs; they cannot read other
|
|
178
|
-
space files or call `space`. Scripts must have the executable bit set; COPY preserves
|
|
179
|
-
the mode from the build context.
|
|
180
|
-
|
|
181
|
-
`persist` paths must be absolute; `space run` bind-mounts each one from
|
|
182
|
-
`<space>/.state<path>` on the host (created on first run) so a container's mutable state
|
|
183
|
-
survives across runs. Both are validated at pack time.
|
|
184
|
-
|
|
185
|
-
## Fish shell integration 🐟
|
|
186
|
-
|
|
187
|
-
Shells can't let a child process change *their* working directory, so `space`
|
|
188
|
-
ships a small fish wrapper function plus completions (commands, subcommands,
|
|
189
|
-
spaces, statuses, config keys, repo refs). Install into fish's autoloaded
|
|
190
|
-
directories:
|
|
191
|
-
|
|
192
|
-
```fish
|
|
193
|
-
space shell fish install
|
|
194
|
-
exec fish
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
After restarting fish (or `exec fish`), `space new "…"` and
|
|
198
|
-
`space use <id>` will `cd` into the selected space once the command succeeds;
|
|
199
|
-
every other command keeps normal CLI behavior. 🚪 The functions and completions
|
|
200
|
-
are written under `~/.config/fish/`, so there's no need to edit `config.fish`.
|
|
201
|
-
For one-off testing without installing:
|
|
202
|
-
|
|
203
|
-
```fish
|
|
204
|
-
space shell init fish | source
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
## Configuration ⚙️
|
|
208
|
-
|
|
209
|
-
Config lives at `~/.config/space-cadet/config.yml` (XDG-aware) and defaults to:
|
|
210
|
-
|
|
211
|
-
```yaml
|
|
212
|
-
version: 1
|
|
213
|
-
base_dir: ~/architect # spaces_dir + src_dir hang off this by default
|
|
214
|
-
default_provider: github.com
|
|
215
|
-
default_organization:
|
|
216
|
-
git_clone_protocol: ssh # ssh | https
|
|
22
|
+
gem install space-cadet
|
|
217
23
|
```
|
|
218
24
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
`space config show`; set one with `space config set KEY VALUE`. Editable keys:
|
|
222
|
-
`base_dir`, `spaces_dir`, `src_dir`, `default_provider`, `default_organization`,
|
|
223
|
-
`git_clone_protocol`.
|
|
25
|
+
or add `gem "space-cadet"` to your `Gemfile` and `bundle install`. The gem
|
|
26
|
+
installs one executable, `space`. 🎀
|
|
224
27
|
|
|
225
|
-
##
|
|
226
|
-
|
|
227
|
-
Repos are added to the current space under `repos/` and tracked in `space.yaml`.
|
|
228
|
-
When an up-to-date evergreen checkout exists at
|
|
229
|
-
`<src_dir>/<host>/<owner>/<name>` (e.g.
|
|
230
|
-
`~/architect/src/github.com/example-org/example-app` — tended by
|
|
231
|
-
[jetpks/repo-tender](https://github.com/jetpks/repo-tender)), `space` copies it
|
|
232
|
-
into the space instead of cloning over the network — a copy-on-write clone on
|
|
233
|
-
APFS. ⚡ Set `src_dir` empty to always clone:
|
|
28
|
+
## 60-second start 🚀
|
|
234
29
|
|
|
235
30
|
```sh
|
|
236
|
-
space config
|
|
31
|
+
space init # create XDG config + state files
|
|
32
|
+
space new "My First Space" # blast off a new space 🪐
|
|
33
|
+
cd ~/architect/spaces/*my-first-space
|
|
34
|
+
space status # report where you are
|
|
237
35
|
```
|
|
238
36
|
|
|
239
|
-
|
|
240
|
-
with `space config set git_clone_protocol https`. Multiple repos passed to
|
|
241
|
-
`space repo add` are fetched **concurrently**, up to five at a time, on fibers —
|
|
242
|
-
no threads, all cooperative. 🧵 After each repo lands, `space` runs `mise trust`
|
|
243
|
-
in it. Each space also gets a workspace-local `tmp/` — use it instead of `/tmp`.
|
|
244
|
-
|
|
245
|
-
## Embedding 📚
|
|
246
|
-
|
|
247
|
-
```ruby
|
|
248
|
-
require "space_core" # config, state, XDG, terminal, git/mise clients,
|
|
249
|
-
# the space store — the `space` CLI runs on this alone
|
|
250
|
-
```
|
|
37
|
+
Full learning journey: [your first space](docs/tutorials/first-space.md).
|
|
251
38
|
|
|
252
39
|
## Documentation 📖
|
|
253
40
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
41
|
+
Organized by [Diátaxis](https://diataxis.fr/) — pick the quadrant that matches
|
|
42
|
+
what you're here for:
|
|
43
|
+
|
|
44
|
+
| I want to… | Go to |
|
|
45
|
+
|---|---|
|
|
46
|
+
| **learn** the tool step by step | [Tutorials](docs/tutorials/) |
|
|
47
|
+
| **get something done** (a recipe) | [How-to guides](docs/how-to/) |
|
|
48
|
+
| **look up** a command, flag, or path | [Reference](docs/reference/) |
|
|
49
|
+
| **understand** why it works this way | [Explanation](docs/explanation/) |
|
|
257
50
|
|
|
258
51
|
## Development 🛠️
|
|
259
52
|
|
|
@@ -5,6 +5,30 @@ require "dry/monads"
|
|
|
5
5
|
module Space::Core::CLI
|
|
6
6
|
module Helpers
|
|
7
7
|
include Dry::Monads[:result]
|
|
8
|
+
|
|
9
|
+
# Printed before any fetch when --trust-host-key is set. The operator
|
|
10
|
+
# should see the TOFU semantics at flag time — deterministic and
|
|
11
|
+
# locale-independent — rather than via ssh's own "Permanently added …"
|
|
12
|
+
# stderr line, which would need locale-fragile scraping to relay.
|
|
13
|
+
TRUST_HOST_KEY_WARNING =
|
|
14
|
+
"Warning: trusting unknown host keys on first contact (--trust-host-key); changed keys still fail verification."
|
|
15
|
+
|
|
16
|
+
def warn_trust_host_key
|
|
17
|
+
terminal.error TRUST_HOST_KEY_WARNING
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# One success line per repo-addition outcome. `Added <full_name>` keeps
|
|
21
|
+
# its exact historical shape (existing tests assert it); the other two
|
|
22
|
+
# make idempotent adds visible — what was fetched vs skipped vs adopted.
|
|
23
|
+
def repo_addition_line(result)
|
|
24
|
+
full_name = result.fetch(:repo).fetch("full_name")
|
|
25
|
+
case result.fetch(:outcome)
|
|
26
|
+
when :present then "Already present: #{full_name}"
|
|
27
|
+
when :adopted then "Registered existing checkout: #{full_name}"
|
|
28
|
+
else "Added #{full_name}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
8
32
|
def project_config
|
|
9
33
|
@project_config ||= Space::Core::Config.load
|
|
10
34
|
end
|
|
@@ -67,9 +91,22 @@ class RepoProgress
|
|
|
67
91
|
@statuses = {}
|
|
68
92
|
end
|
|
69
93
|
|
|
94
|
+
# No fetch happens for an :adopted addition (its checkout is already on
|
|
95
|
+
# disk) — label it adopting, never cloning/copying. :present additions
|
|
96
|
+
# never start at all; they are marked done up front via #skip.
|
|
70
97
|
def start(addition)
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
repo = addition.fetch(:reference).full_name
|
|
99
|
+
case addition.fetch(:outcome, :added)
|
|
100
|
+
when :adopted
|
|
101
|
+
@statuses[repo] = :adopting
|
|
102
|
+
else
|
|
103
|
+
source = addition[:src_source]
|
|
104
|
+
@statuses[repo] = source&.directory? ? :copying : :cloning
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def skip(addition)
|
|
109
|
+
@statuses[addition.fetch(:reference).full_name] = :done
|
|
73
110
|
end
|
|
74
111
|
|
|
75
112
|
def trust(addition)
|
|
@@ -89,14 +126,17 @@ class RepoProgress
|
|
|
89
126
|
failed = @statuses.count { |_repo, status| status == :failed }
|
|
90
127
|
copying = @statuses.select { |_repo, status| status == :copying }.keys
|
|
91
128
|
cloning = @statuses.select { |_repo, status| status == :cloning }.keys
|
|
129
|
+
adopting = @statuses.select { |_repo, status| status == :adopting }.keys
|
|
92
130
|
trusting = @statuses.select { |_repo, status| status == :trusting }.keys
|
|
93
131
|
|
|
94
132
|
if @total == 1
|
|
95
133
|
copying_repo = copying.first
|
|
96
134
|
cloning_repo = cloning.first
|
|
135
|
+
adopting_repo = adopting.first
|
|
97
136
|
trusting_repo = trusting.first
|
|
98
137
|
return "Copying #{copying_repo}" if copying_repo
|
|
99
138
|
return "Cloning #{cloning_repo}" if cloning_repo
|
|
139
|
+
return "Adopting #{adopting_repo}" if adopting_repo
|
|
100
140
|
return "Trusting #{trusting_repo}" if trusting_repo
|
|
101
141
|
return "Fetch failed" if failed.positive?
|
|
102
142
|
|
|
@@ -105,6 +145,7 @@ class RepoProgress
|
|
|
105
145
|
active = []
|
|
106
146
|
active << "copying #{copying.join(', ')}" unless copying.empty?
|
|
107
147
|
active << "cloning #{cloning.join(', ')}" unless cloning.empty?
|
|
148
|
+
active << "adopting #{adopting.join(', ')}" unless adopting.empty?
|
|
108
149
|
active << "trusting #{trusting.join(', ')}" unless trusting.empty?
|
|
109
150
|
suffix = active.empty? ? nil : ": #{active.join('; ')}"
|
|
110
151
|
failed_text = failed.positive? ? ", #{failed} failed" : ""
|
data/lib/space_core/cli/new.rb
CHANGED
|
@@ -6,9 +6,11 @@ class New < BaseCommand
|
|
|
6
6
|
argument :title, required: true, desc: "Space title"
|
|
7
7
|
option :repo, type: :array, aliases: ["-r"], desc: "Repo ref to clone (repeatable: pass -r once per repo)"
|
|
8
8
|
option :git, type: :boolean, default: true, desc: "Initialize the space as a Git repository (use --no-git to skip)"
|
|
9
|
+
option :trust_host_key, type: :boolean, default: false,
|
|
10
|
+
desc: "Accept unknown ssh host keys on first contact (changed keys still fail)"
|
|
9
11
|
example "\"My Space\" -r org/repo -r example-tools/alpha # clone two repos into the space"
|
|
10
12
|
|
|
11
|
-
def call(title:, repo: [], git: true, **opts)
|
|
13
|
+
def call(title:, repo: [], git: true, trust_host_key: false, **opts)
|
|
12
14
|
setup_terminal(**opts.slice(:color, :colors))
|
|
13
15
|
result = store.create(title, git: git).bind do |space|
|
|
14
16
|
terminal.success "Created #{space.id}"
|
|
@@ -18,12 +20,15 @@ class New < BaseCommand
|
|
|
18
20
|
|
|
19
21
|
next Success(space) if repo_specs.empty?
|
|
20
22
|
|
|
23
|
+
warn_trust_host_key if trust_host_key
|
|
21
24
|
progress = RepoProgress.new(repo_specs.length)
|
|
22
25
|
terminal.with_spinner(-> { progress.message }) do
|
|
23
|
-
store.add_repos_to(space, repo_specs,
|
|
26
|
+
store.add_repos_to(space, repo_specs,
|
|
27
|
+
scm: Space::Core::SCM::Git.new(trust_host_key: trust_host_key),
|
|
28
|
+
reporter: progress)
|
|
24
29
|
end.fmap do |results|
|
|
25
30
|
results.each do |r|
|
|
26
|
-
terminal.success
|
|
31
|
+
terminal.success repo_addition_line(r)
|
|
27
32
|
terminal.say terminal.path(r.fetch(:path))
|
|
28
33
|
end
|
|
29
34
|
space
|
data/lib/space_core/cli/repo.rb
CHANGED
|
@@ -5,8 +5,10 @@ module Repo
|
|
|
5
5
|
class Add < BaseCommand
|
|
6
6
|
desc "Add repos to the current space (copy-on-write from an evergreen checkout when available, else clone)"
|
|
7
7
|
argument :repos, type: :array, required: false, desc: "REPO [REPO...]"
|
|
8
|
+
option :trust_host_key, type: :boolean, default: false,
|
|
9
|
+
desc: "Accept unknown ssh host keys on first contact (changed keys still fail)"
|
|
8
10
|
|
|
9
|
-
def call(repos: [], **opts)
|
|
11
|
+
def call(repos: [], trust_host_key: false, **opts)
|
|
10
12
|
setup_terminal(**opts.slice(:color, :colors))
|
|
11
13
|
handle_errors do
|
|
12
14
|
specs = Array(repos).compact
|
|
@@ -16,13 +18,14 @@ module Repo
|
|
|
16
18
|
next
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
warn_trust_host_key if trust_host_key
|
|
19
22
|
progress = RepoProgress.new(specs.length)
|
|
20
23
|
add_result = terminal.with_spinner(-> { progress.message }) do
|
|
21
|
-
store.add_repos(specs, reporter: progress)
|
|
24
|
+
store.add_repos(specs, scm: Space::Core::SCM::Git.new(trust_host_key: trust_host_key), reporter: progress)
|
|
22
25
|
end
|
|
23
26
|
render(add_result) do |results|
|
|
24
27
|
results.each do |result|
|
|
25
|
-
terminal.success
|
|
28
|
+
terminal.success repo_addition_line(result)
|
|
26
29
|
terminal.say terminal.path(result.fetch(:path))
|
|
27
30
|
end
|
|
28
31
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
data/lib/space_core/scm/git.rb
CHANGED
|
@@ -11,6 +11,17 @@ module Space::Core
|
|
|
11
11
|
# Git CLI implementation of SCM::Client. All subprocess work is
|
|
12
12
|
# delegated to Shell.run (which requires an ambient Async::Task).
|
|
13
13
|
class Git < Client
|
|
14
|
+
# trust_host_key: append `-o StrictHostKeyChecking=accept-new` to the
|
|
15
|
+
# ssh command clones run under (TOFU: first-contact host keys are
|
|
16
|
+
# accepted automatically, changed keys still fail verification). The
|
|
17
|
+
# default (false) keeps strict host key checking. Host key
|
|
18
|
+
# verification is never disabled outright — the operator's "just
|
|
19
|
+
# disable it" urge is met with the strictly safer accept-new shape,
|
|
20
|
+
# opt-in only.
|
|
21
|
+
def initialize(trust_host_key: false)
|
|
22
|
+
@trust_host_key = trust_host_key
|
|
23
|
+
end
|
|
24
|
+
|
|
14
25
|
# Resolve the bare remote's HEAD. First try the local
|
|
15
26
|
# `origin/HEAD` symbolic ref; if missing/stale, do a one-shot
|
|
16
27
|
# `git remote set-head origin -a` (network) to refresh it, then
|
|
@@ -107,10 +118,18 @@ module Space::Core
|
|
|
107
118
|
Dry::Monads::Success(parsed)
|
|
108
119
|
end
|
|
109
120
|
|
|
121
|
+
# Every clone runs ssh non-interactively. Open3.capture3 drains the
|
|
122
|
+
# child's stdout/stderr pipes, but ssh opens the controlling terminal
|
|
123
|
+
# (/dev/tty) directly for its host-key/passphrase/password prompts, so
|
|
124
|
+
# without BatchMode=yes a first-contact ssh clone hangs forever behind
|
|
125
|
+
# the CLI spinner. GIT_SSH_COMMAND makes every ssh prompt fail fast
|
|
126
|
+
# instead; git ignores it for https URLs, so it is set unconditionally
|
|
127
|
+
# — no URL-scheme conditional. An inherited GIT_SSH_COMMAND is honored
|
|
128
|
+
# with our options appended after it (assumed ssh-compatible).
|
|
110
129
|
def clone(url, dest)
|
|
111
130
|
parent = File.dirname(dest)
|
|
112
131
|
FileUtils.mkdir_p(parent)
|
|
113
|
-
result = Shell.run("git", "clone", url, dest)
|
|
132
|
+
result = Shell.run("git", "clone", url, dest, env: {"GIT_SSH_COMMAND" => git_ssh_command})
|
|
114
133
|
if result.success?
|
|
115
134
|
Dry::Monads::Success(dest)
|
|
116
135
|
else
|
|
@@ -165,6 +184,15 @@ module Space::Core
|
|
|
165
184
|
|
|
166
185
|
private
|
|
167
186
|
|
|
187
|
+
# Computed at clone time from the in-process ENV so callers may set
|
|
188
|
+
# GIT_SSH_COMMAND after this SCM was constructed. An unset or blank
|
|
189
|
+
# inherited value degrades to plain "ssh".
|
|
190
|
+
def git_ssh_command
|
|
191
|
+
base = ENV["GIT_SSH_COMMAND"].to_s.strip.empty? ? "ssh" : ENV["GIT_SSH_COMMAND"]
|
|
192
|
+
trust_args = @trust_host_key ? ["-o", "StrictHostKeyChecking=accept-new"] : []
|
|
193
|
+
[base, "-o", "BatchMode=yes", *trust_args].join(" ")
|
|
194
|
+
end
|
|
195
|
+
|
|
168
196
|
# `git symbolic-ref --short refs/remotes/origin/HEAD` returns
|
|
169
197
|
# `origin/<branch>` (the short form of `refs/remotes/origin/<branch>`);
|
|
170
198
|
# callers want the bare branch name. Returns nil when origin/HEAD
|
|
@@ -122,16 +122,22 @@ module Space::Core
|
|
|
122
122
|
|
|
123
123
|
def add_repos_to(space, specs, scm: SCM::Git.new, cloner: nil, mise_client: MiseClient.new, reporter: nil)
|
|
124
124
|
additions = prepare_repo_additions(space, specs)
|
|
125
|
+
# :present additions (already tracked) do no work at all; mark them
|
|
126
|
+
# done for the reporter so progress counts every spec without ever
|
|
127
|
+
# claiming a fetch for one.
|
|
128
|
+
additions.each { |addition| reporter&.skip(addition) if addition.fetch(:outcome) == :present }
|
|
125
129
|
first_error = nil
|
|
126
130
|
|
|
127
131
|
Async do |task|
|
|
128
132
|
semaphore = Async::Semaphore.new(MAX_CONCURRENT_CLONES, parent: task)
|
|
129
133
|
|
|
130
134
|
clone_tasks = additions.map do |addition|
|
|
135
|
+
next if addition.fetch(:outcome) == :present
|
|
136
|
+
|
|
131
137
|
semaphore.async(finished: false) do
|
|
132
138
|
clone_addition(addition, scm:, cloner:, mise_client:, reporter:)
|
|
133
139
|
end
|
|
134
|
-
end
|
|
140
|
+
end.compact
|
|
135
141
|
|
|
136
142
|
# Collect results without raising inside the reactor so the outer task
|
|
137
143
|
# succeeds and async does not log "Task may have ended" for our errors.
|
|
@@ -145,8 +151,16 @@ module Space::Core
|
|
|
145
151
|
return Failure(first_error) if first_error
|
|
146
152
|
|
|
147
153
|
Success(additions.map do |addition|
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
outcome = addition.fetch(:outcome)
|
|
155
|
+
repo_data =
|
|
156
|
+
if outcome == :present
|
|
157
|
+
# Already tracked: report the existing entry as-is — registration
|
|
158
|
+
# is not re-run (Space#add_repo's guard would rightly refuse).
|
|
159
|
+
addition.fetch(:existing_repo)
|
|
160
|
+
else
|
|
161
|
+
space.add_repo(addition.fetch(:reference), relative_path: addition.fetch(:relative_path), now: now.call)
|
|
162
|
+
end
|
|
163
|
+
{ space: space, repo: repo_data, reference: addition.fetch(:reference), path: addition.fetch(:path), outcome: outcome }
|
|
150
164
|
end)
|
|
151
165
|
rescue Error => e
|
|
152
166
|
Failure(e)
|
|
@@ -160,7 +174,9 @@ module Space::Core
|
|
|
160
174
|
|
|
161
175
|
def clone_addition(addition, scm:, cloner:, mise_client:, reporter: nil)
|
|
162
176
|
reporter&.start(addition)
|
|
163
|
-
|
|
177
|
+
# :adopted checkouts are already on disk — no fetch of any kind (no
|
|
178
|
+
# scm.clone, no Cloner call); only trust + registration remain.
|
|
179
|
+
fetch_addition(addition, scm:, cloner:) unless addition.fetch(:outcome) == :adopted
|
|
164
180
|
reporter&.trust(addition)
|
|
165
181
|
mise_client.trust(addition.fetch(:path))
|
|
166
182
|
reporter&.finish(addition)
|
|
@@ -183,7 +199,19 @@ module Space::Core
|
|
|
183
199
|
raise GitError, "clone failed (copy): #{result.failure}" if result.failure?
|
|
184
200
|
else
|
|
185
201
|
result = scm.clone(reference.clone_url, destination.to_s)
|
|
186
|
-
|
|
202
|
+
return if result.success?
|
|
203
|
+
|
|
204
|
+
stderr = result.failure[:stderr]
|
|
205
|
+
message = "clone failed: #{stderr}"
|
|
206
|
+
# OpenSSH does not localize "Host key verification failed", so it is
|
|
207
|
+
# a stable trigger. Any other failure surfaces the raw stderr as
|
|
208
|
+
# before — fail-fast without a forward path is a support ticket, so
|
|
209
|
+
# teach the flag right where the operator will be looking.
|
|
210
|
+
if stderr.to_s.include?("Host key verification failed")
|
|
211
|
+
message += "\nHost key not in known_hosts; re-run with --trust-host-key to accept it on first contact, " \
|
|
212
|
+
"or add it to your known_hosts manually."
|
|
213
|
+
end
|
|
214
|
+
raise GitError, message
|
|
187
215
|
end
|
|
188
216
|
end
|
|
189
217
|
|
|
@@ -194,13 +222,15 @@ module Space::Core
|
|
|
194
222
|
relative_path = Pathname.new("repos").join(reference.directory_name)
|
|
195
223
|
destination = space.path.join(relative_path)
|
|
196
224
|
|
|
197
|
-
ensure_repo_can_be_added!(space, reference, relative_path, destination)
|
|
225
|
+
outcome, existing_repo = ensure_repo_can_be_added!(space, reference, relative_path, destination)
|
|
198
226
|
|
|
199
227
|
{
|
|
200
228
|
reference: reference,
|
|
201
229
|
relative_path: relative_path,
|
|
202
230
|
path: destination,
|
|
203
|
-
src_source: src_dir && reference.src_path(src_dir)
|
|
231
|
+
src_source: src_dir && reference.src_path(src_dir),
|
|
232
|
+
outcome: outcome,
|
|
233
|
+
existing_repo: existing_repo
|
|
204
234
|
}
|
|
205
235
|
end
|
|
206
236
|
|
|
@@ -216,17 +246,36 @@ module Space::Core
|
|
|
216
246
|
additions
|
|
217
247
|
end
|
|
218
248
|
|
|
249
|
+
# Classify one resolved spec for the idempotent-add contract:
|
|
250
|
+
# :added — fetch (COW copy when an evergreen src exists, else
|
|
251
|
+
# clone), then trust, then register (today's path);
|
|
252
|
+
# :present — same full_name already tracked: skip fetch, trust, and
|
|
253
|
+
# registration entirely (no-op success, whether or not the
|
|
254
|
+
# directory is on disk — tracked ⇒ skip);
|
|
255
|
+
# :adopted — destination exists on disk with .git, untracked: trust +
|
|
256
|
+
# register, NO fetch (heals the orphaned checkouts a failed
|
|
257
|
+
# batch leaves behind).
|
|
258
|
+
# Refusals (RepoExistsError): a tracked repo matching on path or name
|
|
259
|
+
# with a DIFFERENT full_name is a conflict, not idempotency, and a
|
|
260
|
+
# destination that exists without .git is not ours to register.
|
|
219
261
|
def ensure_repo_can_be_added!(space, reference, relative_path, destination)
|
|
220
|
-
|
|
262
|
+
existing = space.repos.find { |repo| repo["full_name"] == reference.full_name }
|
|
263
|
+
return :present, existing if existing
|
|
264
|
+
|
|
265
|
+
conflicting = space.repos.find do |repo|
|
|
266
|
+
repo["path"] == relative_path.to_s || repo["name"] == reference.name
|
|
267
|
+
end
|
|
268
|
+
raise RepoExistsError, "Repo '#{reference.full_name}' already exists in #{space.id}" if conflicting
|
|
269
|
+
|
|
270
|
+
if destination.exist?
|
|
271
|
+
unless destination.join(".git").exist?
|
|
272
|
+
raise RepoExistsError, "Repo destination already exists and is not a git repository: #{destination}"
|
|
273
|
+
end
|
|
221
274
|
|
|
222
|
-
|
|
223
|
-
repo["full_name"] == reference.full_name ||
|
|
224
|
-
repo["path"] == relative_path.to_s ||
|
|
225
|
-
repo["name"] == reference.name
|
|
275
|
+
return :adopted, nil
|
|
226
276
|
end
|
|
227
|
-
return unless existing
|
|
228
277
|
|
|
229
|
-
|
|
278
|
+
[:added, nil]
|
|
230
279
|
end
|
|
231
280
|
|
|
232
281
|
def metadata_for(id:, title:, timestamp:)
|
data/lib/space_core/version.rb
CHANGED