|
root / src / commands / remove.reef
remove.reef Reef 485 lines 15.7 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
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
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
/******************************************************************************
               __               ____                __
              / /   ___  ____ _/ __/_____________ _/ /__
             / /   / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
            / /___/  __/ /_/ / __(__  ) /__/ /_/ / /  __/
           /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/

   (C)opyright 2025, Leafscale, LLC -  https://www.leafscale.com

   Project: Zygaena
  Filename: remove.reef
   Authors: Chris Tusa <chris.tusa@leafscale.com>
   License: <see LICENSE file included with this source code>
Description: Remove command - uninstall packages

******************************************************************************/

module commands.remove

import sys.args
import sys.process
import io.file
import io.dir
import io.path
import core.str
import core.config
import core.database
import core.pkgdb
import core.package
import core.port
import util.prompt
import util.mtree
import util.checksum
import types
import util.color
import fs.stat
import fs.ops
import util.priv
import exitcodes as ec

export
    fn execute(opts: types.GlobalOptions): int
end export

fn execute(opts: types.GlobalOptions): int
    let argc = args.count()

    // Package arguments should be after the command
    if argc <= opts.cmd_index + 1
        print_usage()
        return ec.EXIT_USAGE()
    end if

    // Load configuration and apply command-line overrides
    let base_cfg = config.load()
    let cfg = config.apply_overrides(base_cfg, opts.root, opts.prefix, opts.no_chroot)
    let root_path = config.get_root(cfg)

    // Show root if set (verbose mode)
    if opts.verbose and str.length(root_path) > 0
        color.print_info("Using root: " + root_path)
    end if

    // Collect packages to remove
    mut packages: [string] = new [string](256)
    mut pkg_count = 0

    mut i = opts.cmd_index + 1
    while i < argc and pkg_count < 256
        let pkg_name = args.get(i)

        // Skip flags and their values
        if pkg_name == "--root" or pkg_name == "--prefix"
            i = i + 2  // Skip flag and its value
            continue
        elif pkg_name == "--no-chroot-scripts"
            i = i + 1  // valueless flag: skip only itself
            continue
        end if
        if str.starts_with(pkg_name, "-")
            i = i + 1
            continue
        end if

        // Check if installed (use rooted version if root is set)
        let installed = check_installed(pkg_name, root_path)
        if installed
            packages[pkg_count] = pkg_name
            pkg_count = pkg_count + 1
        else
            color.print_warning(pkg_name + " is not installed")
        end if

        i = i + 1
    end while

    if pkg_count == 0
        color.print_info("No packages to remove")
        return ec.EXIT_SUCCESS()
    end if

    // Check for reverse dependencies (packages that depend on these)
    mut dependents: [string] = new [string](256)
    mut dependent_count = 0

    i = 0
    while i < pkg_count
        let pkg_name = packages[i]
        let count = get_reverse_dependencies(pkg_name, dependents, dependent_count, 256)
        dependent_count = count
        i = i + 1
    end while

    // Warn if there are dependents
    if dependent_count > 0
        color.print_warning("The following installed packages depend on packages being removed:")
        i = 0
        while i < dependent_count
            println("  - " + dependents[i])
            i = i + 1
        end while

        // Require --force to proceed
        if not opts.force
            color.print_error("Use --force to remove packages with dependents")
            return ec.EXIT_DEPS_CONFLICT()
        end if
        color.print_warning("Proceeding with removal (--force specified)")
    end if

    // Ask for confirmation (unless -y flag)
    if not prompt.confirm_remove(packages, pkg_count, dependents, dependent_count, opts)
        color.print_info("Removal cancelled")
        return ec.EXIT_CANCELLED()
    end if

    // Dry-run: show what would be done and exit
    if opts.dry_run
        color.print_info("Dry run — no changes made")
        return ec.EXIT_SUCCESS()
    end if

    // Remove packages
    mut all_success = true
    i = 0
    while i < pkg_count
        if not remove_package(packages[i], root_path, cfg)
            color.print_error("Failed to remove: " + packages[i])
            all_success = false
        end if
        i = i + 1
    end while

    if all_success
        return ec.EXIT_SUCCESS()
    else
        return ec.EXIT_REMOVE_FAILED()
    end if
end execute

// Check if package is installed, using rooted check if root is set
fn check_installed(name: string, root_path: string): bool
    if str.length(root_path) > 0
        return database.is_installed_rooted(name, root_path)
    end if
    return database.is_installed(name)
end check_installed

proc print_usage()
    println("Usage: coral remove <package...>")
    println("")
    println("Remove one or more installed packages.")
    println("")
    println("Arguments:")
    println("  <package>    Name of installed package to remove")
    println("")
    println("Options:")
    println("  -y, --yes    Don't ask for confirmation")
    println("  -f, --force  Remove even if other packages depend on it")
    println("")
    println("Examples:")
    println("  coral remove vim")
    println("  coral remove vim nano htop")
    println("  coral remove --force libfoo")
end print_usage

fn remove_package(pkg_name: string, root_path: string, cfg: config.Config): bool
    // Check if package is installed (use rooted version if root is set)
    if not check_installed(pkg_name, root_path)
        color.print_error(pkg_name + " is not installed")
        return false
    end if

    color.print_action("Removing " + pkg_name + "...")

    let install_root = get_actual_root(root_path)
    // Resolve script execution context; abort early if confinement is required but impossible.
    // Defense-in-depth: main.reef already bars non-root for all non-query commands, so this abort
    // is currently unreachable via the CLI. Kept so confinement fails safely if that global gate is
    // ever relaxed.
    if package.script_wants_chroot(install_root, config.get_chroot_scripts(cfg)) and not priv.is_root()
        color.print_error("--root " + install_root + " runs maintainer scripts confined to the target via chroot, which requires root privileges.")
        color.print_error("Re-run as root, or pass --no-chroot-scripts to run them on the host (unconfined).")
        return false
    end if
    let script_ctx = package.resolve_script_ctx(install_root, config.get_chroot_scripts(cfg), priv.is_root())

    // Get package info (use rooted version if root is set)
    let pkg = get_installed_pkg(pkg_name, root_path)
    color.print_info("Package: " + pkg.info.name + " " + pkg.info.version)

    // Get file list from database (use rooted version if root is set)
    mut files: [string] = new [string](4096)
    let file_count = get_files_list(pkg_name, files, 4096, root_path)

    if file_count == 0
        color.print_warning("No files recorded for package")
    else
        color.print_info("Removing " + int_to_str(file_count) + " files...")
    end if

    // Run pre-remove script if present (abort on failure)
    let scripts_dir = database.get_scripts_dir(pkg_name)
    if database.has_stored_scripts(pkg_name)
        color.print_info("Running pre-remove script...")
        if not package.run_stored_pre_remove(script_ctx, scripts_dir, pkg.info.version)
            color.print_error("Pre-remove script failed, aborting removal of " + pkg_name)
            return false
        end if
    end if

    // Load config file list and manifest checksums for config protection
    mut config_files: [string] = new [string](256)
    let config_count = database.get_config_files(pkg_name, config_files, 256)

    mut manifest_entries: [mtree.ManifestEntry] = new [mtree.ManifestEntry](8192)
    mut manifest_count = 0
    let manifest_path = database.get_manifest_path(pkg_name)
    if file.fileExists(manifest_path)
        let manifest_content = file.readFile(manifest_path)
        if str.length(manifest_content) > 0
            manifest_count = mtree.parse_manifest(manifest_content, manifest_entries, 8192)
        end if
    end if

    // Remove files (with proper root path, respecting config protection)
    let actual_root = get_actual_root(root_path)
    if not remove_files(files, file_count, actual_root, config_files, config_count, manifest_entries, manifest_count)
        color.print_warning("Some files could not be removed")
        // Continue anyway - unregister from database
    end if

    // Run post-remove script if present (warn on failure, don't abort)
    if database.has_stored_scripts(pkg_name)
        color.print_info("Running post-remove script...")
        if not package.run_stored_post_remove(script_ctx, scripts_dir, pkg.info.version)
            color.print_warning("Post-remove script failed")
        end if
    end if

    // Unregister from database (use rooted version if root is set)
    color.print_info("Unregistering package...")
    if not unregister_pkg(pkg_name, root_path)
        color.print_error("Failed to unregister package from database")
        return false
    end if

    // Update package database indexes
    if not pkgdb.rebuild_indexes_rooted(root_path)
        color.print_warning("Failed to update package indexes")
    end if

    color.print_success("Removed " + pkg_name)
    return true
end remove_package

// Get installed package info, using rooted check if root is set
fn get_installed_pkg(name: string, root_path: string): types.InstalledPackage
    if str.length(root_path) > 0
        return database.get_installed_rooted(name, root_path)
    end if
    return database.get_installed(name)
end get_installed_pkg

// Get files list, using rooted check if root is set
fn get_files_list(name: string, files: [string], max_count: int, root_path: string): int
    if str.length(root_path) > 0
        return database.get_files_rooted(name, files, max_count, root_path)
    end if
    return database.get_files(name, files, max_count)
end get_files_list

// Unregister package, using rooted version if root is set
fn unregister_pkg(name: string, root_path: string): bool
    if str.length(root_path) > 0
        return database.unregister_package_rooted(name, root_path)
    end if
    return database.unregister_package(name)
end unregister_pkg

// Get actual root path (returns "/" if empty)
fn get_actual_root(root_path: string): string
    if str.length(root_path) == 0
        return "/"
    end if
    return root_path
end get_actual_root

// Remove files from system, respecting config file protection
fn remove_files(files: [string], file_count: int, root_path: string, config_files: [string], config_count: int, manifest_entries: [mtree.ManifestEntry], manifest_count: int): bool
    mut all_success = true

    // Remove files in reverse order
    mut i = file_count - 1
    while i >= 0
        let rel_path = files[i]
        let full_path = path.join_path(root_path, rel_path)

        if stat.is_symlink(full_path) or stat.exists(full_path)
            // Check if this is a config file that the user has modified
            if config_count > 0 and package.is_config_file(rel_path, config_files, config_count)
                // Look up the package's sha256 from the manifest
                let pkg_sha = find_manifest_sha256(rel_path, manifest_entries, manifest_count)
                if str.length(pkg_sha) > 0
                    let installed_sha = checksum.sha256_file(full_path)
                    if not str.equals(installed_sha, pkg_sha)
                        // User has modified this config — preserve it
                        color.print_info("Preserving modified config: " + rel_path)
                        i = i - 1
                        continue
                    end if
                end if
            end if

            if not ops.remove_file(full_path)
                color.print_warning("Failed to remove: " + full_path)
                all_success = false
            end if
        end if

        i = i - 1
    end while

    // Try to remove empty parent directories
    i = file_count - 1
    while i >= 0
        let rel_path = files[i]
        let full_path = path.join_path(root_path, rel_path)
        let parent = path.dirname(full_path)

        if str.length(parent) > str.length(root_path) and dir.dir_exists(parent)
            dir.remove_dir(parent)
        end if

        i = i - 1
    end while

    return all_success
end remove_files

// Look up the sha256 for a given relative path in the manifest entries
fn find_manifest_sha256(rel_path: string, entries: [mtree.ManifestEntry], count: int): string
    mut i = 0
    while i < count
        let epath = mtree.entry_path(entries[i])
        // Compare with and without ./ prefix
        mut entry_rel = epath
        if str.starts_with(epath, "./")
            entry_rel = str.substring(epath, 2, str.length(epath) - 2)
        end if
        if str.equals(entry_rel, rel_path)
            return mtree.entry_sha256(entries[i])
        end if
        i = i + 1
    end while
    return ""
end find_manifest_sha256

// Get packages that depend on the given package
// Returns new total count (existing + new dependents)
fn get_reverse_dependencies(pkg_name: string, dependents: [string], current_count: int, max_count: int): int
    mut count = current_count

    // List all installed packages
    mut installed: [string] = new [string](512)
    let installed_count = database.list_installed(installed, 512)

    mut i = 0
    while i < installed_count and count < max_count
        let other_pkg = installed[i]

        // Skip the package being removed and already listed dependents
        if str.equals(other_pkg, pkg_name)
            i = i + 1
            continue
        end if

        // Check if already in dependents list
        if array_contains(dependents, count, other_pkg)
            i = i + 1
            continue
        end if

        // Try to find this package's port to get its dependencies
        let port_result = port.find(other_pkg)
        if port_result.success
            let p = port_result.port

            // Check runtime dependencies
            if depends_on(p.deps.runtime, p.deps.runtime_count, pkg_name)
                dependents[count] = other_pkg
                count = count + 1
            end if
        end if

        i = i + 1
    end while

    return count
end get_reverse_dependencies

// Check if a dependency list contains the given package
fn depends_on(deps: [string], dep_count: int, pkg_name: string): bool
    mut i = 0
    while i < dep_count
        let dep = deps[i]

        // Handle alternative dependencies (format: base:opt1,opt2)
        let colon_pos = str.index_of_char(dep, ':')
        mut base_dep = dep
        if colon_pos > 0
            base_dep = str.substring(dep, 0, colon_pos)
        end if

        if str.equals(base_dep, pkg_name)
            return true
        end if

        i = i + 1
    end while

    return false
end depends_on

// Helper to check if array contains a string
fn array_contains(arr: [string], count: int, s: string): bool
    mut i = 0
    while i < count
        if str.equals(arr[i], s)
            return true
        end if
        i = i + 1
    end while
    return false
end array_contains

// Helper: convert int to string
fn int_to_str(n: int): string
    if n == 0
        return "0"
    end if

    mut negative = false
    mut value = n
    if n < 0
        negative = true
        value = 0 - n
    end if

    mut result = ""
    while value > 0
        let digit = value % 10
        result = str.concat(str.substring("0123456789", digit, 1), result)
        value = value / 10
    end while

    if negative
        result = str.concat("-", result)
    end if

    return result
end int_to_str

end module