|
root / src / setup.reef
setup.reef Reef 427 lines 14.5 KB
  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
module setup

import core.str
import core.result_generic as rg
import core.convert as convert
import io.console as console
import io.file as iofile
import io.dir as iodir
import sys.env
import sys.flag as flag
import sys.process as p
import sys.fd as fd
import paths
import incus
import hermes
import config

export
    type Environment
    fn detect_environment(home_dir: string): Environment
    fn detect_host_lan_ip(): string
    fn render_llm_share_template(host_lan_ip: string, user: string): string
    fn template_contains_placeholder(s: string): bool
    type Stage
    fn plan_stages(env: Environment, with_llm: bool): [Stage]
    fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string]
    fn cmd_setup(argv: [string]): int
end export

// Snapshot of the host state that `setup` cares about.
type Environment = struct
    home_dir:             string
    user:                 string
    host_lan_ip:          string
    incus_reachable:      bool
    repoman_project_present: bool
    claude_share_present: bool
    ollama_binary:        string
    ollama_lan_ok:        bool
    hermes_binary:        string
    hermes_data_present:  bool
end Environment

// Stage is a planned action with a description for the user.
type Stage = struct
    id:          string
    description: string
    is_change:   bool
end Stage

// (function bodies follow in subsequent tasks)

// Probe whether a binary is in PATH by running `command -v <name>`.
// Returns the path on success, empty string on failure.
fn which_binary(name: string): string
    let cap = incus.process_run_capture("sh", ["-c", "command -v \"$1\"", "_", name])
    if cap.exit_code != 0
        return ""
    end if
    return str.trim_ws(cap.stdout)
end which_binary

// Returns true if anything is listening on the given TCP host:port.
// Uses curl with a 1-second connect timeout — succeeds (exit 0) if a server
// responds even with an HTTP error, fails if the connection itself can't be made.
fn ollama_listening_at(host: string): bool
    let url: string = "http://" + host + ":11434"
    let pid: int = p.process_run("curl", [
        "-sS", "-o", "/dev/null", "--connect-timeout", "1", url
    ])
    if pid < 0
        return false
    end if
    return p.process_wait(pid) == 0
end ollama_listening_at

fn detect_environment(home_dir: string): Environment
    let user: string = env.get_env_or("USER", "")
    let lan_ip: string = detect_host_lan_ip()

    // incus reachable?
    let incus_pid: int = incus.process_run_silent("incus", ["version"])
    mut incus_ok: bool = false
    if incus_pid >= 0
        incus_ok = p.process_wait(incus_pid) == 0
    end if

    // repoman project + claude-share profile (only if incus is up)
    mut project_ok: bool = false
    mut claude_share_ok: bool = false
    if incus_ok
        let pe = incus.project_present("repoman")
        if rg.is_ok(pe)
            project_ok = rg.unwrap_ok(pe)
        end if
        let cs = incus.profile_exists("default", "claude-share")
        if rg.is_ok(cs)
            claude_share_ok = rg.unwrap_ok(cs)
        end if
    end if

    let ollama_path: string = which_binary("ollama")
    let hermes_path: string = which_binary("hermes")
    let hermes_data_dir: string = paths.join(home_dir, ".hermes")
    let hermes_data_ok: bool = iodir.dir_exists(hermes_data_dir)

    mut ollama_lan_ok: bool = false
    if str.length(lan_ip) > 0
        ollama_lan_ok = ollama_listening_at(lan_ip)
    end if

    return Environment {
        home_dir:             home_dir,
        user:                 user,
        host_lan_ip:          lan_ip,
        incus_reachable:      incus_ok,
        repoman_project_present: project_ok,
        claude_share_present: claude_share_ok,
        ollama_binary:        ollama_path,
        ollama_lan_ok:        ollama_lan_ok,
        hermes_binary:        hermes_path,
        hermes_data_present:  hermes_data_ok
    }
end detect_environment

fn detect_host_lan_ip(): string
    let cap = incus.process_run_capture("ip", ["-4", "addr", "show", "br0"])
    if cap.exit_code != 0
        return ""
    end if
    // Look for the first occurrence of "inet " and grab the IPv4 token after it
    // until the next slash or space. `cap.stdout` is small, hand-roll the scan.
    let s: string = cap.stdout
    let n: int = str.length(s)
    let needle: string = "inet "
    let needle_len: int = 5
    mut i: int = 0
    while i + needle_len <= n
        if str.substring(s, i, needle_len) == needle
            let start: int = i + needle_len
            mut end_idx: int = start
            while end_idx < n and s[end_idx] != '/' and s[end_idx] != ' '
                end_idx = end_idx + 1
            end while
            return str.substring(s, start, end_idx - start)
        end if
        i = i + 1
    end while
    return ""
end detect_host_lan_ip

fn render_llm_share_template(host_lan_ip: string, user: string): string
    let base: string =
        "name: llm-share\n" +
        "description: |\n" +
        "  Local LLM client tools (ollama client) and host-daemon wiring.\n" +
        "  Created by repoman setup; do not hand-edit (changes will be overwritten).\n" +
        "config:\n" +
        "  environment.OLLAMA_HOST: \"http://{HOST_LAN_IP}:11434\"\n" +
        "devices:\n" +
        "  ollama-bin:\n" +
        "    type: disk\n" +
        "    source: /usr/local/bin/ollama\n" +
        "    path: /usr/local/bin/ollama\n" +
        "    readonly: \"true\"\n"

    let tail: string =
        "  ollama-state:\n" +
        "    type: disk\n" +
        "    source: /home/{USER}/.ollama\n" +
        "    path: /home/{USER}/.ollama\n" +
        "    shift: \"true\"\n"

    let combined: string = base + tail
    let s1: string = str.replace(combined, "{HOST_LAN_IP}", host_lan_ip)
    let s2: string = str.replace(s1,       "{USER}",        user)
    return s2
end render_llm_share_template

fn template_contains_placeholder(s: string): bool
    return str.contains(s, "{HOST_LAN_IP}") or str.contains(s, "{USER}")
end template_contains_placeholder

fn plan_stages(env: Environment, with_llm: bool): [Stage]
    mut count: int = 3                      // incus_project, claude_share_check, registry_defaults
    if with_llm
        count = 4                            // + llm_share_profile (between claude_share and registry)
    end if

    mut stages: [Stage] = new [Stage](count)

    stages[0] = Stage {
        id:          "incus_project",
        description: "ensure Incus project 'repoman' exists",
        is_change:   not env.repoman_project_present
    }
    stages[1] = Stage {
        id:          "claude_share_check",
        description: "verify 'claude-share' profile exists in default project",
        is_change:   false                  // we never modify claude-share; failure→exit
    }
    if with_llm
        let llm_change: bool = (str.length(env.host_lan_ip) > 0) and env.ollama_lan_ok
        stages[2] = Stage {
            id:          "llm_share_profile",
            description: "create/refresh 'llm-share' profile in repoman project",
            is_change:   llm_change
        }
        stages[3] = Stage {
            id:          "registry_defaults",
            description: "write registry defaults (schema 2, llm.enabled, profiles list)",
            is_change:   true
        }
    else
        stages[2] = Stage {
            id:          "registry_defaults",
            description: "write registry defaults (schema 2, llm.enabled=false)",
            is_change:   true
        }
    end if

    return stages
end plan_stages

fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string]
    if stage.id == "incus_project"
        if env.repoman_project_present
            return @Result[config.Registry, string].Ok(reg)  // no-op
        end if
        let r = incus.project_ensure("repoman", false)
        if rg.is_err(r)
            return @Result[config.Registry, string].Err(rg.unwrap_err(r))
        end if
        return @Result[config.Registry, string].Ok(reg)
    end if

    if stage.id == "claude_share_check"
        if env.claude_share_present
            return @Result[config.Registry, string].Ok(reg)
        end if
        return @Result[config.Registry, string].Err(
            "claude-share profile not found.\n" +
            "Create it with:\n" +
            "  incus profile create claude-share\n" +
            "  incus profile edit claude-share  # add your bind-mounts (~/.claude, ~/.local/bin, ...)"
        )
    end if

    if stage.id == "llm_share_profile"
        if not stage.is_change
            return @Result[config.Registry, string].Ok(reg)
        end if
        if str.length(env.host_lan_ip) == 0
            return @Result[config.Registry, string].Err(
                "no br0 IP detected — cannot wire llm-share without a stable LAN address.\n" +
                "Set up br0 first, then re-run 'repoman setup --with-llm'."
            )
        end if
        if not env.ollama_lan_ok
            return @Result[config.Registry, string].Err(
                "ollama daemon not reachable on " + env.host_lan_ip + ":11434.\n" +
                "Add OLLAMA_HOST=" + env.host_lan_ip + ":11434 to your systemd unit and restart ollama."
            )
        end if
        let yaml: string = render_llm_share_template(env.host_lan_ip, env.user)
        let r = incus.profile_create_or_edit("repoman", "llm-share", yaml)
        if rg.is_err(r)
            return @Result[config.Registry, string].Err(rg.unwrap_err(r))
        end if
        return @Result[config.Registry, string].Ok(reg)
    end if

    if stage.id == "registry_defaults"
        // Compute desired LlmDefaults
        let want_enabled: bool = (str.length(env.host_lan_ip) > 0) and env.ollama_lan_ok and (str.length(env.hermes_binary) > 0)
        mut ollama_url: string = ""
        if want_enabled
            ollama_url = "http://" + env.host_lan_ip + ":11434"
        end if
        let new_llm = config.LlmDefaults {
            enabled:        want_enabled,
            hermes_default: false,
            ollama_url:     ollama_url,
            hermes_seed:    hermes.default_seed_list()
        }
        // Compute desired profiles list
        mut new_profiles: [string] = ["default", "claude-share"]
        if want_enabled
            new_profiles = ["default", "claude-share", "llm-share"]
        end if
        let new_defaults = config.Defaults {
            repos_root:    reg.defaults.repos_root,
            backup_root:   reg.defaults.backup_root,
            logdir:        reg.defaults.logdir,
            incus_project: reg.defaults.incus_project,
            default_image: reg.defaults.default_image,
            profiles:      new_profiles,
            llm:           new_llm
        }
        let new_reg = config.Registry {
            schema:   2,
            output:   reg.output,
            defaults: new_defaults,
            projects: reg.projects
        }
        return @Result[config.Registry, string].Ok(new_reg)
    end if

    return @Result[config.Registry, string].Err("unknown stage id: " + stage.id)
end apply_stage

fn cmd_setup(argv: [string]): int
    let parser: flag.FlagParser = flag.flag_parser_from(argv)
    flag.application(parser, "repoman setup")
    flag.description(parser, "First-time host bootstrap: incus project, profiles, registry")
    let _f1 = flag.bool_flag(parser, "non-interactive", '\0', false, "accept all defaults without prompting")
    let _f2 = flag.bool_flag(parser, "with-llm",        '\0', false, "include the LLM stack (llm-share profile, ollama wiring)")
    let _f3 = flag.bool_flag(parser, "without-llm",     '\0', false, "skip the LLM stack")

    if not flag.parse(parser)
        console.printErr("repoman: error: " + flag.error(parser))
        return 2
    end if

    let non_interactive: bool = flag.get_bool(parser, "non-interactive")
    let with_llm_flag: bool = flag.get_bool(parser, "with-llm")
    let without_llm_flag: bool = flag.get_bool(parser, "without-llm")
    if with_llm_flag and without_llm_flag
        console.printErr("repoman: error: --with-llm and --without-llm are mutually exclusive")
        return 2
    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 env_snap = detect_environment(home)
    if not env_snap.incus_reachable
        console.printErr("repoman: error: 'incus' is not installed or not on PATH")
        return 3
    end if

    // Decide LLM inclusion
    mut with_llm: bool = false
    if with_llm_flag
        with_llm = true
    else
        if without_llm_flag
            with_llm = false
        else
            if non_interactive
                with_llm = false                // safe default in non-interactive mode
            else
                with_llm = console.confirm_default_no("Include local LLM stack (ollama + hermes wiring)?")
            end if
        end if
    end if

    // Plan & display
    let stages = plan_stages(env_snap, with_llm)
    println("")
    println("repoman setup plan:")
    let n: int = stages.length()
    mut i: int = 0
    while i < n
        let st = stages[i]
        mut marker: string = "   "
        if st.is_change
            marker = " * "
        end if
        println(marker + "[" + st.id + "] " + st.description)
        i = i + 1
    end while
    println("")
    println("* = will change; otherwise no-op")
    println("")

    if not non_interactive
        let proceed = console.confirm("proceed?")
        if not proceed
            println("aborted")
            return 4
        end if
    end if

    // Load registry (or init)
    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
    mut reg: config.Registry = rg.unwrap_ok(reg_r)

    // Apply stages
    mut k: int = 0
    while k < n
        let st = stages[k]
        println("==> [" + st.id + "] " + st.description)
        let r = apply_stage(st, env_snap, reg)
        if rg.is_err(r)
            console.printErr("repoman: error: " + rg.unwrap_err(r))
            return 1
        end if
        reg = rg.unwrap_ok(r)
        k = k + 1
    end while

    // Persist registry
    let cfg_path: string = config.registry_path(home)
    let saved = config.save(reg, cfg_path)
    if rg.is_err(saved)
        console.printErr("repoman: error: " + rg.unwrap_err(saved))
        return 1
    end if

    println("")
    println("setup complete.")
    println("")
    println("  next: repoman new <name>")
    println("        repoman list")
    return 0
end cmd_setup

end module