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

nightconcept / almandine / 14742961017

29 Apr 2025 11:00PM UTC coverage: 80.336% (-1.7%) from 82.069%
14742961017

push

github

web-flow
fix: Installs on MacOS and uninstalls (#11)

9 of 35 new or added lines in 3 files covered. (25.71%)

4 existing lines in 2 files now uncovered.

1528 of 1902 relevant lines covered (80.34%)

2.3 hits per line

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

6.13
/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
local M = {}
1✔
10

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

33
--- Remove wrapper scripts and Lua CLI folder.
34
-- @param executor [function?] Optional. Function to use for executing shell commands (default: os.execute)
35
-- @return [boolean, string?] True if successful, or false and error message
36
function M.uninstall_self(executor)
1✔
37
  -- Remove wrapper scripts from known install locations
NEW
38
  local is_windows = package.config:sub(1, 1) == "\\"
×
39
  local install_prefixes
NEW
40
  local wrappers = { "almd", "almd.sh", "almd.bat", "almd.ps1" }
×
NEW
41
  if is_windows then
×
NEW
42
    local localappdata = os.getenv("LOCALAPPDATA") or ""
×
NEW
43
    local userprofile = os.getenv("USERPROFILE") or ""
×
NEW
44
    install_prefixes = {
×
NEW
45
      localappdata .. "\\almd",
×
NEW
46
      localappdata .. "\\Programs\\almd",
×
NEW
47
      userprofile .. "\\.almd",
×
NEW
48
      userprofile .. "\\AppData\\Local\\almd",
×
49
      "C:\\Program Files\\almd",
50
      "C:\\almd",
51
    }
52
  else
NEW
53
    install_prefixes = {
×
NEW
54
      os.getenv("HOME") .. "/.local/bin",
×
NEW
55
      os.getenv("HOME") .. "/.almd/install",
×
NEW
56
      os.getenv("HOME") .. "/.almd",
×
57
      "/usr/local/bin",
58
    }
59
  end
NEW
60
  for _, prefix in ipairs(install_prefixes) do
×
NEW
61
    for _, script in ipairs(wrappers) do
×
NEW
62
      local sep = is_windows and "\\" or "/"
×
NEW
63
      local path = prefix .. sep .. script
×
NEW
64
      os.remove(path)
×
65
    end
66
  end
67
  -- Remove src/ folder from main install dir
68
  local cli_dir
NEW
69
  if is_windows then
×
NEW
70
    cli_dir = (os.getenv("LOCALAPPDATA") or "") .. "\\almd\\src"
×
NEW
71
    if not require("lfs").attributes(cli_dir) then
×
NEW
72
      cli_dir = (os.getenv("USERPROFILE") or "") .. "\\.almd\\src"
×
73
    end
74
  else
NEW
75
    cli_dir = os.getenv("HOME") .. "/.almd/src"
×
76
  end
NEW
77
  if require("lfs").attributes(cli_dir) then
×
NEW
78
    M.rmdir_recursive(cli_dir, executor)
×
79
  end
UNCOV
80
  return true
×
81
end
82

83
--- Returns the absolute path to the install root (directory containing this script)
84
-- Works for both CLI and test runner
85
local function get_install_root()
86
  -- Try debug.getinfo first (works for most Lua runners)
87
  local info = debug.getinfo(1, "S")
×
88
  local source = info and info.source or ""
×
89
  local path
90
  if source:sub(1, 1) == "@" then
×
91
    path = source:sub(2)
×
92
  elseif arg and arg[0] then
×
93
    path = arg[0]
×
94
  end
95
  if not path then
×
96
    return "."
×
97
  end
98
  -- Remove trailing filename (e.g. /src/modules/self.lua)
99
  path = path:gsub("[\\/][^\\/]-$", "")
×
100
  -- If in /src/modules, move up two dirs to install root
101
  if path:match("[\\/]modules$") then
×
102
    path = path:gsub("[\\/]modules$", "")
×
103
    path = path:gsub("[\\/]src$", "")
×
104
  end
105
  return path
×
106
end
107

108
--- Atomically self-update the CLI from the latest GitHub release.
109
-- Downloads, extracts, backs up, and atomically replaces the install tree.
110
-- Only deletes backup if new version is fully extracted and ready.
111
-- @return [boolean, string?] True if successful, or false and error message
112
function M.self_update()
1✔
113
  local is_windows = package.config:sub(1, 1) == "\\"
×
114
  local install_root = get_install_root()
×
115
  local join = function(...) -- join paths with correct sep
116
    local sep = is_windows and "\\" or "/"
×
117
    local args = { ... }
×
118
    local out = args[1] or ""
×
119
    for i = 2, #args do
×
120
      if out:sub(-1) ~= sep then
×
121
        out = out .. sep
×
122
      end
123
      local seg = args[i]
×
124
      if seg:sub(1, 1) == sep then
×
125
        seg = seg:sub(2)
×
126
      end
127
      out = out .. seg
×
128
    end
129
    return out
×
130
  end
131

132
  -- Utility: check if file or directory exists
133
  local function path_exists(path)
134
    local f = io.open(path, "r")
×
135
    if f then
×
136
      f:close()
×
137
      return true
×
138
    end
139
    return false
×
140
  end
141

142
  -- Helper: download file (wget/curl)
143
  local downloader = require("utils.downloader")
×
144
  local function download(url, out)
145
    return downloader.download(url, out)
×
146
  end
147

148
  -- Helper: run shell command
149
  local function shell(cmd)
150
    local ok = os.execute(cmd)
×
151
    return ok == 0 or ok == true
×
152
  end
153

154
  -- Set up copy commands for cross-platform compatibility
155
  local cp, xcopy
156
  if is_windows then
×
157
    cp = "copy"
×
158
    xcopy = "xcopy"
×
159
  else
160
    cp = "cp -f"
×
161
    xcopy = "cp -r"
×
162
  end
163

164
  -- Step 1: Fetch latest tag
165
  local tag_url = "https://api.github.com/repos/nightconcept/almandine/tags?per_page=1"
×
166
  local zip_url, tag
167

168
  local tmp_dir = is_windows and os.getenv("TEMP") or "/tmp"
×
169
  local uuid = tostring(os.time()) .. tostring(math.random(10000, 99999))
×
170
  local work_dir = tmp_dir .. (is_windows and "\\almd_update_" or "/almd_update_") .. uuid
×
171
  local tag_file = work_dir .. (is_windows and "\\tag.json" or "/tag.json")
×
172
  os.execute(
×
173
    (is_windows and "mkdir " or "mkdir -p ") .. work_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")
×
174
  )
175
  local ok, err = download(tag_url, tag_file)
×
176
  if not ok then
×
177
    return false, "Failed to fetch latest release info: " .. (err or "unknown error")
×
178
  end
179
  local tag_data = io.open(tag_file, "r")
×
180
  if not tag_data then
×
181
    return false, "Could not read tag file"
×
182
  end
183
  local tag_json = tag_data:read("*a")
×
184
  tag_data:close()
×
185
  tag = tag_json:match('"name"%s*:%s*"([^"]+)"')
×
186
  if not tag then
×
187
    return false, "Could not parse latest tag from GitHub API"
×
188
  end
189
  zip_url = "https://github.com/nightconcept/almandine/archive/refs/tags/" .. tag .. ".zip"
×
190

191
  -- Step 2: Download zip
192
  local zip_path = work_dir .. (is_windows and "\\almd.zip" or "/almd.zip")
×
193
  ok, err = download(zip_url, zip_path)
×
194
  if not ok then
×
195
    return false, "Failed to download release zip: " .. (err or "unknown error")
×
196
  end
197
  -- Step 3: Extract zip
198
  local extract_dir = work_dir .. (is_windows and "\\extract" or "/extract")
×
199
  os.execute(
×
200
    (is_windows and "mkdir " or "mkdir -p ") .. extract_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")
×
201
  )
202
  local unzip_cmd = is_windows
×
203
      and (
×
204
        "powershell -Command \"Add-Type -A 'System.IO.Compression.FileSystem'; "
205
        .. "[IO.Compression.ZipFile]::ExtractToDirectory('"
×
206
        .. zip_path
×
207
        .. "', '"
×
208
        .. extract_dir
×
209
        .. "')\""
×
210
      ) -- luacheck: ignore 121
211
    or ("unzip -q -o '" .. zip_path .. "' -d '" .. extract_dir .. "'")
×
212
  if not shell(unzip_cmd .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")) then
×
213
    return false, "Failed to extract release zip"
×
214
  end
215
  -- Step 4: Find extracted folder
216
  local extracted = extract_dir .. (is_windows and ("\\almandine-" .. tag) or ("/almandine-" .. tag))
×
217
  local extracted_v = extract_dir .. (is_windows and ("\\almandine-v" .. tag) or ("/almandine-v" .. tag))
×
218
  local main_lua_path = extracted .. "/src/main.lua"
×
219
  local main_lua_v_path = extracted_v .. "/src/main.lua"
×
220
  local file_handle = io.open(main_lua_path) or io.open(main_lua_v_path)
×
221
  local final_dir
222
  if file_handle then
×
223
    file_handle:close()
×
224
    final_dir = io.open(main_lua_path) and extracted or extracted_v
×
225
  else
226
    final_dir = nil
×
227
  end
228
  if not final_dir then
×
229
    return false, "Could not find extracted CLI source in zip"
×
230
  end
231

232
  -- Step 5: Backup current install tree (wrapper scripts + src)
233
  local backup_dir = work_dir .. (is_windows and "\\backup" or "/backup")
×
234
  os.execute(
×
235
    (is_windows and "mkdir " or "mkdir -p ") .. backup_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")
×
236
  )
237
  -- Copy wrappers (always files)
238
  local wrappers = {
×
239
    { from = join(install_root, "install", "almd.sh"), to = join(backup_dir, "almd.sh") },
×
240
    { from = join(install_root, "install", "almd.bat"), to = join(backup_dir, "almd.bat") },
×
241
    { from = join(install_root, "install", "almd.ps1"), to = join(backup_dir, "almd.ps1") },
×
242
  }
243
  for _, w in ipairs(wrappers) do
×
244
    if path_exists(w.from) then
×
245
      if is_windows then
×
246
        shell(cp .. ' "' .. w.from .. '" "' .. w.to .. '"' .. (is_windows and " /Y /Q >NUL 2>&1" or " >/dev/null 2>&1"))
×
247
      else
248
        shell(cp .. ' "' .. w.from .. '" "' .. w.to .. '"' .. (is_windows and "" or " >/dev/null 2>&1"))
×
249
      end
250
    end
251
  end
252
  -- Copy src directory
253
  local src_dir = join(install_root, "src")
×
254
  if path_exists(src_dir) then
×
255
    if is_windows then
×
256
      shell(
×
257
        xcopy
258
          .. ' "'
×
259
          .. src_dir
×
260
          .. '" "'
×
261
          .. join(backup_dir, "src")
×
262
          .. '"'
×
263
          .. (is_windows and " /E /I /Y /Q >NUL 2>&1" or " >/dev/null 2>&1")
×
264
      )
265
    else
266
      shell(
×
267
        xcopy .. ' "' .. src_dir .. '" "' .. join(backup_dir, "src") .. '"' .. (is_windows and "" or " >/dev/null 2>&1")
×
268
      )
269
    end
270
  end
271

272
  -- Windows: Check if files are locked (in use) before proceeding
273
  if is_windows then
×
274
    local function is_file_locked(path)
275
      local lock_handle = io.open(path, "r+")
×
276
      if lock_handle then
×
277
        lock_handle:close()
×
278
        return false
×
279
      end
280
      return true
×
281
    end
282
    local files_to_check = {
×
283
      join(install_root, "src", "main.lua"),
×
284
      join(install_root, "install", "almd.sh"),
×
285
      join(install_root, "install", "almd.bat"),
×
286
      join(install_root, "install", "almd.ps1"),
×
287
    }
288
    local function stage_update_for_next_run(staging_dir, extracted_dir)
289
      -- Remove any existing staged update
290
      if path_exists(staging_dir) then
×
291
        if is_windows then
×
292
          shell('rmdir /s /q "' .. staging_dir .. '" >NUL 2>&1')
×
293
        else
294
          shell('rm -rf "' .. staging_dir .. '" >/dev/null 2>&1')
×
295
        end
296
      end
297
      -- Copy extracted_dir to staging_dir
298
      if is_windows then
×
299
        shell('xcopy "' .. extracted_dir .. '" "' .. staging_dir .. '" /E /I /Y /Q >NUL 2>&1')
×
300
      else
301
        shell('cp -r "' .. extracted_dir .. '" "' .. staging_dir .. '" >/dev/null 2>&1')
×
302
      end
303
      -- Write marker file
304
      local marker = io.open(join(install_root, "install", "update_pending"), "w")
×
305
      if marker then
×
306
        marker:write(os.date("%Y-%m-%dT%H:%M:%S"))
×
307
        marker:close()
×
308
      end
309
    end
310
    local locked_file = nil
311
    for _, file in ipairs(files_to_check) do
×
312
      if is_file_locked(file) then
×
313
        locked_file = file
×
314
        break
315
      end
316
    end
317
    if locked_file then
×
318
      local staging_dir = join(install_root, "install", "next")
×
319
      stage_update_for_next_run(staging_dir, final_dir)
×
320
      return false, "Update staged for next run."
×
321
    end
322
  end
323

324
  -- Step 6: Replace install tree with new version
325
  -- Remove old src and wrappers
326
  local rm = is_windows and "rmdir /s /q" or "rm -rf"
×
327
  shell(rm .. " " .. join(install_root, "src") .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
328
  shell(
×
329
    is_windows
330
        and (
×
331
          "del "
332
          .. join(install_root, "install", "almd.sh")
×
333
          .. " "
×
334
          .. join(install_root, "install", "almd.bat")
×
335
          .. " "
×
336
          .. join( -- luacheck: ignore 121
×
337
            install_root,
338
            "install",
339
            "almd.ps1"
340
          )
341
          .. " >NUL 2>&1"
×
342
        )
343
      or (
×
344
        "rm -f "
345
        .. join(install_root, "install", "almd.sh")
×
346
        .. " "
×
347
        .. join(install_root, "install", "almd.bat")
×
348
        .. " "
×
349
        .. join(install_root, "install", "almd.ps1")
×
350
        .. " >/dev/null 2>&1"
×
351
      )
352
  )
353
  -- Copy new src and wrappers
354
  local src_cmd, sh_cmd, bat_cmd, ps1_cmd
355
  if is_windows then
×
356
    src_cmd = xcopy .. ' "' .. final_dir .. '\\src" "' .. join(install_root, "src") .. '" /E /I /Y /Q >NUL 2>&1'
×
357
    sh_cmd = cp
×
358
      .. ' "'
×
359
      .. final_dir
×
360
      .. '\\install\\almd.sh" "'
×
361
      .. join(install_root, "install", "almd.sh")
×
362
      .. '" /Y /Q >NUL 2>&1'
×
363
    bat_cmd = cp
×
364
      .. ' "'
×
365
      .. final_dir
×
366
      .. '\\install\\almd.bat" "'
×
367
      .. join(install_root, "install", "almd.bat")
×
368
      .. '" /Y /Q >NUL 2>&1'
×
369
    ps1_cmd = cp
×
370
      .. ' "'
×
371
      .. final_dir
×
372
      .. '\\install\\almd.ps1" "'
×
373
      .. join(install_root, "install", "almd.ps1")
×
374
      .. '" /Y /Q >NUL 2>&1'
×
375
  else
376
    src_cmd = xcopy .. ' "' .. final_dir .. '/src" "' .. join(install_root, "src") .. '" >/dev/null 2>&1'
×
377
    sh_cmd = cp
×
378
      .. ' "'
×
379
      .. final_dir
×
380
      .. '/install/almd.sh" "'
×
381
      .. join(install_root, "install", "almd.sh")
×
382
      .. '" >/dev/null 2>&1'
×
383
    bat_cmd = cp
×
384
      .. ' "'
×
385
      .. final_dir
×
386
      .. '/install/almd.bat" "'
×
387
      .. join(install_root, "install", "almd.bat")
×
388
      .. '" >/dev/null 2>&1'
×
389
    ps1_cmd = cp
×
390
      .. ' "'
×
391
      .. final_dir
×
392
      .. '/install/almd.ps1" "'
×
393
      .. join(install_root, "install", "almd.ps1")
×
394
      .. '" >/dev/null 2>&1'
×
395
  end
396

397
  local ok_shell = shell(src_cmd) and shell(sh_cmd) and shell(bat_cmd) and shell(ps1_cmd)
×
398
  if not ok_shell then
×
399
    return false,
×
400
      "Failed to copy new files during update. Please check permissions and available disk space, and retry."
401
  end
402

403
  -- Step 7: Validate new install
404
  local ok_new = io.open(join(install_root, "src", "main.lua"), "r")
×
405
  if not ok_new then
×
406
    -- Rollback: restore from backup
407
    if is_windows then
×
408
      local src_restore = join(backup_dir, "src")
×
409
      if path_exists(src_restore) then
×
410
        shell(xcopy .. ' "' .. src_restore .. '" "' .. join(install_root, "src") .. '" /E /I /Y /Q >NUL 2>&1')
×
411
      end
412
      for _, w in ipairs(wrappers) do
×
413
        if path_exists(w.from) then
×
414
          shell(cp .. ' "' .. join(backup_dir, w.to:match("([^/\\]+)$")) .. '" "' .. w.to .. '" /Y /Q >NUL 2>&1')
×
415
        end
416
      end
417
    else
418
      local src_restore = join(backup_dir, "src")
×
419
      if path_exists(src_restore) then
×
420
        shell(xcopy .. ' "' .. src_restore .. '" "' .. join(install_root, "src") .. '" >/dev/null 2>&1')
×
421
      end
422
    end
423
    return false, "Update failed: new version not found, rolled back to previous version."
×
424
  else
425
    ok_new:close()
×
426
  end
427

428
  -- Step 8: Delete backup and temp work dir
429
  shell(rm .. " " .. backup_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
430
  shell(rm .. " " .. work_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
431
  return true, "Update staged for next run."
×
432
end
433

434
--- Prints usage/help information for the `self` command.
435
-- @param print_fn [function] Optional. Function to use for printing (default: print)
436
function M.help_info(print_fn)
1✔
437
  print_fn = print_fn or print
1✔
438
  print_fn("Usage: almd self uninstall")
1✔
439
  print_fn("Uninstalls the Almandine CLI and removes all associated files.")
1✔
440
end
441

442
return M
1✔
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