• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nightconcept / almandine / 14846535458

05 May 2025 09:22PM UTC coverage: 32.421% (-35.5%) from 67.965%
14846535458

push

github

web-flow
fix: Change init module to be e2e testable (#17)

91 of 420 new or added lines in 11 files covered. (21.67%)

462 of 1425 relevant lines covered (32.42%)

1.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

2.49
/src/modules/self.lua
1
--[[
2
  Self Command Module
3

4
  Implementation for self-uninstall and future self-update features.
5
  This module provides removal of CLI wrapper scripts and the CLI Lua folder.
6
]]
7
--
8

9
---TODO: remove this once we have a pass over this file
10
-- luacheck: ignore
11
local lfs = require("lfs")
2✔
12
local M = {}
2✔
13

14
---@class SelfDeps
15
---@field executor fun(cmd: string): boolean, string?, number? Optional function for command execution.
16
---@field printer table Printer utility with stdout/stderr methods.
17

18
--- Recursively delete a directory and its contents (cross-platform: POSIX and Windows)
19
-- @param path [string] Directory path to delete
20
-- @param executor [function?] Optional. Function to use for executing shell commands (default: os.execute)
21
-- @return [boolean, string?] True if successful, or false and error message
22
function M.rmdir_recursive(path, executor)
2✔
23
  executor = executor or os.execute
×
24
  local is_windows = package.config:sub(1, 1) == "\\"
×
25
  local cmd
26
  if is_windows then
×
27
    cmd = string.format('rmdir /s /q "%s"', path)
×
28
  else
29
    cmd = string.format("rm -rf %q", path)
×
30
  end
31
  local ok = executor(cmd)
×
32
  -- os.execute returns true/0 on success, or nonzero/nil on failure
33
  if ok == 0 or ok == true then
×
34
    return true
×
35
  else
36
    return false, "Failed to remove directory: " .. path
×
37
  end
38
end
39

40
--- Remove wrapper scripts and Lua CLI folder.
41
-- @param deps SelfDeps Dependencies { executor?, printer }.
42
-- @return boolean success True if successful, false otherwise.
43
-- @return string|nil output_message Message for stdout.
44
-- @return string|nil error_message Message for stderr.
45
function M.uninstall_self(deps)
2✔
NEW
46
  local executor = deps and deps.executor or os.execute
×
NEW
47
  local printer = deps and deps.printer
×
48

NEW
49
  local output_messages = {}
×
NEW
50
  local error_messages = {}
×
51

52
  -- Remove wrapper scripts from known install locations
53
  local is_windows = package.config:sub(1, 1) == "\\"
×
54
  local install_prefixes
55
  local wrappers = { "almd", "almd.sh", "almd.bat", "almd.ps1" }
×
56
  if is_windows then
×
57
    local localappdata = os.getenv("LOCALAPPDATA") or ""
×
58
    local userprofile = os.getenv("USERPROFILE") or ""
×
59
    install_prefixes = {
×
60
      localappdata .. "\\almd",
×
61
      localappdata .. "\\Programs\\almd",
×
62
      userprofile .. "\\.almd",
×
63
      userprofile .. "\\AppData\\Local\\almd",
×
64
      "C:\\Program Files\\almd",
65
      "C:\\almd",
66
    }
67
  else
68
    install_prefixes = {
×
69
      os.getenv("HOME") .. "/.local/bin",
×
70
      os.getenv("HOME") .. "/.almd/install",
×
71
      os.getenv("HOME") .. "/.almd",
×
72
      "/usr/local/bin",
73
    }
74
  end
75
  for _, prefix in ipairs(install_prefixes) do
×
76
    for _, script in ipairs(wrappers) do
×
77
      local sep = is_windows and "\\" or "/"
×
78
      local path = prefix .. sep .. script
×
NEW
79
      local removed = os.remove(path)
×
NEW
80
      if removed == nil then
×
81
        -- os.remove returns nil on failure, true on success
82
        -- printer.stderr("DEBUG: Failed to remove wrapper ", path) -- Optional debug
NEW
83
      elseif removed then
×
NEW
84
        table.insert(output_messages, "Removed wrapper: " .. path)
×
85
      end
86
    end
87
  end
88
  -- Remove src/ folder from main install dir
89
  local cli_dir
90
  if is_windows then
×
91
    cli_dir = (os.getenv("LOCALAPPDATA") or "") .. "\\almd\\src"
×
92
    if not require("lfs").attributes(cli_dir) then
×
93
      cli_dir = (os.getenv("USERPROFILE") or "") .. "\\.almd\\src"
×
94
    end
95
  else
96
    cli_dir = os.getenv("HOME") .. "/.almd/src"
×
97
  end
98
  if require("lfs").attributes(cli_dir) then
×
99
    M.rmdir_recursive(cli_dir, executor)
×
NEW
100
    table.insert(output_messages, "Removed CLI directory: " .. cli_dir)
×
101
  else
NEW
102
    table.insert(output_messages, "CLI directory not found or already removed: " .. cli_dir)
×
103
  end
104

NEW
105
  local final_output = table.concat(output_messages, "\n")
×
106
  local final_error = nil
NEW
107
  if #error_messages > 0 then
×
NEW
108
    final_error = table.concat(error_messages, "\n")
×
109
  end
110

NEW
111
  return true, final_output, final_error -- Assume success unless rmdir fails catastrophically (hard to detect well)
×
112
end
113

114
--- Returns the absolute path to the install root (directory containing this script)
115
-- Works for both CLI and test runner
116
local function get_install_root()
117
  -- Try debug.getinfo first (works for most Lua runners)
118
  local info = debug.getinfo(1, "S")
×
119
  local source = info and info.source or ""
×
120
  local path
121
  if source:sub(1, 1) == "@" then
×
122
    path = source:sub(2)
×
123
  elseif arg and arg[0] then
×
124
    path = arg[0]
×
125
  end
126
  if not path then
×
127
    return "."
×
128
  end
129
  -- Remove trailing filename (e.g. /src/modules/self.lua)
130
  path = path:gsub("[\\/][^\\/]-$", "")
×
131
  -- If in /src/modules, move up two dirs to install root
132
  if path:match("[\\/]modules$") then
×
133
    path = path:gsub("[\\/]modules$", "")
×
134
    path = path:gsub("[\\/]src$", "")
×
135
  end
136
  return path
×
137
end
138

139
--- Atomically self-update the CLI from the latest GitHub release.
140
-- Downloads, extracts, backs up, and atomically replaces the install tree.
141
-- Only deletes backup if new version is fully extracted and ready.
142
-- @param deps SelfDeps Dependencies { executor?, printer }.
143
-- @return boolean success True if successful, false otherwise.
144
-- @return string|nil output_message Message for stdout.
145
-- @return string|nil error_message Message for stderr.
146
function M.self_update(deps)
2✔
NEW
147
  local executor = deps and deps.executor or os.execute
×
NEW
148
  local printer = deps and deps.printer
×
149

150
  local is_windows = package.config:sub(1, 1) == "\\"
×
151
  local install_root = get_install_root()
×
152
  local join = function(...) -- join paths with correct sep
153
    local sep = is_windows and "\\" or "/"
×
154
    local args = { ... }
×
155
    local out = args[1] or ""
×
156
    for i = 2, #args do
×
157
      if out:sub(-1) ~= sep then
×
158
        out = out .. sep
×
159
      end
160
      local seg = args[i]
×
161
      if seg:sub(1, 1) == sep then
×
162
        seg = seg:sub(2)
×
163
      end
164
      out = out .. seg
×
165
    end
166
    return out
×
167
  end
168

169
  local function log_output(msg)
NEW
170
    if printer then
×
NEW
171
      printer.stdout(msg)
×
172
    else
NEW
173
      print(msg) -- Fallback if printer not injected (e.g., testing)
×
174
    end
175
  end
176

177
  local function log_error(msg)
NEW
178
    if printer then
×
NEW
179
      printer.stderr(msg)
×
180
    else
NEW
181
      print("Error: " .. msg) -- Fallback
×
182
    end
183
  end
184

185
  -- Utility: check if file or directory exists
186
  local function path_exists(path)
187
    -- Use lfs for better directory checking
NEW
188
    local mode = lfs.attributes(path, "mode")
×
NEW
189
    return mode ~= nil
×
190
  end
191

192
  -- Helper: download file (wget/curl)
193
  local downloader = require("utils.downloader")
×
194
  local function download(url, out)
195
    return downloader.download(url, out)
×
196
  end
197

198
  -- Helper: run shell command using injected executor
199
  local function shell(cmd)
NEW
200
    local ok, reason, code = executor(cmd)
×
201
    return ok == 0 or ok == true
×
202
  end
203

204
  -- Set up copy commands for cross-platform compatibility
205
  local cp, xcopy
206
  if is_windows then
×
207
    cp = "copy"
×
208
    xcopy = "xcopy"
×
209
  else
210
    cp = "cp -f"
×
211
    xcopy = "cp -r"
×
212
  end
213

214
  -- Step 1: Fetch latest tag
215
  local tag_url = "https://api.github.com/repos/nightconcept/almandine/tags?per_page=1"
×
216
  local zip_url, tag
217

218
  local tmp_dir = is_windows and os.getenv("TEMP") or "/tmp"
×
219
  local uuid = tostring(os.time()) .. tostring(math.random(10000, 99999))
×
220
  local work_dir = tmp_dir .. (is_windows and "\\almd_update_" or "/almd_update_") .. uuid
×
221
  local tag_file = work_dir .. (is_windows and "\\tag.json" or "/tag.json")
×
222
  os.execute(
×
223
    (is_windows and "mkdir " or "mkdir -p ") .. work_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")
×
224
  )
225
  local ok, err = download(tag_url, tag_file)
×
226
  if not ok then
×
227
    return false, "Failed to fetch latest release info: " .. (err or "unknown error")
×
228
  end
229
  local tag_data = io.open(tag_file, "r")
×
230
  if not tag_data then
×
231
    return false, "Could not read tag file"
×
232
  end
233
  local tag_json = tag_data:read("*a")
×
234
  tag_data:close()
×
235
  tag = tag_json:match('"name"%s*:%s*"([^"]+)"')
×
236
  if not tag then
×
237
    return false, "Could not parse latest tag from GitHub API"
×
238
  end
239
  zip_url = "https://github.com/nightconcept/almandine/archive/refs/tags/" .. tag .. ".zip"
×
240

241
  -- Step 2: Download zip
242
  local zip_path = work_dir .. (is_windows and "\\almd.zip" or "/almd.zip")
×
243
  ok, err = download(zip_url, zip_path)
×
244
  if not ok then
×
245
    return false, "Failed to download release zip: " .. (err or "unknown error")
×
246
  end
247
  -- Step 3: Extract zip
248
  local extract_dir = work_dir .. (is_windows and "\\extract" or "/extract")
×
249
  os.execute(
×
250
    (is_windows and "mkdir " or "mkdir -p ") .. extract_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")
×
251
  )
252
  local unzip_cmd = is_windows
×
253
      and (
×
254
        "powershell -Command \"Add-Type -A 'System.IO.Compression.FileSystem'; "
255
        .. "[IO.Compression.ZipFile]::ExtractToDirectory('"
×
256
        .. zip_path
×
257
        .. "', '"
×
258
        .. extract_dir
×
259
        .. "')\""
×
260
      ) -- luacheck: ignore 121
261
    or ("unzip -q -o '" .. zip_path .. "' -d '" .. extract_dir .. "'")
×
262
  if not shell(unzip_cmd .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")) then
×
NEW
263
    log_error("Failed to extract release zip")
×
NEW
264
    return false, nil, "Failed to extract release zip"
×
265
  end
266
  -- Step 4: Find extracted folder
267
  local extracted = extract_dir .. (is_windows and ("\\almandine-" .. tag) or ("/almandine-" .. tag))
×
268
  local extracted_v = extract_dir .. (is_windows and ("\\almandine-v" .. tag) or ("/almandine-v" .. tag))
×
269
  local main_lua_path = extracted .. "/src/main.lua"
×
270
  local main_lua_v_path = extracted_v .. "/src/main.lua"
×
271
  local file_handle = io.open(main_lua_path) or io.open(main_lua_v_path)
×
272
  local final_dir
273
  if file_handle then
×
274
    file_handle:close()
×
275
    final_dir = io.open(main_lua_path) and extracted or extracted_v
×
276
  else
277
    final_dir = nil
×
278
  end
279
  if not final_dir then
×
NEW
280
    log_error("Could not find extracted CLI source in zip")
×
NEW
281
    return false, nil, "Could not find extracted CLI source in zip"
×
282
  end
283

284
  -- Step 5: Backup current install tree (wrapper scripts + src)
285
  local backup_dir = work_dir .. (is_windows and "\\backup" or "/backup")
×
286
  os.execute(
×
287
    (is_windows and "mkdir " or "mkdir -p ") .. backup_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")
×
288
  )
289
  -- Copy wrappers (always files)
290
  local wrappers = {
×
291
    { from = join(install_root, "install", "almd.sh"), to = join(backup_dir, "almd.sh") },
×
292
    { from = join(install_root, "install", "almd.bat"), to = join(backup_dir, "almd.bat") },
×
293
    { from = join(install_root, "install", "almd.ps1"), to = join(backup_dir, "almd.ps1") },
×
294
  }
295
  for _, w in ipairs(wrappers) do
×
296
    if path_exists(w.from) then
×
297
      if is_windows then
×
298
        shell(cp .. ' "' .. w.from .. '" "' .. w.to .. '"' .. (is_windows and " /Y /Q >NUL 2>&1" or " >/dev/null 2>&1"))
×
299
      else
300
        shell(cp .. ' "' .. w.from .. '" "' .. w.to .. '"' .. (is_windows and "" or " >/dev/null 2>&1"))
×
301
      end
302
    end
303
  end
304
  -- Copy src directory
305
  local src_dir = join(install_root, "src")
×
306
  if path_exists(src_dir) then
×
307
    if is_windows then
×
308
      shell(
×
309
        xcopy
310
          .. ' "'
×
311
          .. src_dir
×
312
          .. '" "'
×
313
          .. join(backup_dir, "src")
×
314
          .. '"'
×
315
          .. (is_windows and " /E /I /Y /Q >NUL 2>&1" or " >/dev/null 2>&1")
×
316
      )
317
    else
318
      shell(
×
319
        xcopy .. ' "' .. src_dir .. '" "' .. join(backup_dir, "src") .. '"' .. (is_windows and "" or " >/dev/null 2>&1")
×
320
      )
321
    end
322
  end
323

324
  -- Windows: Check if files are locked (in use) before proceeding
325
  -- Note: This lock check is inherently racy and might not be reliable.
326
  if is_windows then
×
327
    local function is_file_locked(path)
328
      local lock_handle = io.open(path, "r+")
×
329
      if lock_handle then
×
330
        lock_handle:close()
×
331
        return false
×
332
      end
333
      return true
×
334
    end
335
    local files_to_check = {
×
336
      join(install_root, "src", "main.lua"),
×
337
      join(install_root, "install", "almd.sh"),
×
338
      join(install_root, "install", "almd.bat"),
×
339
      join(install_root, "install", "almd.ps1"),
×
340
    }
341
    local function stage_update_for_next_run(staging_dir, extracted_dir)
342
      -- Remove any existing staged update
343
      if path_exists(staging_dir) then
×
344
        if is_windows then
×
345
          shell('rmdir /s /q "' .. staging_dir .. '" >NUL 2>&1')
×
346
        else
347
          shell('rm -rf "' .. staging_dir .. '" >/dev/null 2>&1')
×
348
        end
349
      end
350
      -- Copy extracted_dir to staging_dir
351
      if is_windows then
×
352
        shell('xcopy "' .. extracted_dir .. '" "' .. staging_dir .. '" /E /I /Y /Q >NUL 2>&1')
×
353
      else
354
        shell('cp -r "' .. extracted_dir .. '" "' .. staging_dir .. '" >/dev/null 2>&1')
×
355
      end
356
      -- Write marker file
357
      local marker = io.open(join(install_root, "install", "update_pending"), "w")
×
NEW
358
      log_output("Staging update for next run...")
×
359
      if marker then
×
360
        marker:write(os.date("%Y-%m-%dT%H:%M:%S"))
×
361
        marker:close()
×
362
      end
363
    end
364
    local locked_file = nil
365
    for _, file in ipairs(files_to_check) do
×
NEW
366
      if path_exists(file) and is_file_locked(file) then -- Only check if file exists
×
367
        locked_file = file
×
368
        break
369
      end
370
    end
371
    if locked_file then
×
NEW
372
      log_output("File currently in use: " .. locked_file)
×
373
      local staging_dir = join(install_root, "install", "next")
×
374
      stage_update_for_next_run(staging_dir, final_dir)
×
375
      -- Return success=true because staging is the expected outcome here
NEW
376
      return true, "Update staged for next run due to locked file.", nil
×
377
    end
378
  end
379

NEW
380
  log_output("Replacing current installation...")
×
381
  -- Step 6: Replace install tree with new version
382
  -- Remove old src and wrappers
383
  local rm = is_windows and "rmdir /s /q" or "rm -rf"
×
384
  shell(rm .. " " .. join(install_root, "src") .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
385
  shell(
×
386
    is_windows
387
        and (
×
388
          "del "
389
          .. join(install_root, "install", "almd.sh")
×
390
          .. " "
×
391
          .. join(install_root, "install", "almd.bat")
×
392
          .. " "
×
393
          .. join( -- luacheck: ignore 121
×
394
            install_root,
395
            "install",
396
            "almd.ps1"
397
          )
398
          .. " >NUL 2>&1"
×
399
        )
400
      or (
×
401
        "rm -f "
402
        .. join(install_root, "install", "almd.sh")
×
403
        .. " "
×
404
        .. join(install_root, "install", "almd.bat")
×
405
        .. " "
×
406
        .. join(install_root, "install", "almd.ps1")
×
407
        .. " >/dev/null 2>&1"
×
408
      )
409
  )
410
  -- Copy new src and wrappers
411
  local src_cmd, sh_cmd, bat_cmd, ps1_cmd
412
  if is_windows then
×
413
    src_cmd = xcopy .. ' "' .. final_dir .. '\\src" "' .. join(install_root, "src") .. '" /E /I /Y /Q >NUL 2>&1'
×
414
    sh_cmd = cp
×
415
      .. ' "'
×
416
      .. final_dir
×
417
      .. '\\install\\almd.sh" "'
×
418
      .. join(install_root, "install", "almd.sh")
×
419
      .. '" /Y /Q >NUL 2>&1'
×
420
    bat_cmd = cp
×
421
      .. ' "'
×
422
      .. final_dir
×
423
      .. '\\install\\almd.bat" "'
×
424
      .. join(install_root, "install", "almd.bat")
×
425
      .. '" /Y /Q >NUL 2>&1'
×
426
    ps1_cmd = cp
×
427
      .. ' "'
×
428
      .. final_dir
×
429
      .. '\\install\\almd.ps1" "'
×
430
      .. join(install_root, "install", "almd.ps1")
×
431
      .. '" /Y /Q >NUL 2>&1'
×
432
  else
433
    src_cmd = xcopy .. ' "' .. final_dir .. '/src" "' .. join(install_root, "src") .. '" >/dev/null 2>&1'
×
434
    sh_cmd = cp
×
435
      .. ' "'
×
436
      .. final_dir
×
437
      .. '/install/almd.sh" "'
×
438
      .. join(install_root, "install", "almd.sh")
×
439
      .. '" >/dev/null 2>&1'
×
440
    bat_cmd = cp
×
441
      .. ' "'
×
442
      .. final_dir
×
443
      .. '/install/almd.bat" "'
×
444
      .. join(install_root, "install", "almd.bat")
×
445
      .. '" >/dev/null 2>&1'
×
446
    ps1_cmd = cp
×
447
      .. ' "'
×
448
      .. final_dir
×
449
      .. '/install/almd.ps1" "'
×
450
      .. join(install_root, "install", "almd.ps1")
×
451
      .. '" >/dev/null 2>&1'
×
452
  end
453

454
  local ok_shell = shell(src_cmd) and shell(sh_cmd) and shell(bat_cmd) and shell(ps1_cmd)
×
455
  if not ok_shell then
×
NEW
456
    log_error("Failed to copy new files during update.")
×
457
    -- Attempt rollback (best effort)
NEW
458
    M._rollback_update(install_root, backup_dir, { printer = printer, executor = executor })
×
NEW
459
    return false, nil, "Failed to copy new files during update. Attempted rollback."
×
460
  end
461

462
  -- Step 7: Validate new install
NEW
463
  log_output("Validating new installation...")
×
464
  local ok_new = io.open(join(install_root, "src", "main.lua"), "r")
×
465
  if not ok_new then
×
NEW
466
    log_error("Update failed: New version validation failed after copy.")
×
467
    -- Rollback: restore from backup
NEW
468
    M._rollback_update(install_root, backup_dir, { printer = printer, executor = executor })
×
NEW
469
    return false, nil, "Update failed: new version validation failed. Rolled back to previous version."
×
470
  else
471
    ok_new:close()
×
NEW
472
    log_output("New version validated.")
×
473
  end
474

475
  -- Step 8: Delete backup and temp work dir
NEW
476
  log_output("Cleaning up temporary files...")
×
477
  shell(rm .. " " .. backup_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
478
  shell(rm .. " " .. work_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
NEW
479
  return true, "Update completed successfully.", nil
×
480
end
481

482
--- Helper function for rolling back an update attempt.
483
-- @param install_root string The base install directory.
484
-- @param backup_dir string The directory containing the backup.
485
-- @param deps SelfDeps Dependencies { executor?, printer }.
486
function M._rollback_update(install_root, backup_dir, deps)
2✔
NEW
487
  local executor = deps.executor or os.execute
×
NEW
488
  local printer = deps.printer
×
489

NEW
490
  local function log_output(msg) if printer then printer.stdout(msg) end end
×
NEW
491
  local function log_error(msg) if printer then printer.stderr(msg) end end
×
492

NEW
493
  log_output("Attempting rollback from backup: " .. backup_dir)
×
494

NEW
495
  local is_windows = package.config:sub(1, 1) == "\\"
×
496
  local join = function(...) -- Re-define locally for clarity
NEW
497
    local sep = is_windows and "\\" or "/"
×
NEW
498
    local args = { ... }
×
NEW
499
    local out = args[1] or ""
×
NEW
500
    for i = 2, #args do
×
NEW
501
      if out:sub(-1) ~= sep and #out > 0 then out = out .. sep end
×
NEW
502
      local seg = args[i]
×
NEW
503
      if seg:sub(1, 1) == sep then seg = seg:sub(2) end
×
NEW
504
      out = out .. seg
×
505
    end
NEW
506
    return out
×
507
  end
508

NEW
509
  local function path_exists(path) return lfs.attributes(path, "mode") ~= nil end
×
NEW
510
  local function shell(cmd) return (executor(cmd) == 0 or executor(cmd) == true) end
×
511

NEW
512
  local cp = is_windows and "copy" or "cp -f"
×
NEW
513
  local xcopy = is_windows and "xcopy" or "cp -r"
×
514

515
  -- Restore src directory
NEW
516
  local src_restore = join(backup_dir, "src")
×
NEW
517
  if path_exists(src_restore) then
×
518
    -- Ensure target install root/src exists before xcopy-ing into it if needed
519
    -- Though usually we d only call rollback *after* deleting it, so restore needs to create it
520
    -- First remove any partially copied new src
NEW
521
    local rm = is_windows and "rmdir /s /q" or "rm -rf"
×
NEW
522
    shell(rm .. " " .. join(install_root, "src") .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
523
    -- Now copy backup src
NEW
524
    local restore_src_cmd = is_windows
×
NEW
525
      and xcopy .. ' "' .. src_restore .. '" "' .. join(install_root, "src") .. '" /E /I /Y /Q >NUL 2>&1'
×
NEW
526
      or xcopy .. ' "' .. src_restore .. '" "' .. join(install_root, "src") .. '" >/dev/null 2>&1'
×
NEW
527
    if not shell(restore_src_cmd) then
×
NEW
528
      log_error("Rollback failed: Could not restore src directory.")
×
529
    end
530
  else
NEW
531
    log_error("Rollback warning: Backup src directory not found: " .. src_restore)
×
532
  end
533

534
  -- Restore wrappers
NEW
535
  local wrappers_to_restore = { "almd.sh", "almd.bat", "almd.ps1" }
×
NEW
536
  for _, w_name in ipairs(wrappers_to_restore) do
×
NEW
537
    local backup_path = join(backup_dir, w_name)
×
NEW
538
    local install_path = join(install_root, "install", w_name)
×
NEW
539
    if path_exists(backup_path) then
×
NEW
540
      local restore_wrap_cmd = is_windows
×
NEW
541
        and cp .. ' "' .. backup_path .. '" "' .. install_path .. '" /Y /Q >NUL 2>&1'
×
NEW
542
        or cp .. ' "' .. backup_path .. '" "' .. install_path .. '" >/dev/null 2>&1'
×
NEW
543
      if not shell(restore_wrap_cmd) then
×
NEW
544
        log_error("Rollback failed: Could not restore wrapper: " .. w_name)
×
545
      end
546
    else
547
      -- If backup doesn't exist, attempt to remove potentially half-copied new one
NEW
548
      os.remove(install_path)
×
549
    end
550
  end
551
end
552

553
--- Prints usage/help information for the `self` command.
554
-- @return string Help text.
555
function M.help_info()
2✔
NEW
556
  return [[
×
557
Usage: almd self <command>
558

559
Manages the Almandine CLI installation itself.
560

561
Commands:
562
  uninstall   Uninstalls the Almandine CLI and removes associated files.
563
  update      Updates the Almandine CLI to the latest version from GitHub.
NEW
564
]]
×
565
end
566

567
return M
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc