Home

horcrux @main - refs - log -
-
https://git.jolheiser.com/horcrux.git
Split your source across forges
tree log patch
implement sync Signed-off-by: jolheiser <git@jolheiser.com>
Signature
-----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgBTEvCQk6VqUAdN2RuH6bj1dNkY oOpbPWj+jw4ua1B1cAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 AAAAQFgahmKRPLz1XzDxucvRg97OCzwTEgjjSq9UXEsV1gAxqI46nfmPz+aYH3qClIBawk sWLo+LYLfV6AQ1sxIdSgM= -----END SSH SIGNATURE-----
jolheiser <git@jolheiser.com>
2 weeks ago
11 changed files, 120 additions(+), 514 deletions(-)
.gitignore.horcrux.jsonnetconfig.example.jsonnetconfig.goforge.gogit.gogo.modgo.sumhorcrux.libsonnetmain.goschema.cue
M .gitignore.gitignore
1
2
3
4
5
6
7
8
diff --git a/.gitignore b/.gitignore
index 52d743ae80bf51b424bb5e1668b15b530e2d46f3..0ac34b80b4848a123053d66e614d3c393d5c9e5e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 /secrets
 /horcrux*
+/.horcrux
M .horcrux.jsonnet.horcrux.jsonnet
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
diff --git a/.horcrux.jsonnet b/.horcrux.jsonnet
index f8e8e3da552ed9ce4c5a2d197e72f21a7209a68a..2b77fd10e77ac5dbac37b9f121a4e9e1f3551b99 100644
--- a/.horcrux.jsonnet
+++ b/.horcrux.jsonnet
@@ -1,19 +1,14 @@
-local hc = import 'horcrux.libsonnet';
 local repo(name) = {
+  name: name,
   source: 'https://git.jolheiser.com/' + name + '.git',
   dest: [
-    {
-      forge: hc.GitHub('jolheiser', 'secrets/gh', name),
-      url: 'https://github.com/jolheiser/' + name + '.git',
-    },
-    //{
-    //  forge: hc.Gitea('jolheiser', 'secrets/gt', name),
-    //  url: 'https://gitea.com/jolheiser/' + name + '.git',
-    //},
+    'git@github.com:jolheiser/' + name,
+    'git@tangled.sh:jolheiser.com/' + name,
   ],
 };
 {
-  interval: '1h',
+  key: '~/.ssh/horcrux',
+  interval: '15m',
   storage: '.horcrux',
   repos: [
     repo('horcrux'),
D config.example.jsonnet
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
diff --git a/config.example.jsonnet b/config.example.jsonnet
deleted file mode 100644
index be116a7901d079c6d8219dc8001ac761185315bc..0000000000000000000000000000000000000000
--- a/config.example.jsonnet
+++ /dev/null
@@ -1,29 +0,0 @@
-// Optionally import the jsonnet lib
-local hc = import 'horcrux.libsonnet';
-
-// Optional example of using jsonnet to remove some boilerplate
-local repo(name) = {
-  source: 'https://git.jolheiser.com/' + name + '.git',
-  dest: [
-    {
-      forge: hc.GitHub('jolheiser', 'secret', name),
-      url: 'https://github.com/jolheiser/' + name + '.git',
-    },
-    {
-      forge: hc.Gitea('jolheiser', 'moreSecret', name),
-      url: 'https://gitea.com/jolheiser/' + name + '.git',
-    },
-  ],
-};
-
-// Actual output config
-{
-  // https://pkg.go.dev/time#ParseDuration
-  interval: '1h',
-  storage: '.horcrux',
-  repos: [
-    repo('horcrux'),
-    repo('ugit'),
-    repo('helix.drv'),
-  ],
-}
M config.goconfig.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
diff --git a/config.go b/config.go
index e9b61d01c4f9de69f7ba563050e5a4ecb40f8d86..e437dd2ff4c1819d253694b2b6d08d654b57beac 100644
--- a/config.go
+++ b/config.go
@@ -6,25 +6,16 @@ 	"time"
 )
 
 type Config struct {
+	Key      string
 	Interval Duration
 	Storage  string
 	Repos    []RepoConfig
 }
 
 type RepoConfig struct {
+	Name   string
 	Source string
-	Dest   []DestConfig
-}
-
-type DestConfig struct {
-	Forge DestForgeConfig
-	URL   string
-}
-
-type DestForgeConfig struct {
-	ForgeConfig
-	Name      string
-	TokenFile string
+	Dest   []string
 }
 
 type Duration time.Duration
D forge.go
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
diff --git a/forge.go b/forge.go
deleted file mode 100644
index 6c4f40b96d7ff7e4857ab59b76ec37bbbd88aaed..0000000000000000000000000000000000000000
--- a/forge.go
+++ /dev/null
@@ -1,361 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"strings"
-	"time"
-)
-
-var httpClient = &http.Client{
-	Timeout: time.Second * 5,
-}
-
-// Configuration for forge clients
-type ForgeConfig struct {
-	Username    string
-	Token       string
-	RepoName    string
-	Description string
-	Private     bool
-	APIURL      string
-}
-
-// Common interface for all forge clients
-type ForgeClient interface {
-	CheckRepoExists() (bool, error)
-	CreateRepo() error
-}
-
-// GitHub implementation
-type GitHubClient struct {
-	config ForgeConfig
-}
-
-func NewGitHubClient(config ForgeConfig) *GitHubClient {
-	if config.APIURL == "" {
-		config.APIURL = "https://api.github.com"
-	}
-	return &GitHubClient{config: config}
-}
-
-func (c *GitHubClient) CheckRepoExists() (bool, error) {
-	url := fmt.Sprintf("%s/repos/%s/%s", c.config.APIURL, c.config.Username, c.config.RepoName)
-
-	req, err := http.NewRequest("GET", url, nil)
-	if err != nil {
-		return false, err
-	}
-
-	req.Header.Set("Authorization", "token "+c.config.Token)
-	req.Header.Set("Accept", "application/vnd.github.v3+json")
-
-	resp, err := httpClient.Do(req)
-	if err != nil {
-		return false, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == 200 {
-		return true, nil
-	} else if resp.StatusCode == 404 {
-		return false, nil
-	}
-
-	body, _ := io.ReadAll(resp.Body)
-	return false, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, string(body))
-}
-
-func (c *GitHubClient) CreateRepo() error {
-	url := fmt.Sprintf("%s/user/repos", c.config.APIURL)
-
-	payload := map[string]interface{}{
-		"name":        c.config.RepoName,
-		"description": c.config.Description,
-		"private":     c.config.Private,
-	}
-
-	jsonPayload, err := json.Marshal(payload)
-	if err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload))
-	if err != nil {
-		return err
-	}
-
-	req.Header.Set("Authorization", "token "+c.config.Token)
-	req.Header.Set("Accept", "application/vnd.github.v3+json")
-	req.Header.Set("Content-Type", "application/json")
-
-	resp, err := httpClient.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == 201 {
-		return nil
-	}
-
-	body, _ := io.ReadAll(resp.Body)
-	return fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, string(body))
-}
-
-// GitLab implementation
-type GitLabClient struct {
-	config ForgeConfig
-}
-
-func NewGitLabClient(config ForgeConfig) *GitLabClient {
-	if config.APIURL == "" {
-		config.APIURL = "https://gitlab.com/api/v4"
-	}
-	return &GitLabClient{config: config}
-}
-
-func (c *GitLabClient) CheckRepoExists() (bool, error) {
-	// URL encode the repo path for GitLab
-	encodedPath := strings.Replace(c.config.Username+"/"+c.config.RepoName, "/", "%2F", -1)
-	url := fmt.Sprintf("%s/projects/%s", c.config.APIURL, encodedPath)
-
-	req, err := http.NewRequest("GET", url, nil)
-	if err != nil {
-		return false, err
-	}
-
-	req.Header.Set("PRIVATE-TOKEN", c.config.Token)
-
-	resp, err := httpClient.Do(req)
-	if err != nil {
-		return false, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == 200 {
-		return true, nil
-	} else if resp.StatusCode == 404 {
-		return false, nil
-	}
-
-	body, _ := io.ReadAll(resp.Body)
-	return false, fmt.Errorf("GitLab API error: %d - %s", resp.StatusCode, string(body))
-}
-
-func (c *GitLabClient) CreateRepo() error {
-	url := fmt.Sprintf("%s/projects", c.config.APIURL)
-
-	visibility := "public"
-	if c.config.Private {
-		visibility = "private"
-	}
-
-	payload := map[string]interface{}{
-		"name":        c.config.RepoName,
-		"description": c.config.Description,
-		"path":        c.config.RepoName,
-		"visibility":  visibility,
-	}
-
-	jsonPayload, err := json.Marshal(payload)
-	if err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload))
-	if err != nil {
-		return err
-	}
-
-	req.Header.Set("PRIVATE-TOKEN", c.config.Token)
-	req.Header.Set("Content-Type", "application/json")
-
-	resp, err := httpClient.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == 201 {
-		return nil
-	}
-
-	body, _ := io.ReadAll(resp.Body)
-	return fmt.Errorf("GitLab API error: %d - %s", resp.StatusCode, string(body))
-}
-
-// Gitea/Codeberg/Forgejo implementation
-type GiteaClient struct {
-	config ForgeConfig
-}
-
-func NewGiteaClient(config ForgeConfig) *GiteaClient {
-	// Default to Codeberg if no base URL provided
-	if config.APIURL == "" {
-		config.APIURL = "https://codeberg.org/api/v1"
-	}
-	return &GiteaClient{config: config}
-}
-
-func (c *GiteaClient) CheckRepoExists() (bool, error) {
-	url := fmt.Sprintf("%s/repos/%s/%s", c.config.APIURL, c.config.Username, c.config.RepoName)
-
-	req, err := http.NewRequest("GET", url, nil)
-	if err != nil {
-		return false, err
-	}
-
-	req.Header.Set("Authorization", "token "+c.config.Token)
-	req.Header.Set("Accept", "application/json")
-
-	resp, err := httpClient.Do(req)
-	if err != nil {
-		return false, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == 200 {
-		return true, nil
-	} else if resp.StatusCode == 404 {
-		return false, nil
-	}
-
-	body, _ := io.ReadAll(resp.Body)
-	return false, fmt.Errorf("Gitea API error: %d - %s", resp.StatusCode, string(body))
-}
-
-func (c *GiteaClient) CreateRepo() error {
-	url := fmt.Sprintf("%s/user/repos", c.config.APIURL)
-
-	payload := map[string]interface{}{
-		"name":        c.config.RepoName,
-		"description": c.config.Description,
-		"private":     c.config.Private,
-	}
-
-	jsonPayload, err := json.Marshal(payload)
-	if err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload))
-	if err != nil {
-		return err
-	}
-
-	req.Header.Set("Authorization", "token "+c.config.Token)
-	req.Header.Set("Accept", "application/json")
-	req.Header.Set("Content-Type", "application/json")
-
-	resp, err := httpClient.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == 201 {
-		return nil
-	}
-
-	body, _ := io.ReadAll(resp.Body)
-	return fmt.Errorf("Gitea API error: %d - %s", resp.StatusCode, string(body))
-}
-
-// SourceHut implementation
-type SourceHutClient struct {
-	config ForgeConfig
-}
-
-func NewSourceHutClient(config ForgeConfig) *SourceHutClient {
-	if config.APIURL == "" {
-		config.APIURL = "https://git.sr.ht/api"
-	}
-	return &SourceHutClient{config: config}
-}
-
-func (c *SourceHutClient) CheckRepoExists() (bool, error) {
-	url := fmt.Sprintf("%s/repos/%s/%s", c.config.APIURL, c.config.Username, c.config.RepoName)
-
-	req, err := http.NewRequest("GET", url, nil)
-	if err != nil {
-		return false, err
-	}
-
-	req.Header.Set("Authorization", "token "+c.config.Token)
-
-	resp, err := httpClient.Do(req)
-	if err != nil {
-		return false, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == 200 {
-		return true, nil
-	} else if resp.StatusCode == 404 {
-		return false, nil
-	}
-
-	body, _ := io.ReadAll(resp.Body)
-	return false, fmt.Errorf("SourceHut API error: %d - %s", resp.StatusCode, string(body))
-}
-
-func (c *SourceHutClient) CreateRepo() error {
-	url := fmt.Sprintf("%s/repos", c.config.APIURL)
-
-	visibility := "public"
-	if c.config.Private {
-		visibility = "private"
-	}
-
-	payload := map[string]interface{}{
-		"name":        c.config.RepoName,
-		"description": c.config.Description,
-		"visibility":  visibility,
-	}
-
-	jsonPayload, err := json.Marshal(payload)
-	if err != nil {
-		return err
-	}
-
-	req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload))
-	if err != nil {
-		return err
-	}
-
-	req.Header.Set("Authorization", "token "+c.config.Token)
-	req.Header.Set("Content-Type", "application/json")
-
-	resp, err := httpClient.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == 201 {
-		return nil
-	}
-
-	body, _ := io.ReadAll(resp.Body)
-	return fmt.Errorf("SourceHut API error: %d - %s", resp.StatusCode, string(body))
-}
-
-// Factory function to create the appropriate forge client
-func NewForgeClient(forgeType string, config ForgeConfig) (ForgeClient, error) {
-	switch strings.ToLower(forgeType) {
-	case "github":
-		return NewGitHubClient(config), nil
-	case "gitlab":
-		return NewGitLabClient(config), nil
-	case "gitea", "codeberg", "forgejo":
-		return NewGiteaClient(config), nil
-	case "sourcehut", "srht":
-		return NewSourceHutClient(config), nil
-	default:
-		return nil, fmt.Errorf("unsupported forge type: %s", forgeType)
-	}
-}
D git.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
diff --git a/git.go b/git.go
deleted file mode 100644
index bb19bc2d99702f6108c3a8d1a3d44e31d9b63738..0000000000000000000000000000000000000000
--- a/git.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"log/slog"
-	"net/url"
-	"os"
-	"os/exec"
-	"strings"
-)
-
-func git(dir string, args ...string) error {
-	cmd := exec.Command("git", args...)
-	cmd.Dir = dir
-	// cmd.Stdout = os.Stdout
-	// cmd.Stderr = os.Stderr
-	return cmd.Run()
-}
-
-func (r RepoConfig) Sync() error {
-	tmp, err := os.MkdirTemp(os.TempDir(), "horcrux-*")
-	if err != nil {
-		return err
-	}
-	defer os.RemoveAll(tmp)
-	if err := git(os.TempDir(), "clone", "--mirror", r.Source, tmp); err != nil {
-		return err
-	}
-	for _, dest := range r.Dest {
-		token := dest.Forge.Token
-		if dest.Forge.TokenFile != "" {
-			tokenBytes, err := os.ReadFile(dest.Forge.TokenFile)
-			if err != nil {
-				slog.Error("could not read token file", slog.Any("err", err))
-				continue
-			}
-			token = strings.TrimSpace(string(tokenBytes))
-		}
-		client, err := NewForgeClient(dest.Forge.Name, ForgeConfig{
-			Username:    dest.Forge.Username,
-			Token:       token,
-			RepoName:    dest.Forge.RepoName,
-			Description: fmt.Sprintf("MIRROR of %s", r.Source),
-			APIURL:      dest.Forge.APIURL,
-		})
-		if err != nil {
-			return err
-		}
-		ok, err := client.CheckRepoExists()
-		if err != nil {
-			return err
-		}
-		if !ok {
-			if err := client.CreateRepo(); err != nil {
-				return err
-			}
-		}
-		u, err := url.Parse(dest.URL)
-		if err != nil {
-			return err
-		}
-		u.User = url.UserPassword(dest.Forge.Username, token)
-		if err := git(tmp, "remote", "add", "--mirror=push", dest.Forge.Name, u.String()); err != nil {
-			return err
-		}
-		if err := git(tmp, "push", "--mirror", "--force-with-lease", dest.Forge.Name); err != nil {
-			return err
-		}
-	}
-	return nil
-}
M go.modgo.mod
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
diff --git a/go.mod b/go.mod
index 7903471b8906299466e59e261d07995ea3e644c1..0d2afe6804077bf2b23fb3f2bcb26940daec56cd 100644
--- a/go.mod
+++ b/go.mod
@@ -1,32 +1,42 @@
 module horcrux
 
-go 1.23.3
+go 1.24.3
 
 require (
 	github.com/go-git/go-git/v5 v5.14.0
-	github.com/google/go-jsonnet v0.20.0
+	github.com/google/go-jsonnet v0.21.0
 )
 
 require (
+	cuelang.org/go v0.14.1 // indirect
 	dario.cat/mergo v1.0.0 // indirect
 	github.com/Microsoft/go-winio v0.6.2 // indirect
 	github.com/ProtonMail/go-crypto v1.1.5 // indirect
 	github.com/cloudflare/circl v1.6.0 // indirect
+	github.com/cockroachdb/apd/v3 v3.2.1 // indirect
 	github.com/cyphar/filepath-securejoin v0.4.1 // indirect
+	github.com/emicklei/proto v1.14.2 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-git/go-billy/v5 v5.6.2 // indirect
 	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
+	github.com/google/uuid v1.6.0 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
+	github.com/mitchellh/go-wordwrap v1.0.1 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
 	github.com/pjbgf/sha1cd v0.3.2 // indirect
+	github.com/protocolbuffers/txtpbfmt v0.0.0-20250627152318-f293424e46b5 // indirect
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/skeema/knownhosts v1.3.1 // indirect
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
-	golang.org/x/crypto v0.35.0 // indirect
-	golang.org/x/net v0.35.0 // indirect
-	golang.org/x/sys v0.30.0 // indirect
+	go.jolheiser.com/cuesonnet v0.0.0-20250903112544-0914371465d2 // indirect
+	golang.org/x/crypto v0.40.0 // indirect
+	golang.org/x/net v0.42.0 // indirect
+	golang.org/x/sys v0.34.0 // indirect
+	golang.org/x/text v0.27.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
-	sigs.k8s.io/yaml v1.1.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+	sigs.k8s.io/yaml v1.4.0 // indirect
 )
M go.sumgo.sum
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
diff --git a/go.sum b/go.sum
index b34ccf0d13fecb4fc12ab9e1e4f91ce11b6fa989..5fe173d62353c56c2abf3e00662fa584bd005f53 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+cuelang.org/go v0.14.1 h1:kxFAHr7bvrCikbtVps2chPIARazVdnRmlz65dAzKyWg=
+cuelang.org/go v0.14.1/go.mod h1:aSP9UZUM5m2izHAHUvqtq0wTlWn5oLjuv2iBMQZBLLs=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
 dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
@@ -11,6 +13,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
 github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
+github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg=
+github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
 github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -18,6 +22,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
+github.com/emicklei/proto v1.14.2 h1:wJPxPy2Xifja9cEMrcA/g08art5+7CGJNFNk35iXC1I=
+github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -32,10 +38,15 @@ github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
 github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/go-jsonnet v0.20.0 h1:WG4TTSARuV7bSm4PMB4ohjxe33IHT5WVTrJSU33uT4g=
 github.com/google/go-jsonnet v0.20.0/go.mod h1:VbgWF9JX7ztlv770x/TolZNGGFfiHEVx9G6ca2eUmeA=
+github.com/google/go-jsonnet v0.21.0 h1:43Bk3K4zMRP/aAZm9Po2uSEjY6ALCkYUVIcz9HLGMvA=
+github.com/google/go-jsonnet v0.21.0/go.mod h1:tCGAu8cpUpEZcdGMmdOu37nh8bGgqubhI5v2iSk3KJQ=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
@@ -47,14 +58,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
 github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
 github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
 github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/protocolbuffers/txtpbfmt v0.0.0-20250627152318-f293424e46b5 h1:WWs1ZFnGobK5ZXNu+N9If+8PDNVB9xAqrib/stUXsV4=
+github.com/protocolbuffers/txtpbfmt v0.0.0-20250627152318-f293424e46b5/go.mod h1:BnHogPTyzYAReeQLZrOxyxzS739DaTNtTvohVdbENmA=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
@@ -69,14 +86,20 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+go.jolheiser.com/cuesonnet v0.0.0-20250903112544-0914371465d2 h1:VEcbjVi23CbqvJQeRSTpWSmxJYplffXAy9THufKLJTA=
+go.jolheiser.com/cuesonnet v0.0.0-20250903112544-0914371465d2/go.mod h1:zjccrkDZF5sm3QsBBPde0NTYCSOxLDCm7xwiE1Iturw=
 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
 golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
+golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
+golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
 golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -85,12 +108,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
 golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
 golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
 golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -105,3 +132,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs=
 sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
+sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
+sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
D horcrux.libsonnet
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
diff --git a/horcrux.libsonnet b/horcrux.libsonnet
deleted file mode 100644
index 324fcb42a2f3d3c98c0f9c5eccca8b30ada88407..0000000000000000000000000000000000000000
--- a/horcrux.libsonnet
+++ /dev/null
@@ -1,14 +0,0 @@
-{
-  Forge(name, username, tokenFile, repo, apiURL=''):: {
-    name: name,
-    username: username,
-    tokenFile: tokenFile,
-    repoName: repo,
-    apiURL: apiURL,
-  },
-  GitHub(username, tokenFile, repo, apiURL='https://api.github.com'):: self.Forge('github', username, tokenFile, repo, apiURL),
-  GitLab(username, tokenFile, repo, apiURL='https://gitlab.com/api/v4'):: self.Forge('gitlab', username, tokenFile, repo, apiURL),
-  Gitea(username, tokenFile, repo, apiURL='https://gitea.com/api/v1'):: self.Forge('gitea', username, tokenFile, repo, apiURL),
-  SourceHut(username, tokenFile, repo, apiURL='https://git.sr.ht/api'):: self.Forge('sourcehut', username, tokenFile, repo, apiURL),
-  Codeberg(username, tokenFile, repo, apiURL='https://codeberg.org/api/v1'):: self.Forge('gitea', username, tokenFile, repo, apiURL),
-}
M main.gomain.go
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
diff --git a/main.go b/main.go
index efb6409b847b7f459169bc23626932557e6d5750..8bd5def46b511ba0cab9a77d0c73e52f3a2c853d 100644
--- a/main.go
+++ b/main.go
@@ -1,16 +1,23 @@
 package main
 
 import (
-	"encoding/json"
+	"bytes"
+	_ "embed"
+	"errors"
 	"flag"
 	"fmt"
 	"log/slog"
 	"os"
+	"os/exec"
 	"os/signal"
+	"path/filepath"
 	"time"
 
-	"github.com/google/go-jsonnet"
+	"go.jolheiser.com/cuesonnet"
 )
+
+//go:embed schema.cue
+var schema cuesonnet.Schema
 
 func maine() error {
 	fs := flag.NewFlagSet("horcrux", flag.ExitOnError)
@@ -39,24 +46,45 @@ 	if err != nil {
 		return fmt.Errorf("could not read config file: %w", err)
 	}
 
-	vm := jsonnet.MakeVM()
-	cfgJSON, err := vm.EvaluateAnonymousSnippet(*configFlag, string(cfg))
-	if err != nil {
-		return fmt.Errorf("could not evaluate jsonnet: %w", err)
+	var config Config
+	if err := schema.Decode(bytes.NewReader(cfg), &config); err != nil {
+		return fmt.Errorf("could not decode config file: %w", err)
 	}
 
-	var config Config
-	if err := json.Unmarshal([]byte(cfgJSON), &config); err != nil {
-		return fmt.Errorf("could not unmarshal JSON from config: %w", err)
+	if err := os.MkdirAll(config.Storage, os.ModePerm); err != nil {
+		return fmt.Errorf("could not create storage repo at %q: %w", config.Storage, err)
 	}
 
+	git := sshGit(config.Key)
 	ticker := time.NewTicker(time.Duration(config.Interval))
 	go func() {
 		for {
 			slog.Debug("running sync...")
 			for _, r := range config.Repos {
-				if err := r.Sync(); err != nil {
-					slog.Error("could not sync repo", slog.String("repo", r.Source), slog.Any("err", err))
+
+				// Check if we need to clone first
+				repoPath := filepath.Join(config.Storage, r.Name)
+				_, err := os.Stat(repoPath)
+				if err != nil {
+					if errors.Is(err, os.ErrNotExist) {
+						if err := git(config.Storage, "clone", "--mirror", r.Source, r.Name); err != nil {
+							slog.Error("could not clone repo", slog.String("repo", r.Source), slog.Any("err", err))
+						}
+					} else {
+						slog.Error("could not stat repo path", slog.Any("err", err))
+					}
+				}
+
+				// Update from remote
+				if err := git(repoPath, "remote", "update", "--prune"); err != nil {
+					slog.Error("could not update repo", slog.String("repo", r.Source), slog.Any("err", err))
+				}
+
+				// Push
+				for _, dest := range r.Dest {
+					if err := git(repoPath, "push", "--mirror", "--force", dest); err != nil {
+						slog.Error("could not push repo", slog.String("repo", r.Source), slog.String("dest", dest), slog.Any("err", err))
+					}
 				}
 			}
 			<-ticker.C
@@ -68,6 +96,17 @@ 	signal.Notify(ch, os.Interrupt, os.Kill)
 	<-ch
 
 	return nil
+}
+
+func sshGit(key string) func(string, ...string) error {
+	return func(dir string, args ...string) error {
+		cmd := exec.Command("git", args...)
+		cmd.Dir = dir
+		cmd.Env = append(os.Environ(), fmt.Sprintf(`GIT_SSH_COMMAND=ssh -i %s`, key))
+		// cmd.Stdout = os.Stdout
+		// cmd.Stderr = os.Stderr
+		return cmd.Run()
+	}
 }
 
 func main() {
I schema.cue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
diff --git a/schema.cue b/schema.cue
new file mode 100644
index 0000000000000000000000000000000000000000..9baa6f81b6681ab45bc781925aede285a74d193e
--- /dev/null
+++ b/schema.cue
@@ -0,0 +1,16 @@
+import "time"
+
+#Repo: {
+	name: string
+	source: string
+	dest: [...string]
+}
+
+#Schema: {
+	key: string
+	interval: time.Duration | *"1h"
+	storage: string | *".horcrux"
+	repos: [...#Repo]
+}
+
+#Schema