Skip to content

Commit 44be41f

Browse files
authored
feat(cliinstall): add upgrade output and Cobra command (#34)
Mechanically prepared by `wb worktree merge` from exact source heads. - `cli-install-t22` at `19ed62b643670c44c69db9f6fc45ed86be0216ee` Commits: - feat(cliinstall): add upgrade output and Cobra command - Merge remote-tracking branch 'origin/main' into cli-install-t22 - fix(cliinstall): delegate upgrade decisions to UpdateAt for self-update equivalence - fix(cliinstall): align upgrade --check with self-update --check - fix(cliinstall): make the self-update equivalence matrix pass on Windows/macOS - fix(cliinstall): make the whole cliinstall test suite OS-portable - fix(cliinstall): close remaining Windows-only gaps CI caught in 5a05b47 - fix(cliinstall): fix manualUpgradeFixture's Windows archive/path gap Candidate: `19ed62b643670c44c69db9f6fc45ed86be0216ee` WB effort: `merge-strongo-cli-helpers-main-f54357fddfff-4c9d7acb6e2d` <!-- wb:provenance:v1 {"effort":"merge-strongo-cli-helpers-main-f54357fddfff-4c9d7acb6e2d"} -->
2 parents e270739 + 19ed62b commit 44be41f

21 files changed

Lines changed: 5793 additions & 719 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ jobs:
4646
if ($uncovered.Count -ne 0) {
4747
throw "Windows directory-sync coverage is incomplete: $uncovered"
4848
}
49+
# cliinstall's self-update-equals-upgrade-self equivalence test
50+
# (TestSelfUpdateEqualsUpgradeSelf) builds and execs a real fixture
51+
# binary to prove `self-update` and `upgrade <self>` agree — it had
52+
# only ever run on ubuntu before this step existed.
53+
- name: Test cliinstall/selfupdate upgrade equivalence (Windows)
54+
run: go test -count=1 ./cliinstall/... ./selfupdate/...
4955

5056
# daemonlifecycle's detach, pipe-EOF and process-identity tests exercise
5157
# each OS's own process model, so they run natively on all three. cgo is off
@@ -68,6 +74,16 @@ jobs:
6874
go-version-file: go.mod
6975
- name: Test daemonlifecycle
7076
run: go test -count=1 -v ./daemonlifecycle
77+
# macOS has no other job that runs cliinstall/selfupdate — ubuntu
78+
# already gets full coverage via strongo_workflow below, and Windows
79+
# already gets its own run in windows_skillsync, so this step is
80+
# macOS-only to avoid running the same tests three times over.
81+
# cliinstall's self-update-equals-upgrade-self equivalence test
82+
# (TestSelfUpdateEqualsUpgradeSelf) builds and execs a real fixture
83+
# binary to prove `self-update` and `upgrade <self>` agree.
84+
- name: Test cliinstall/selfupdate upgrade equivalence (macOS)
85+
if: matrix.os == 'macos-latest'
86+
run: go test -count=1 ./cliinstall/... ./selfupdate/...
7187

7288
strongo_workflow:
7389
uses: strongo/cicd/.github/workflows/workflow.yml@main

‎README.md‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,70 @@ registered automatically. A host with no Cobra dependency at all builds the
368368
same listing, details and install flow directly from `cliinstall.Probe`/
369369
`cliinstall.Install` plus the framework-neutral `cliinstall/cliui` writers,
370370
exactly as a hand-rolled self-update CLI does from `selfupdate/cliui`.
371+
372+
## Upgrade command
373+
374+
`cliinstall/cobracmd` also builds `upgrade`, the fleet-wide counterpart to a
375+
host's own `self-update`: `<cli> upgrade` (no arguments) reports every
376+
*installed* catalog CLI plus the host itself — current version, latest
377+
stable release, verdict and the exact command or destination an upgrade
378+
would use — without changing anything, and ends with the next step;
379+
`<cli> upgrade --all` and `<cli> upgrade <name>...` upgrade what the report
380+
showed, after one confirmation covering every target that would actually be
381+
replaced or have a manager command executed. `--check` reports the same way
382+
without applying anything; `--dry-run` walks the same decision path for
383+
named/`--all` targets without asking for confirmation. `upgrade --all` means
384+
every *installed* catalog id, not the host's own relevance matrix — a target
385+
that is merely relevant but not installed has nothing to upgrade.
386+
387+
The host itself is always upgraded last, classified and versioned from its
388+
OWN `self-update` `Config` (never from a `PATH` probe of its own binary) —
389+
`<cli> self-update` and `<cli> upgrade <cli>` reach the exact same library
390+
call and report the same outcome (cli-install#req:self-update-equals-upgrade-self).
391+
A separate PATH copy of the host, if one exists, is reported as a warning
392+
and left untouched.
393+
394+
```go
395+
package cli
396+
397+
import (
398+
"github.com/spf13/cobra"
399+
400+
"github.com/strongo/cli-helpers/cliinstall"
401+
"github.com/strongo/cli-helpers/cliinstall/cobracmd"
402+
"github.com/strongo/cli-helpers/selfupdate"
403+
)
404+
405+
func newUpgradeCommand() *cobra.Command {
406+
return cobracmd.NewUpgrade(cobracmd.UpgradeCommandOptions{
407+
HostID: "datatug", // this CLI's own catalog id
408+
Errors: datatugUpgradeErrors{}, // implements both cobracmd.ErrorMapper and cobracmd.UpgradeErrorMapper
409+
// HostConfig is the SAME selfupdate.Config datatug's own self-update
410+
// command builds — same Managers, same CurrentVersion, same release
411+
// endpoints — so `upgrade datatug` and `self-update` agree by
412+
// construction, not by convention.
413+
HostConfig: datatugSelfUpdateConfig(),
414+
HostAfterUpdate: datatugAfterUpdate, // the SAME hook, if any, self-update passes
415+
})
416+
}
417+
418+
// datatugUpgradeErrors extends datatugInstallErrors (see above) with the
419+
// upgrades-available method cli-install#req:upgrade-check requires; a host
420+
// that implements only cobracmd.ErrorMapper simply never receives that call.
421+
type datatugUpgradeErrors struct{ datatugInstallErrors }
422+
423+
func (datatugUpgradeErrors) UpgradesAvailable(results []cliinstall.UpgradeResult) error {
424+
return exitError{code: 5, err: errors.New("upgrades available")} // datatug's own dedicated code, if it wants one
425+
}
426+
```
427+
428+
`cliinstall.UpgradeOptions.Env.RunManaged` is wired automatically the same
429+
way install's is — from `selfupdate/cliui.ManagedCommandRunner`, never by
430+
the host. `--all`, `--check`, `--yes/-y`, `--dry-run` and `--format
431+
text|json` are registered automatically; there is no `--dir` (upgrade always
432+
acts on the copy status-probing already located) and no `update` alias
433+
(REQ: update-alias-policy — `self-update`'s own `update` alias, where one
434+
already ships, is unaffected). A host with no Cobra dependency at all builds
435+
the same report, dry-run and upgrade flow directly from
436+
`cliinstall.PlanUpgrade`/`CheckUpgrades`/`Upgrade` plus the framework-neutral
437+
`cliinstall/cliui` upgrade writers.

‎cliinstall/batch_test.go‎

Lines changed: 119 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,48 @@ import (
1717

1818
// --- shared batch test fixtures ------------------------------------------
1919

20+
// fakeAbsDir returns an absolute directory on the REAL host OS this test
21+
// process is actually running on, built from elems — never touching real
22+
// disk, since it only ever backs a fake Env.PathDirs/HostDir func (a test
23+
// that touches real disk uses t.TempDir() directly instead, which is
24+
// already host-OS-native).
25+
//
26+
// searchDirs' own filepath.IsAbs filter runs under the actual runtime.GOOS
27+
// the test process executes on, NEVER under a test's own goosName override
28+
// (the package-level var some tests force to a different value specifically
29+
// to exercise that OS's naming policy in isolation — see e.g.
30+
// TestProbe_WindowsExecutableSuffix). A hardcoded POSIX literal like
31+
// "/usr/bin" is not absolute on Windows (filepath.IsAbs requires a drive
32+
// letter or UNC root there), so on a REAL Windows test run searchDirs
33+
// silently dropped every such fake PATH/HostDir entry, and every target
34+
// behind it went unseen — this is what made most of this package's own
35+
// test suite report "not installed" (or panic on the resulting nil result)
36+
// the first time task-22's S5 ran it on real Windows CI.
37+
func fakeAbsDir(elems ...string) string {
38+
if runtime.GOOS == "windows" {
39+
return filepath.Join(`C:\fakeroot`, filepath.Join(elems...))
40+
}
41+
return "/" + filepath.Join(elems...)
42+
}
43+
44+
// fakeAbsExe joins dir and name the same way probeOne itself locates an
45+
// executable: the ".exe" suffix decision follows goosName (the package's
46+
// own test seam over runtime.GOOS — see status.go's own doc comment on
47+
// it), exactly like probeOne's own filename construction, while the join
48+
// itself uses the REAL stdlib filepath.Join, exactly like probeOne's own
49+
// candidate construction (which is never goosName-parameterized — see
50+
// fakeAbsDir's own doc comment for why that must track the real host
51+
// GOOS). Most callers never override goosName, so the two coincide; a
52+
// caller that does (to exercise a specific OS's naming policy in
53+
// isolation, e.g. an ".exe" suffix test running for real on Linux CI)
54+
// still gets exactly what probeOne would search for.
55+
func fakeAbsExe(dir, name string) string {
56+
if goosName == "windows" {
57+
name += ".exe"
58+
}
59+
return filepath.Join(dir, name)
60+
}
61+
2062
// batchEnv builds an InstallEnv from plain maps/funcs, mirroring status_
2163
// test.go's fakeEnv but for the extra install-only dependencies
2264
// (cli-install#req:no-network-in-tests).
@@ -37,13 +79,15 @@ func batchEnv(pathDirs []string, hostDir string, executables map[string]bool, ru
3779
}
3880

3981
// multiJSONRun answers "version --json" for any probed path by looking up
40-
// that path's base filename in versions, naming itself after that base —
41-
// so one Run func can back several distinct targets probed at different
42-
// paths in the same test.
82+
// that path's base filename (with any platform executable suffix trimmed
83+
// — probeOne appends ".exe" on windows, but versions' own keys are the
84+
// bare catalog id, e.g. "ovdb" not "ovdb.exe") in versions, naming itself
85+
// after that base — so one Run func can back several distinct targets
86+
// probed at different paths in the same test.
4387
func multiJSONRun(versions map[string]string) func(context.Context, string, []string) ([]byte, error) {
4488
return func(_ context.Context, path string, args []string) ([]byte, error) {
4589
if len(args) == 2 && args[0] == "version" && args[1] == "--json" {
46-
base := filepath.Base(path)
90+
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
4791
if v, ok := versions[base]; ok {
4892
b, _ := json.Marshal(buildinfo.VersionJSON{Name: base, Version: v})
4993
return b, nil
@@ -138,9 +182,10 @@ func TestPlan_HostDirErrorTreatedAsEmpty(t *testing.T) {
138182
}
139183

140184
func TestPlan_AlreadyInstalledIsNotReinstalled(t *testing.T) {
141-
executables := map[string]bool{"/bin1/ovdb": true}
185+
bin1 := fakeAbsDir("bin1")
186+
executables := map[string]bool{fakeAbsExe(bin1, "ovdb"): true}
142187
run := multiJSONRun(map[string]string{"ovdb": "1.2.3"})
143-
env := batchEnv([]string{"/bin1"}, "/usr/bin", executables, run, noRunManaged)
188+
env := batchEnv([]string{bin1}, fakeAbsDir("usr", "bin"), executables, run, noRunManaged)
144189
opts := Options{HostID: "datatug", Env: env}
145190

146191
result, err := Plan(context.Background(), []string{"ovdb"}, opts)
@@ -169,9 +214,19 @@ func TestPlan_DedupesNames(t *testing.T) {
169214
}
170215

171216
func TestPlan_UnrecognizedAtDestinationFails(t *testing.T) {
172-
hostDir := "/home/alex/go/bin" // Manual, not denylisted
173-
destPath := "/home/alex/go/bin/ovdb"
174-
executables := map[string]bool{destPath: true}
217+
hostDir := fakeAbsDir("home", "alex", "go", "bin") // Manual, not denylisted
218+
// installFilePath is the SAME function Plan itself uses to build the
219+
// planned destination path — computing the expected value through it
220+
// (rather than a hand-typed parallel literal) keeps this assertion
221+
// correct under any goosName/host-OS combination, including the
222+
// ".exe" suffix windows adds. fakeAbsExe instead matches what
223+
// probeOne's own real filepath.Join search looks for (see its own
224+
// doc comment for why the two joiners can differ) — here, with
225+
// goosName left at its default, both happen to agree, but the
226+
// executables map key is built the way probeOne actually searches.
227+
destPath := installFilePath(goosName, hostDir, "ovdb")
228+
locatedPath := fakeAbsExe(hostDir, "ovdb")
229+
executables := map[string]bool{locatedPath: true}
175230
run := func(context.Context, string, []string) ([]byte, error) {
176231
return []byte("somethingelse 9.9.9 (abc) 2026-01-01"), nil
177232
}
@@ -202,9 +257,15 @@ func TestPlan_UnrecognizedAtDestinationFails_CaseVariant(t *testing.T) {
202257
t.Cleanup(func() { goosName = origGOOS })
203258
goosName = "darwin"
204259

205-
hostDir := "/Users/alex/go/bin" // Manual, not denylisted; planning builds destPath from this
206-
pathEntry := "/Users/Alex/go/bin" // same directory, different case, found on PATH
207-
locatedPath := "/Users/Alex/go/bin/ovdb" // located copy: pathEntry + id
260+
hostDir := fakeAbsDir("Users", "alex", "go", "bin") // Manual, not denylisted; planning builds destPath from this
261+
pathEntry := fakeAbsDir("Users", "Alex", "go", "bin") // same directory, different case, found on PATH
262+
// fakeAbsExe (not installFilePath): this must match what probeOne's
263+
// own real filepath.Join search actually looks for, not Plan's
264+
// separately-implemented destination-path joiner (see fakeAbsExe's
265+
// own doc comment) — the test only asserts Outcome/Kind below, not an
266+
// exact Failure.Path string, so the two joiners' possibly-differing
267+
// separator styles never need to agree here.
268+
locatedPath := fakeAbsExe(pathEntry, "ovdb")
208269
executables := map[string]bool{locatedPath: true}
209270
run := func(context.Context, string, []string) ([]byte, error) {
210271
return []byte("somethingelse 9.9.9 (abc) 2026-01-01"), nil
@@ -226,13 +287,14 @@ func TestPlan_UnrecognizedElsewhereWarns(t *testing.T) {
226287
// The unrecognized copy is on PATH at /bin1, earlier than the planned
227288
// destination /home/alex/go/bin (not on PATH at all) — REQ:
228289
// unrecognized-copy-not-trusted's shadowing case.
229-
hostDir := "/home/alex/go/bin"
230-
unrecognizedPath := "/bin1/ovdb"
290+
hostDir := fakeAbsDir("home", "alex", "go", "bin")
291+
bin1 := fakeAbsDir("bin1")
292+
unrecognizedPath := fakeAbsExe(bin1, "ovdb")
231293
executables := map[string]bool{unrecognizedPath: true}
232294
run := func(context.Context, string, []string) ([]byte, error) {
233295
return []byte("somethingelse 9.9.9 (abc) 2026-01-01"), nil
234296
}
235-
env := batchEnv([]string{"/bin1"}, hostDir, executables, run, noRunManaged)
297+
env := batchEnv([]string{bin1}, hostDir, executables, run, noRunManaged)
236298
srv := newReleaseServer(t, `[{"tag_name":"v1.0.0","prerelease":false,"draft":false}]`, nil)
237299
opts := Options{HostID: "datatug", Env: env, ConfigureRelease: configureReleaseFromServer(srv)}
238300

@@ -256,7 +318,16 @@ func TestPlan_UnrecognizedElsewhereWarns(t *testing.T) {
256318
}
257319

258320
func TestPlan_HomebrewNeedsNoNetwork(t *testing.T) {
259-
hostDir := "/opt/homebrew/Caskroom/wb/1.0.0"
321+
// Homebrew casks are POSIX-only (wb/ovdb's own catalog entries declare
322+
// CaskOS: darwin/linux, never windows), so caskSupportsOS only chooses
323+
// MethodHomebrew there — pin goosName so this Homebrew-policy fixture
324+
// exercises that regardless of the REAL host OS running the test (same
325+
// pattern as TestPlanMethod_DirGivenAllowed).
326+
origGOOS := goosName
327+
t.Cleanup(func() { goosName = origGOOS })
328+
goosName = "darwin"
329+
330+
hostDir := fakeAbsDir("opt", "homebrew", "Caskroom", "wb", "1.0.0")
260331
env := batchEnv(nil, hostDir, nil, func(context.Context, string, []string) ([]byte, error) { return nil, errors.New("should not run") }, noRunManaged)
261332
opts := Options{HostID: "wb", Env: env}
262333

@@ -405,9 +476,10 @@ func TestExecute_ConfirmationGate_NamesOnlyPendingTargets(t *testing.T) {
405476
// "ovdb" is already installed (excluded from confirmation); "datatug"
406477
// is not (included) — REQ: confirmation-gate: "Targets that are
407478
// already installed... are excluded from the question."
408-
executables := map[string]bool{"/bin1/ovdb": true}
479+
bin1 := fakeAbsDir("bin1")
480+
executables := map[string]bool{fakeAbsExe(bin1, "ovdb"): true}
409481
run := multiJSONRun(map[string]string{"ovdb": "1.0.0"})
410-
env := batchEnv([]string{"/bin1"}, "/home/alex/go/bin", executables, run, noRunManaged)
482+
env := batchEnv([]string{bin1}, fakeAbsDir("home", "alex", "go", "bin"), executables, run, noRunManaged)
411483
srv := newReleaseServer(t, `[{"tag_name":"v1.0.0","prerelease":false,"draft":false}]`, nil)
412484
opts := Options{HostID: "wb", Env: env, ConfigureRelease: configureReleaseFromServer(srv)}
413485
plan, err := Plan(context.Background(), []string{"ovdb", "datatug"}, opts)
@@ -461,7 +533,13 @@ func TestExecute_DeclinedKeepsStatusAndDestination(t *testing.T) {
461533
}
462534

463535
func TestExecute_HomebrewPrintOnlyRedirectsWithoutRunning(t *testing.T) {
464-
hostDir := "/opt/homebrew/Caskroom/wb/1.0.0"
536+
// Same reasoning as TestPlan_HomebrewNeedsNoNetwork: pin goosName so
537+
// this Homebrew-policy fixture works regardless of the real host OS.
538+
origGOOS := goosName
539+
t.Cleanup(func() { goosName = origGOOS })
540+
goosName = "darwin"
541+
542+
hostDir := fakeAbsDir("opt", "homebrew", "Caskroom", "wb", "1.0.0")
465543
env := batchEnv(nil, hostDir, nil, func(context.Context, string, []string) ([]byte, error) { return nil, errors.New("should not run") }, noRunManaged)
466544
opts := Options{HostID: "wb", Env: env}
467545
plan, err := Plan(context.Background(), []string{"ovdb"}, opts)
@@ -486,17 +564,24 @@ func TestExecute_HomebrewPrintOnlyRedirectsWithoutRunning(t *testing.T) {
486564
// managed command succeeds and one whose fails — both get a result, in
487565
// order, and the earlier failure never stops the later target.
488566
func TestExecute_AcceptedBatchInstallsIndependently(t *testing.T) {
567+
// Same reasoning as TestPlan_HomebrewNeedsNoNetwork: pin goosName so
568+
// this Homebrew-policy fixture works regardless of the real host OS.
569+
origGOOS := goosName
570+
t.Cleanup(func() { goosName = origGOOS })
571+
goosName = "darwin"
572+
489573
executables := map[string]bool{}
574+
homebrewBin := fakeAbsDir("opt", "homebrew", "bin")
490575
run := multiJSONRun(map[string]string{"ovdb": "2.0.0"})
491576
runManaged := func(_ context.Context, exe string, args []string) error {
492577
token := args[len(args)-1]
493578
if token == "openvaultdb/tap/ovdb" {
494-
executables["/opt/homebrew/bin/ovdb"] = true
579+
executables[fakeAbsExe(homebrewBin, "ovdb")] = true
495580
return nil
496581
}
497582
return errors.New("brew: cask not found")
498583
}
499-
env := batchEnv([]string{"/opt/homebrew/bin"}, "/opt/homebrew/Caskroom/wb/1.0.0", executables, run, runManaged)
584+
env := batchEnv([]string{homebrewBin}, fakeAbsDir("opt", "homebrew", "Caskroom", "wb", "1.0.0"), executables, run, runManaged)
500585
opts := Options{HostID: "wb", Env: env}
501586
plan, err := Plan(context.Background(), []string{"ovdb", "datatug"}, opts)
502587
if err != nil {
@@ -537,8 +622,8 @@ func TestExecute_DirectBatchOneFailsOthersSucceed(t *testing.T) {
537622
// shared GoReleaser-shaped default — Plan() looks the target up in
538623
// the real compiled catalog, so this fixture must match that entry
539624
// exactly, not a hand-picked naming.
540-
okAsset := fmt.Sprintf("ovdb_%s_%s_%s.tar.gz", version, runtime.GOOS, runtime.GOARCH)
541-
archive := makeTarGz(t, "ovdb", binContent)
625+
archive, ext := makeArchive(t, "ovdb", binContent)
626+
okAsset := fmt.Sprintf("ovdb_%s_%s_%s.%s", version, runtime.GOOS, runtime.GOARCH, ext)
542627
checksums := fmt.Sprintf("%s %s\n", sha256Hex(archive), okAsset)
543628

544629
okServer := newReleaseServer(t, `[{"tag_name":"`+tag+`","prerelease":false,"draft":false}]`, map[string][]byte{
@@ -549,7 +634,7 @@ func TestExecute_DirectBatchOneFailsOthersSucceed(t *testing.T) {
549634

550635
destDir := t.TempDir()
551636
run := multiJSONRun(map[string]string{"ovdb": version})
552-
env := batchEnv([]string{destDir}, "/nonexistent-host-dir", nil, run, noRunManaged)
637+
env := batchEnv([]string{destDir}, fakeAbsDir("nonexistent-host-dir"), nil, run, noRunManaged)
553638
// IsExecutable must reflect a real install landing in destDir so the
554639
// post-install verification probe (and the batch's own PATH-based
555640
// executable check) sees it.
@@ -617,7 +702,13 @@ func TestInstall_PanicsOnUnknownHost(t *testing.T) {
617702
}
618703

619704
func TestInstall_DryRunNeverCallsExecute(t *testing.T) {
620-
hostDir := "/opt/homebrew/Caskroom/wb/1.0.0"
705+
// Same reasoning as TestPlan_HomebrewNeedsNoNetwork: pin goosName so
706+
// this Homebrew-policy fixture works regardless of the real host OS.
707+
origGOOS := goosName
708+
t.Cleanup(func() { goosName = origGOOS })
709+
goosName = "darwin"
710+
711+
hostDir := fakeAbsDir("opt", "homebrew", "Caskroom", "wb", "1.0.0")
621712
env := batchEnv(nil, hostDir, nil, func(context.Context, string, []string) ([]byte, error) { return nil, errors.New("should not run") }, noRunManaged)
622713
opts := Options{HostID: "wb", Env: env, DryRun: true} // no Confirm set: panics if Execute is ever reached
623714

@@ -645,9 +736,10 @@ func TestInstall_UnknownNameNeverReachesExecute(t *testing.T) {
645736
}
646737

647738
func TestInstall_RealRunExecutesAfterPlan(t *testing.T) {
648-
executables := map[string]bool{"/bin1/ovdb": true}
739+
bin1 := fakeAbsDir("bin1")
740+
executables := map[string]bool{fakeAbsExe(bin1, "ovdb"): true}
649741
run := multiJSONRun(map[string]string{"ovdb": "1.0.0"})
650-
env := batchEnv([]string{"/bin1"}, "/usr/bin", executables, run, noRunManaged)
742+
env := batchEnv([]string{bin1}, fakeAbsDir("usr", "bin"), executables, run, noRunManaged)
651743
opts := Options{HostID: "datatug", Env: env, Yes: true}
652744

653745
result, err := Install(context.Background(), []string{"ovdb"}, opts)

0 commit comments

Comments
 (0)