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
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
|
module cli
import core.str
import core.result_generic as rg
import core.convert as convert
import time.time as time
import io.console as console
import io.file as iofile
import sys.flag as flag
import sys.env as env
import sys.args as args
import config
import incus
import sync
import paths
import log
export
fn cmd_new(argv: [string]): int
fn cmd_sync(argv: [string]): int
fn dispatch(argv: [string]): int
end export
// argv passed in is the slice past argv[1] (i.e., excludes program + subcommand).
fn cmd_new(argv: [string]): int
let parser: flag.FlagParser = flag.flag_parser_from(argv)
flag.application(parser, "repoman new")
flag.description(parser, "Create a new container + repo bind")
let _r1 = flag.string_flag(parser, "repo", '\0', "", "repo dirname (defaults to <name>)")
let _r2 = flag.string_flag(parser, "image", '\0', "", "container image (overrides default)")
let _v = flag.bool_flag(parser, "verbose", 'v', false, "show subprocess output (incus probes)")
let _q = flag.bool_flag(parser, "quiet", 'q', false, "force quiet mode even if config sets verbose")
if not flag.parse(parser)
console.printErr("repoman: error: " + flag.error(parser))
return 2
end if
let positionals: [string] = flag.positional_args(parser)
if positionals.length() != 1
console.printErr("repoman: error: 'new' takes exactly one positional argument: <name>")
return 2
end if
let name: string = positionals[0]
let repo_flag: string = flag.get_string(parser, "repo")
let image_flag: string = flag.get_string(parser, "image")
if not incus.validate_name(name)
console.printErr("repoman: error: invalid container name: " + name)
console.printErr("hint: lowercase alphanumeric + hyphens, ≤63 chars, no leading hyphen")
return 1
end if
let home: string = env.get_env_or("HOME", "")
if str.length(home) == 0
console.printErr("repoman: error: HOME is not set")
return 3
end if
let cfg_path: string = config.registry_path(home)
let reg_r = config.load_or_init(home)
if rg.is_err(reg_r)
console.printErr("repoman: error: " + rg.unwrap_err(reg_r))
return 3
end if
let reg: config.Registry = rg.unwrap_ok(reg_r)
// Open per-invocation log file. Failures degrade to stderr-only with a
// warning; logging is best-effort, never blocks the operation.
let _ol: bool = log.open_log(reg.defaults.logdir, name, "new")
// Resolve verbose mode: --quiet wins, then --verbose, then registry default.
let cli_verbose: bool = flag.get_bool(parser, "verbose")
let cli_quiet: bool = flag.get_bool(parser, "quiet")
mut verbose: bool = reg.output == "verbose"
if cli_verbose
verbose = true
end if
if cli_quiet
verbose = false
end if
// Reject duplicate name
let pn: int = reg.projects.length()
mut i: int = 0
while i < pn
if reg.projects[i].name == name
log.write("repoman: error: project '" + name + "' already in registry")
log.write("hint: incus delete --project " + reg.defaults.incus_project + " " + name + " ; then remove from " + cfg_path)
return 4
end if
i = i + 1
end while
// Resolve repo path
mut repo: string = repo_flag
if str.length(repo) == 0
repo = name
end if
let repos_root: string = paths.expand_home(reg.defaults.repos_root)
let repo_path: string = paths.join(repos_root, repo)
if not paths.is_dir(repo_path)
log.write("repoman: error: no repo at " + repo_path)
return 3
end if
// Read override (optional)
let override_path: string = paths.join(home, ".config/repoman/repos.d/" + name + ".toml")
mut override: config.Override = config.Override {
image: "", profiles: new [string](0), has_profiles: false,
mounts: new [config.Mount](0),
env_keys: new [string](0), env_values: new [string](0)
}
if iofile.fileExists(override_path)
let ov_r = config.parse_override(iofile.readFile(override_path))
if rg.is_err(ov_r)
log.write("repoman: error: bad override " + override_path + ": " + rg.unwrap_err(ov_r))
return 3
end if
override = rg.unwrap_ok(ov_r)
end if
let eff: config.EffectiveConfig = config.merge_with_defaults(name, repo, image_flag, override, reg.defaults)
// Ensure incus project
log.write("==> incus project ensure " + reg.defaults.incus_project)
let pe = incus.project_ensure(reg.defaults.incus_project, verbose)
if rg.is_err(pe)
log.write("repoman: error: " + rg.unwrap_err(pe))
return 1
end if
// Reject if container exists already
let ce = incus.container_exists(reg.defaults.incus_project, name, verbose)
if rg.is_err(ce)
log.write("repoman: error: " + rg.unwrap_err(ce))
return 1
end if
if rg.unwrap_ok(ce)
log.write("repoman: error: container '" + name + "' already exists in project '" + reg.defaults.incus_project + "'")
log.write("hint: incus delete --project " + reg.defaults.incus_project + " " + name)
return 4
end if
// Launch
log.write("==> incus launch " + eff.image + " " + name)
let lr = incus.launch(reg.defaults.incus_project, name, eff.image, eff.profiles)
if rg.is_err(lr)
log.write("repoman: error: " + rg.unwrap_err(lr))
return 1
end if
// Mounts: device names "repo" for the auto bind, "mount-1", "mount-2", ...
let mn: int = eff.mounts.length()
mut k: int = 0
while k < mn
let m: config.Mount = eff.mounts[k]
mut dev_name: string = "repo"
if k > 0
dev_name = "mount-" + convert.to_string(k)
end if
log.write("==> incus device add " + name + " " + dev_name + " " + m.source + ":" + m.path)
let dr = incus.device_add_disk(reg.defaults.incus_project, name, dev_name, m.source, m.path)
if rg.is_err(dr)
log.write("repoman: error: " + rg.unwrap_err(dr))
log.write("hint: incus delete --project " + reg.defaults.incus_project + " " + name)
return 1
end if
k = k + 1
end while
// Env
let en: int = eff.env_keys.length()
mut e: int = 0
while e < en
let er = incus.set_env_var(reg.defaults.incus_project, name, eff.env_keys[e], eff.env_values[e])
if rg.is_err(er)
log.write("repoman: error: " + rg.unwrap_err(er))
return 1
end if
e = e + 1
end while
// Restart so binds + env take effect
log.write("==> incus restart " + name)
let rr = incus.restart(reg.defaults.incus_project, name)
if rg.is_err(rr)
log.write("repoman: error: " + rg.unwrap_err(rr))
return 1
end if
// Build new project entry and write registry
let now: string = time.time_format_iso(time.time_now())
let new_p: config.Project = config.Project {
name: name,
repo: repo,
image: eff.image,
profiles: eff.profiles,
created: now,
last_sync: "",
backup: true
}
let reg2_r = config.add_project(reg, new_p)
if rg.is_err(reg2_r)
log.write("repoman: error: " + rg.unwrap_err(reg2_r))
return 1
end if
let saved = config.save(rg.unwrap_ok(reg2_r), cfg_path)
if rg.is_err(saved)
log.write("repoman: error: " + rg.unwrap_err(saved))
return 1
end if
// Ready hint — use $UID and $HOME for shell expansion (correct on any host)
log.write("==> ready")
log.write("")
log.write(" shell in: incus exec --project " + reg.defaults.incus_project + " --user $UID --cwd " + repo_path + " --env HOME=$HOME " + name + " -- bash -l")
log.write(" run claude: incus exec --project " + reg.defaults.incus_project + " " + name + " -- claude")
return 0
end cmd_new
fn cmd_sync(argv: [string]): int
let parser: flag.FlagParser = flag.flag_parser_from(argv)
flag.application(parser, "repoman sync")
flag.description(parser, "rsync local repos → NFS backup")
let _f1 = flag.bool_flag(parser, "no-delete", '\0', false, "additive only — no deletions on the destination")
let _f2 = flag.bool_flag(parser, "dry-run", '\0', false, "preview changes without writing")
let _v = flag.bool_flag(parser, "verbose", 'v', false, "show subprocess output (NFS probes)")
let _q = flag.bool_flag(parser, "quiet", 'q', false, "force quiet mode even if config sets verbose")
if not flag.parse(parser)
console.printErr("repoman: error: " + flag.error(parser))
return 2
end if
let positionals: [string] = flag.positional_args(parser)
if positionals.length() > 1
console.printErr("repoman: error: 'sync' takes at most one positional argument: [name]")
return 2
end if
let no_delete: bool = flag.get_bool(parser, "no-delete")
let dry_run: bool = flag.get_bool(parser, "dry-run")
let home: string = env.get_env_or("HOME", "")
if str.length(home) == 0
console.printErr("repoman: error: HOME is not set")
return 3
end if
let reg_r = config.load_or_init(home)
if rg.is_err(reg_r)
console.printErr("repoman: error: " + rg.unwrap_err(reg_r))
return 3
end if
let reg: config.Registry = rg.unwrap_ok(reg_r)
let cfg_path: string = config.registry_path(home)
// Open per-invocation log file. Single-project sync uses the project
// name; whole-tree sync uses "all".
mut log_label: string = "all"
if positionals.length() == 1
log_label = positionals[0]
end if
let _ol: bool = log.open_log(reg.defaults.logdir, log_label, "sync")
// Resolve verbose mode: --quiet wins, then --verbose, then registry default.
let cli_verbose: bool = flag.get_bool(parser, "verbose")
let cli_quiet: bool = flag.get_bool(parser, "quiet")
mut verbose: bool = reg.output == "verbose"
if cli_verbose
verbose = true
end if
if cli_quiet
verbose = false
end if
let backup_root: string = paths.expand_home(reg.defaults.backup_root)
let repos_root: string = paths.expand_home(reg.defaults.repos_root)
// ensure_nfs_mounted
let mr = sync.ensure_nfs_mounted(backup_root, verbose)
if rg.is_err(mr)
log.write("repoman: error: " + rg.unwrap_err(mr))
return 3
end if
// Resolve target
mut src: string = ""
mut dst: string = ""
mut excluded: [string] = new [string](0)
mut single_target: string = ""
if positionals.length() == 1
let name: string = positionals[0]
// Find in registry
let pn: int = reg.projects.length()
mut found: int = -1
mut i: int = 0
while i < pn
if reg.projects[i].name == name
found = i
end if
i = i + 1
end while
if found < 0
log.write("repoman: error: '" + name + "' not in registry")
log.write("hint: repoman new " + name)
return 1
end if
let proj: config.Project = reg.projects[found]
if not proj.backup
log.write("repoman: error: '" + name + "' has backup = false; refusing single-target sync")
return 1
end if
src = paths.join(repos_root, proj.repo) + "/"
dst = paths.join(backup_root, proj.repo) + "/"
single_target = name
else
// whole tree
src = repos_root + "/"
dst = backup_root + "/"
// Build excludes for backup=false projects
let pn: int = reg.projects.length()
mut buf: [string] = new [string](pn)
mut count: int = 0
mut i: int = 0
while i < pn
if not reg.projects[i].backup
buf[count] = reg.projects[i].repo
count = count + 1
end if
i = i + 1
end while
mut tight: [string] = new [string](count)
mut j: int = 0
while j < count
tight[j] = buf[j]
j = j + 1
end while
excluded = tight
end if
// Build args + log + run
let is_tty: bool = false // v0.1: assume non-TTY (cron-friendly defaults).
let rsync_args: [string] = sync.build_rsync_args(src, dst, dry_run, no_delete, is_tty, excluded)
mut tags: string = ""
if dry_run
tags = tags + "(dry-run) "
end if
if no_delete
tags = tags + "(additive) "
end if
log.write("==> rsync " + tags + src + " → " + dst)
let exit_code: int = sync.run_rsync(rsync_args)
if exit_code < 0
log.write("repoman: error: failed to spawn rsync")
return 1
end if
if exit_code != 0
return exit_code
end if
// Success: update last_sync. Skip in dry-run mode (nothing changed).
if not dry_run
let now: string = time.time_format_iso(time.time_now())
if str.length(single_target) > 0
let upd = config.update_last_sync(reg, single_target, now)
if rg.is_ok(upd)
let _s1 = config.save(rg.unwrap_ok(upd), cfg_path)
end if
else
mut cur: config.Registry = reg
let pn: int = cur.projects.length()
mut i: int = 0
while i < pn
if cur.projects[i].backup
let upd = config.update_last_sync(cur, cur.projects[i].name, now)
if rg.is_ok(upd)
cur = rg.unwrap_ok(upd)
end if
end if
i = i + 1
end while
let _s2 = config.save(cur, cfg_path)
end if
end if
return 0
end cmd_sync
fn version_string(): string
return "repoman 0.1.0"
end version_string
proc print_usage()
console.printErr("Usage: repoman <subcommand> [args]")
console.printErr("")
console.printErr("Subcommands")
console.printErr(" new <name> [--repo <dirname>] [--image <image>]")
console.printErr(" Launch a container in the 'repoman' Incus project; bind ~/repos/<dirname>.")
console.printErr("")
console.printErr(" sync [name] [--no-delete] [--dry-run]")
console.printErr(" Mirror local repos to NFS backup (rsync --delete by default).")
console.printErr("")
console.printErr(" --version | -V")
console.printErr(" --help | -h | help")
end print_usage
fn dispatch(argv: [string]): int
// argv is the full process argv: [program, subcommand, ...]
let n: int = argv.length()
if n < 2
print_usage()
return 0
end if
let sub: string = argv[1]
if sub == "--version" or sub == "-V"
console.printErr(version_string())
return 0
end if
if sub == "--help" or sub == "-h" or sub == "help"
print_usage()
return 0
end if
// Slice argv[2..] for the subcommand parser
mut rest: [string] = new [string](n - 2)
mut i: int = 0
while i < n - 2
rest[i] = argv[i + 2]
i = i + 1
end while
if sub == "new"
return cmd_new(rest)
end if
if sub == "sync"
return cmd_sync(rest)
end if
console.printErr("repoman: error: unknown subcommand: " + sub)
console.printErr("hint: try 'repoman --help'")
return 2
end dispatch
end module
|