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

nightconcept / almandine / 14720604808

29 Apr 2025 12:25AM UTC coverage: 83.233% (-6.9%) from 90.086%
14720604808

push

github

web-flow
feat: Self updating implemented (#5)

98 of 273 new or added lines in 2 files covered. (35.9%)

3 existing lines in 1 file now uncovered.

1246 of 1497 relevant lines covered (83.23%)

1.94 hits per line

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

10.98
/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✔
20
    -- Windows: use rmdir /s /q
21
    cmd = string.format('rmdir /s /q "%s"', path)
×
22
  else
23
    -- POSIX: use rm -rf
24
    cmd = string.format("rm -rf %q", path)
1✔
25
  end
26
  local ok = executor(cmd)
1✔
27
  if ok == 0 or ok == true then
1✔
28
    return true
1✔
29
  else
30
    return false, "Failed to remove directory: " .. path
×
31
  end
32
end
33

34
--- Remove wrapper scripts and Lua CLI folder.
35
-- @param executor [function?] Optional. Function to use for executing shell commands (default: os.execute)
36
-- @return [boolean, string?] True if successful, or false and error message
37
function M.uninstall_self(executor)
1✔
38
  local errors = {}
2✔
39
  -- Remove wrapper scripts
40
  local wrappers = { "install/almd.sh", "install/almd.bat", "install/almd.ps1" }
2✔
41
  for _, script in ipairs(wrappers) do
8✔
42
    local ok = os.remove(script)
6✔
43
    if not ok then
6✔
44
      table.insert(errors, "Failed to remove " .. script)
×
45
    end
46
  end
47
  -- Remove src/ folder (all CLI code)
48
  local ok, err = M.rmdir_recursive("src", executor)
2✔
49
  if not ok then
2✔
50
    table.insert(errors, "Failed to remove src/: " .. (err or "unknown error"))
1✔
51
  end
52
  if #errors > 0 then
2✔
53
    return false, table.concat(errors, "\n")
1✔
54
  end
55
  return true
1✔
56
end
57

58
--- Returns the absolute path to the install root (directory containing this script)
59
-- Works for both CLI and test runner
60
local function get_install_root()
61
  -- Try debug.getinfo first (works for most Lua runners)
NEW
62
  local info = debug.getinfo(1, "S")
×
NEW
63
  local source = info and info.source or ""
×
64
  local path
NEW
65
  if source:sub(1, 1) == "@" then
×
NEW
66
    path = source:sub(2)
×
NEW
67
  elseif arg and arg[0] then
×
NEW
68
    path = arg[0]
×
69
  end
NEW
70
  if not path then
×
NEW
71
    return "."
×
72
  end
73
  -- Remove trailing filename (e.g. /src/modules/self.lua)
NEW
74
  path = path:gsub("[\\/][^\\/]-$", "")
×
75
  -- If in /src/modules, move up two dirs to install root
NEW
76
  if path:match("[\\/]modules$") then
×
NEW
77
    path = path:gsub("[\\/]modules$", "")
×
NEW
78
    path = path:gsub("[\\/]src$", "")
×
79
  end
NEW
80
  return path
×
81
end
82

83
--- Atomically self-update the CLI from the latest GitHub release.
84
-- Downloads, extracts, backs up, and atomically replaces the install tree.
85
-- Only deletes backup if new version is fully extracted and ready.
86
-- @return [boolean, string?] True if successful, or false and error message
87
function M.self_update()
1✔
88
  local is_windows = package.config:sub(1, 1) == "\\"
×
NEW
89
  local install_root = get_install_root()
×
90
  local join = function(...) -- join paths with correct sep
NEW
91
    local sep = is_windows and "\\" or "/"
×
NEW
92
    local args = { ... }
×
NEW
93
    local out = args[1] or ""
×
NEW
94
    for i = 2, #args do
×
NEW
95
      if out:sub(-1) ~= sep then
×
NEW
96
        out = out .. sep
×
97
      end
NEW
98
      local seg = args[i]
×
NEW
99
      if seg:sub(1, 1) == sep then
×
NEW
100
        seg = seg:sub(2)
×
101
      end
NEW
102
      out = out .. seg
×
103
    end
NEW
104
    return out
×
105
  end
106

107
  -- Utility: check if file or directory exists
108
  local function path_exists(path)
NEW
109
    local f = io.open(path, "r")
×
NEW
110
    if f then
×
NEW
111
      f:close()
×
NEW
112
      return true
×
113
    end
NEW
114
    return false
×
115
  end
116

117
  -- Helper: download file (wget/curl)
118
  local downloader = require("utils.downloader")
×
119
  local function download(url, out)
120
    return downloader.download(url, out)
×
121
  end
122

123
  -- Helper: run shell command
124
  local function shell(cmd)
125
    local ok = os.execute(cmd)
×
126
    return ok == 0 or ok == true
×
127
  end
128

129
  -- Set up copy commands for cross-platform compatibility
130
  local cp, xcopy
NEW
131
  if is_windows then
×
NEW
132
    cp = "copy"
×
NEW
133
    xcopy = "xcopy"
×
134
  else
NEW
135
    cp = "cp -f"
×
NEW
136
    xcopy = "cp -r"
×
137
  end
138

139
  -- Step 1: Fetch latest tag
NEW
140
  local tag_url = "https://api.github.com/repos/nightconcept/almandine/tags?per_page=1"
×
141
  local zip_url, tag
142

NEW
143
  local tmp_dir = is_windows and os.getenv("TEMP") or "/tmp"
×
NEW
144
  local uuid = tostring(os.time()) .. tostring(math.random(10000, 99999))
×
NEW
145
  local work_dir = tmp_dir .. (is_windows and "\\almd_update_" or "/almd_update_") .. uuid
×
146
  local tag_file = work_dir .. (is_windows and "\\tag.json" or "/tag.json")
×
NEW
147
  os.execute(
×
NEW
148
    (is_windows and "mkdir " or "mkdir -p ") .. work_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")
×
149
  )
150
  local ok, err = download(tag_url, tag_file)
×
151
  if not ok then
×
152
    return false, "Failed to fetch latest release info: " .. (err or "unknown error")
×
153
  end
154
  local tag_data = io.open(tag_file, "r")
×
155
  if not tag_data then
×
156
    return false, "Could not read tag file"
×
157
  end
158
  local tag_json = tag_data:read("*a")
×
159
  tag_data:close()
×
160
  tag = tag_json:match('"name"%s*:%s*"([^"]+)"')
×
161
  if not tag then
×
162
    return false, "Could not parse latest tag from GitHub API"
×
163
  end
NEW
164
  zip_url = "https://github.com/nightconcept/almandine/archive/refs/tags/" .. tag .. ".zip"
×
165

166
  -- Step 2: Download zip
167
  local zip_path = work_dir .. (is_windows and "\\almd.zip" or "/almd.zip")
×
168
  ok, err = download(zip_url, zip_path)
×
169
  if not ok then
×
170
    return false, "Failed to download release zip: " .. (err or "unknown error")
×
171
  end
172
  -- Step 3: Extract zip
UNCOV
173
  local extract_dir = work_dir .. (is_windows and "\\extract" or "/extract")
×
NEW
174
  os.execute(
×
NEW
175
    (is_windows and "mkdir " or "mkdir -p ") .. extract_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")
×
176
  )
177
  local unzip_cmd = is_windows
×
178
      and (
×
179
        "powershell -Command \"Add-Type -A 'System.IO.Compression.FileSystem'; "
180
        .. "[IO.Compression.ZipFile]::ExtractToDirectory('"
×
181
        .. zip_path
×
182
        .. "', '"
×
183
        .. extract_dir
×
184
        .. "')\""
×
185
      ) -- luacheck: ignore 121
186
    or ("unzip -q -o '" .. zip_path .. "' -d '" .. extract_dir .. "'")
×
NEW
187
  if not shell(unzip_cmd .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")) then
×
188
    return false, "Failed to extract release zip"
×
189
  end
190
  -- Step 4: Find extracted folder
UNCOV
191
  local extracted = extract_dir .. (is_windows and ("\\almandine-" .. tag) or ("/almandine-" .. tag))
×
192
  local extracted_v = extract_dir .. (is_windows and ("\\almandine-v" .. tag) or ("/almandine-v" .. tag))
×
NEW
193
  local main_lua_path = extracted .. "/src/main.lua"
×
NEW
194
  local main_lua_v_path = extracted_v .. "/src/main.lua"
×
NEW
195
  local file_handle = io.open(main_lua_path) or io.open(main_lua_v_path)
×
196
  local final_dir
NEW
197
  if file_handle then
×
NEW
198
    file_handle:close()
×
NEW
199
    final_dir = io.open(main_lua_path) and extracted or extracted_v
×
200
  else
NEW
201
    final_dir = nil
×
202
  end
203
  if not final_dir then
×
204
    return false, "Could not find extracted CLI source in zip"
×
205
  end
206

207
  -- Step 5: Backup current install tree (wrapper scripts + src)
208
  local backup_dir = work_dir .. (is_windows and "\\backup" or "/backup")
×
NEW
209
  os.execute(
×
NEW
210
    (is_windows and "mkdir " or "mkdir -p ") .. backup_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1")
×
211
  )
212
  -- Copy wrappers (always files)
213
  local wrappers = {
×
NEW
214
    { from = join(install_root, "install", "almd.sh"), to = join(backup_dir, "almd.sh") },
×
NEW
215
    { from = join(install_root, "install", "almd.bat"), to = join(backup_dir, "almd.bat") },
×
NEW
216
    { from = join(install_root, "install", "almd.ps1"), to = join(backup_dir, "almd.ps1") },
×
217
  }
218
  for _, w in ipairs(wrappers) do
×
NEW
219
    if path_exists(w.from) then
×
NEW
220
      if is_windows then
×
NEW
221
        shell(cp .. ' "' .. w.from .. '" "' .. w.to .. '"' .. (is_windows and " /Y /Q >NUL 2>&1" or " >/dev/null 2>&1"))
×
222
      else
NEW
223
        shell(cp .. ' "' .. w.from .. '" "' .. w.to .. '"' .. (is_windows and "" or " >/dev/null 2>&1"))
×
224
      end
225
    end
226
  end
227
  -- Copy src directory
NEW
228
  local src_dir = join(install_root, "src")
×
NEW
229
  if path_exists(src_dir) then
×
NEW
230
    if is_windows then
×
NEW
231
      shell(
×
232
        xcopy
NEW
233
          .. ' "'
×
NEW
234
          .. src_dir
×
NEW
235
          .. '" "'
×
NEW
236
          .. join(backup_dir, "src")
×
NEW
237
          .. '"'
×
NEW
238
          .. (is_windows and " /E /I /Y /Q >NUL 2>&1" or " >/dev/null 2>&1")
×
239
      )
240
    else
NEW
241
      shell(
×
NEW
242
        xcopy .. ' "' .. src_dir .. '" "' .. join(backup_dir, "src") .. '"' .. (is_windows and "" or " >/dev/null 2>&1")
×
243
      )
244
    end
245
  end
246

247
  -- Windows: Check if files are locked (in use) before proceeding
NEW
248
  if is_windows then
×
249
    local function is_file_locked(path)
NEW
250
      local lock_handle = io.open(path, "r+")
×
NEW
251
      if lock_handle then
×
NEW
252
        lock_handle:close()
×
NEW
253
        return false
×
254
      end
NEW
255
      return true
×
256
    end
NEW
257
    local files_to_check = {
×
NEW
258
      join(install_root, "src", "main.lua"),
×
NEW
259
      join(install_root, "install", "almd.sh"),
×
NEW
260
      join(install_root, "install", "almd.bat"),
×
NEW
261
      join(install_root, "install", "almd.ps1"),
×
262
    }
263
    local function stage_update_for_next_run(staging_dir, extracted_dir)
264
      -- Remove any existing staged update
NEW
265
      if path_exists(staging_dir) then
×
NEW
266
        if is_windows then
×
NEW
267
          shell('rmdir /s /q "' .. staging_dir .. '" >NUL 2>&1')
×
268
        else
NEW
269
          shell('rm -rf "' .. staging_dir .. '" >/dev/null 2>&1')
×
270
        end
271
      end
272
      -- Copy extracted_dir to staging_dir
NEW
273
      if is_windows then
×
NEW
274
        shell('xcopy "' .. extracted_dir .. '" "' .. staging_dir .. '" /E /I /Y /Q >NUL 2>&1')
×
275
      else
NEW
276
        shell('cp -r "' .. extracted_dir .. '" "' .. staging_dir .. '" >/dev/null 2>&1')
×
277
      end
278
      -- Write marker file
NEW
279
      local marker = io.open(join(install_root, "install", "update_pending"), "w")
×
NEW
280
      if marker then
×
NEW
281
        marker:write(os.date("%Y-%m-%dT%H:%M:%S"))
×
NEW
282
        marker:close()
×
283
      end
284
    end
285
    local locked_file = nil
NEW
286
    for _, file in ipairs(files_to_check) do
×
NEW
287
      if is_file_locked(file) then
×
NEW
288
        locked_file = file
×
289
        break
290
      end
291
    end
NEW
292
    if locked_file then
×
NEW
293
      local staging_dir = join(install_root, "install", "next")
×
NEW
294
      stage_update_for_next_run(staging_dir, final_dir)
×
NEW
295
      return false, "Update staged for next run."
×
296
    end
297
  end
298

299
  -- Step 6: Replace install tree with new version
300
  -- Remove old src and wrappers
301
  local rm = is_windows and "rmdir /s /q" or "rm -rf"
×
NEW
302
  shell(rm .. " " .. join(install_root, "src") .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
303
  shell(
×
304
    is_windows
NEW
305
        and (
×
306
          "del "
NEW
307
          .. join(install_root, "install", "almd.sh")
×
NEW
308
          .. " "
×
NEW
309
          .. join(install_root, "install", "almd.bat")
×
NEW
310
          .. " "
×
NEW
311
          .. join( -- luacheck: ignore 121
×
312
            install_root,
313
            "install",
314
            "almd.ps1"
315
          )
NEW
316
          .. " >NUL 2>&1"
×
317
        )
NEW
318
      or (
×
319
        "rm -f "
NEW
320
        .. join(install_root, "install", "almd.sh")
×
NEW
321
        .. " "
×
NEW
322
        .. join(install_root, "install", "almd.bat")
×
NEW
323
        .. " "
×
NEW
324
        .. join(install_root, "install", "almd.ps1")
×
NEW
325
        .. " >/dev/null 2>&1"
×
326
      )
327
  )
328
  -- Copy new src and wrappers
329
  local src_cmd, sh_cmd, bat_cmd, ps1_cmd
NEW
330
  if is_windows then
×
NEW
331
    src_cmd = xcopy .. ' "' .. final_dir .. '\\src" "' .. join(install_root, "src") .. '" /E /I /Y /Q >NUL 2>&1'
×
NEW
332
    sh_cmd = cp
×
NEW
333
      .. ' "'
×
NEW
334
      .. final_dir
×
NEW
335
      .. '\\install\\almd.sh" "'
×
NEW
336
      .. join(install_root, "install", "almd.sh")
×
NEW
337
      .. '" /Y /Q >NUL 2>&1'
×
NEW
338
    bat_cmd = cp
×
NEW
339
      .. ' "'
×
NEW
340
      .. final_dir
×
NEW
341
      .. '\\install\\almd.bat" "'
×
NEW
342
      .. join(install_root, "install", "almd.bat")
×
NEW
343
      .. '" /Y /Q >NUL 2>&1'
×
NEW
344
    ps1_cmd = cp
×
NEW
345
      .. ' "'
×
NEW
346
      .. final_dir
×
NEW
347
      .. '\\install\\almd.ps1" "'
×
NEW
348
      .. join(install_root, "install", "almd.ps1")
×
NEW
349
      .. '" /Y /Q >NUL 2>&1'
×
350
  else
NEW
351
    src_cmd = xcopy .. ' "' .. final_dir .. '/src" "' .. join(install_root, "src") .. '" >/dev/null 2>&1'
×
NEW
352
    sh_cmd = cp
×
NEW
353
      .. ' "'
×
NEW
354
      .. final_dir
×
NEW
355
      .. '/install/almd.sh" "'
×
NEW
356
      .. join(install_root, "install", "almd.sh")
×
NEW
357
      .. '" >/dev/null 2>&1'
×
NEW
358
    bat_cmd = cp
×
NEW
359
      .. ' "'
×
NEW
360
      .. final_dir
×
NEW
361
      .. '/install/almd.bat" "'
×
NEW
362
      .. join(install_root, "install", "almd.bat")
×
NEW
363
      .. '" >/dev/null 2>&1'
×
NEW
364
    ps1_cmd = cp
×
NEW
365
      .. ' "'
×
NEW
366
      .. final_dir
×
NEW
367
      .. '/install/almd.ps1" "'
×
NEW
368
      .. join(install_root, "install", "almd.ps1")
×
NEW
369
      .. '" >/dev/null 2>&1'
×
370
  end
371

NEW
372
  local ok_shell = shell(src_cmd) and shell(sh_cmd) and shell(bat_cmd) and shell(ps1_cmd)
×
NEW
373
  if not ok_shell then
×
NEW
374
    return false,
×
375
      "Failed to copy new files during update. Please check permissions and available disk space, and retry."
376
  end
377

378
  -- Step 7: Validate new install
NEW
379
  local ok_new = io.open(join(install_root, "src", "main.lua"), "r")
×
380
  if not ok_new then
×
381
    -- Rollback: restore from backup
NEW
382
    if is_windows then
×
NEW
383
      local src_restore = join(backup_dir, "src")
×
NEW
384
      if path_exists(src_restore) then
×
NEW
385
        shell(xcopy .. ' "' .. src_restore .. '" "' .. join(install_root, "src") .. '" /E /I /Y /Q >NUL 2>&1')
×
386
      end
NEW
387
      for _, w in ipairs(wrappers) do
×
NEW
388
        if path_exists(w.from) then
×
NEW
389
          shell(cp .. ' "' .. join(backup_dir, w.to:match("([^/\\]+)$")) .. '" "' .. w.to .. '" /Y /Q >NUL 2>&1')
×
390
        end
391
      end
392
    else
NEW
393
      local src_restore = join(backup_dir, "src")
×
NEW
394
      if path_exists(src_restore) then
×
NEW
395
        shell(xcopy .. ' "' .. src_restore .. '" "' .. join(install_root, "src") .. '" >/dev/null 2>&1')
×
396
      end
397
    end
UNCOV
398
    return false, "Update failed: new version not found, rolled back to previous version."
×
399
  else
NEW
400
    ok_new:close()
×
401
  end
402

403
  -- Step 8: Delete backup and temp work dir
NEW
404
  shell(rm .. " " .. backup_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
NEW
405
  shell(rm .. " " .. work_dir .. (is_windows and " >NUL 2>&1" or " >/dev/null 2>&1"))
×
NEW
406
  return true, "Update staged for next run."
×
407
end
408

409
--- Prints usage/help information for the `self` command.
410
-- @param print_fn [function] Optional. Function to use for printing (default: print)
411
function M.help_info(print_fn)
1✔
412
  print_fn = print_fn or print
1✔
413
  print_fn("Usage: almd self uninstall")
1✔
414
  print_fn("Uninstalls the Almandine CLI and removes all associated files.")
1✔
415
end
416

417
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