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
|
module profile
import core.str
import core.result_generic as rg
import core.convert as convert
import io.file as iofile
import io.dir as iodir
import sys.env
import paths
import incus
export
type ProfileEntry
type HostFacts
fn vendor_dir(): string
fn user_dir(home_dir: string): string
fn render(yaml: string, host: HostFacts): string
fn lookup(name: string, home_dir: string): rg.Result[string, string]
fn list_all(home_dir: string): [ProfileEntry]
fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
fn remove_profile(name: string): rg.Result[bool, string]
fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
end export
// One entry from the profile library, populated by list_all.
type ProfileEntry = struct
name: string // e.g., "claude-share"
source: string // "user", "vendor", or "user (shadows vendor)"
file_path: string // resolved file path
installed: bool // present in incus state
end ProfileEntry
// Substitution context for templated profile YAML.
type HostFacts = struct
lan_ip: string
user: string
home: string
end HostFacts
// (function bodies follow in subsequent tasks)
fn vendor_dir(): string
return "/usr/local/share/repoman/profiles"
end vendor_dir
fn user_dir(home_dir: string): string
let suffix: string = ".config/repoman/profiles.d"
if str.is_empty(home_dir)
return "/" + suffix
end if
return paths.join(home_dir, suffix)
end user_dir
fn render(yaml: string, host: HostFacts): string
let s1: string = str.replace(yaml, "\${HOST_LAN_IP}", host.lan_ip)
let s2: string = str.replace(s1, "\${USER}", host.user)
let s3: string = str.replace(s2, "\${HOME}", host.home)
return s3
end render
fn lookup(name: string, home_dir: string): rg.Result[string, string]
let user_path: string = paths.join(user_dir(home_dir), name + ".yml")
if iofile.fileExists(user_path)
return @Result[string, string].Ok(user_path)
end if
let vendor_path: string = paths.join(vendor_dir(), name + ".yml")
if iofile.fileExists(vendor_path)
return @Result[string, string].Ok(vendor_path)
end if
return @Result[string, string].Err("profile not found: " + name + " (looked in " + user_path + " and " + vendor_path + ")")
end lookup
fn list_all(home_dir: string): [ProfileEntry]
// Buffers for filename lists. Cap at 256 per dir; profile libraries
// are not expected to be enormous.
let max_files: int = 256
mut user_buf: [string] = new [string](max_files)
mut vendor_buf: [string] = new [string](max_files)
let u_dir: string = user_dir(home_dir)
mut user_count: int = 0
if iodir.dir_exists(u_dir)
user_count = iodir.list_dir(u_dir, user_buf, max_files)
end if
let v_dir: string = vendor_dir()
mut vendor_count: int = 0
if iodir.dir_exists(v_dir)
vendor_count = iodir.list_dir(v_dir, vendor_buf, max_files)
end if
// Pre-allocate result buffer at worst-case size.
let cap: int = user_count + vendor_count
mut entries_buf: [ProfileEntry] = new [ProfileEntry](cap)
mut e_count: int = 0
// Pass 1: add all user-dir *.yml files
mut i: int = 0
while i < user_count
let fname: string = user_buf[i]
if str.ends_with(fname, ".yml")
let name: string = str.substring(fname, 0, str.length(fname) - 4)
let path: string = paths.join(u_dir, fname)
let installed_r = incus.profile_exists("default", name)
mut installed: bool = false
if rg.is_ok(installed_r)
installed = rg.unwrap_ok(installed_r)
end if
entries_buf[e_count] = ProfileEntry {
name: name,
source: "user",
file_path: path,
installed: installed
}
e_count = e_count + 1
end if
i = i + 1
end while
// Pass 2: add vendor-dir *.yml files NOT already present (so user wins)
mut j: int = 0
while j < vendor_count
let fname: string = vendor_buf[j]
if str.ends_with(fname, ".yml")
let name: string = str.substring(fname, 0, str.length(fname) - 4)
// Check if name already in entries_buf
mut shadowed: bool = false
mut k: int = 0
while k < e_count
if entries_buf[k].name == name
shadowed = true
// Update the existing user entry's source label to indicate shadow
entries_buf[k] = ProfileEntry {
name: entries_buf[k].name,
source: "user (shadows vendor)",
file_path: entries_buf[k].file_path,
installed: entries_buf[k].installed
}
end if
k = k + 1
end while
if not shadowed
let path: string = paths.join(v_dir, fname)
let installed_r = incus.profile_exists("default", name)
mut installed: bool = false
if rg.is_ok(installed_r)
installed = rg.unwrap_ok(installed_r)
end if
entries_buf[e_count] = ProfileEntry {
name: name,
source: "vendor",
file_path: path,
installed: installed
}
e_count = e_count + 1
end if
end if
j = j + 1
end while
// Compact to actual size
mut entries: [ProfileEntry] = new [ProfileEntry](e_count)
mut m: int = 0
while m < e_count
entries[m] = entries_buf[m]
m = m + 1
end while
return entries
end list_all
fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
// Resolve the file (user or vendor)
let path_r = lookup(name, home_dir)
if rg.is_err(path_r)
return @Result[bool, string].Err(rg.unwrap_err(path_r))
end if
let path: string = rg.unwrap_ok(path_r)
// Read the file
let yaml: string = iofile.readFile(path)
if str.length(yaml) == 0
return @Result[bool, string].Err("empty or unreadable profile file: " + path)
end if
// Render — but if HOST_LAN_IP is needed and host.lan_ip is empty, fail with a hint
if str.contains(yaml, "\${HOST_LAN_IP}") and str.length(host.lan_ip) == 0
return @Result[bool, string].Err("profile " + name + " requires \${HOST_LAN_IP} but [host].lan_ip is empty in registry. Run 'repoman setup' to detect and store the host LAN IP.")
end if
let rendered: string = render(yaml, host)
// Apply via incus
return incus.profile_create_or_edit("default", name, rendered)
end install
fn remove_profile(name: string): rg.Result[bool, string]
return incus.delete_profile("default", name)
end remove_profile
fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
let path_r = lookup(name, home_dir)
if rg.is_err(path_r)
return @Result[string, string].Err(rg.unwrap_err(path_r))
end if
let path: string = rg.unwrap_ok(path_r)
let yaml: string = iofile.readFile(path)
let rendered: string = render(yaml, host)
return @Result[string, string].Ok(rendered)
end show
fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
// Render the file
let path_r = lookup(name, home_dir)
if rg.is_err(path_r)
return @Result[string, string].Err(rg.unwrap_err(path_r))
end if
let path: string = rg.unwrap_ok(path_r)
let yaml: string = iofile.readFile(path)
let rendered: string = render(yaml, host)
// Fetch incus state
let incus_r = incus.profile_get("default", name)
if rg.is_err(incus_r)
// Profile not installed — diff shows the rendered file as "would install"
let msg: string = "profile " + name + " is not installed. Rendered would-be content:\n\n" + rendered
return @Result[string, string].Ok(msg)
end if
let live: string = rg.unwrap_ok(incus_r)
if rendered == live
return @Result[string, string].Ok("(no drift)")
end if
// Simple side-by-side: file content marked '-- file --', incus content '-- incus --'.
let header: string = "--- profile file (rendered): " + path + "\n+++ incus state\n"
let body: string = "-- file --\n" + rendered + "\n-- incus --\n" + live
return @Result[string, string].Ok(header + body)
end diff
end module
|