diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/lpm.c | 575 | ||||
-rw-r--r-- | src/lpm.lua | 1612 |
2 files changed, 2187 insertions, 0 deletions
diff --git a/src/lpm.c b/src/lpm.c new file mode 100644 index 0000000..760c372 --- /dev/null +++ b/src/lpm.c @@ -0,0 +1,575 @@ +#include <git2.h> +#include <string.h> +#include <stdio.h> +#include <errno.h> +#include <lua.h> +#include <lauxlib.h> +#include <ctype.h> +#include <lualib.h> +#include <dirent.h> +#include <unistd.h> +#include <archive.h> +#include <archive_entry.h> + +#include <sys/stat.h> +#include <git2.h> +#include <openssl/evp.h> +#include <curl/curl.h> + +#ifdef _WIN32 + #include <direct.h> + #include <winsock2.h> + #include <windows.h> + #include <fileapi.h> +#else + #define MAX_PATH PATH_MAX +#endif + +static char hex_digits[] = "0123456789abcdef"; +static int lpm_hash(lua_State* L) { + size_t len; + const char* data = luaL_checklstring(L, 1, &len); + const char* type = luaL_optstring(L, 2, "string"); + unsigned char buffer[EVP_MAX_MD_SIZE]; + EVP_MD_CTX* c = EVP_MD_CTX_new(); + EVP_MD_CTX_init(c); + EVP_DigestInit_ex(c, EVP_sha256(), NULL); + if (strcmp(type, "file") == 0) { + FILE* file = fopen(data, "rb"); + if (!file) { + EVP_DigestFinal(c, buffer, NULL); + return luaL_error(L, "can't open %s", data); + } + while (1) { + unsigned char chunk[4096]; + size_t bytes = fread(chunk, 1, sizeof(chunk), file); + EVP_DigestUpdate(c, chunk, bytes); + if (bytes < 4096) + break; + } + fclose(file); + } else { + EVP_DigestUpdate(c, data, len); + } + int digest_length; + EVP_DigestFinal(c, buffer, &digest_length); + EVP_MD_CTX_free(c); + char hex_buffer[EVP_MAX_MD_SIZE * 2]; + for (size_t i = 0; i < digest_length; ++i) { + hex_buffer[i*2+0] = hex_digits[buffer[i] >> 4]; + hex_buffer[i*2+1] = hex_digits[buffer[i] & 0xF]; + } + lua_pushlstring(L, hex_buffer, digest_length * 2); + hex_buffer[digest_length*2]=0; + return 1; +} + +int lpm_symlink(lua_State* L) { + #ifndef _WIN32 + if (symlink(luaL_checkstring(L, 1), luaL_checkstring(L, 2))) + return luaL_error(L, "can't create symlink %s: %s", luaL_checkstring(L, 2), strerror(errno)); + return 0; + #else + return luaL_error(L, "can't create symbolic link %s: your operating system sucks", luaL_checkstring(L, 2)); + #endif +} + +int lpm_chmod(lua_State* L) { + if (chmod(luaL_checkstring(L, 1), luaL_checkinteger(L, 2))) + return luaL_error(L, "can't chmod %s: %s", luaL_checkstring(L, 1), strerror(errno)); + return 0; +} + +/** BEGIN STOLEN LITE CODE **/ +#if _WIN32 +static LPWSTR utfconv_utf8towc(const char *str) { + LPWSTR output; + int len = MultiByteToWideChar(CP_UTF8, 0, str, -1, NULL, 0); + if (len == 0) + return NULL; + output = (LPWSTR) malloc(sizeof(WCHAR) * len); + if (output == NULL) + return NULL; + len = MultiByteToWideChar(CP_UTF8, 0, str, -1, output, len); + if (len == 0) { + free(output); + return NULL; + } + return output; +} + +static char *utfconv_wctoutf8(LPCWSTR str) { + char *output; + int len = WideCharToMultiByte(CP_UTF8, 0, str, -1, NULL, 0, NULL, NULL); + if (len == 0) + return NULL; + output = (char *) malloc(sizeof(char) * len); + if (output == NULL) + return NULL; + len = WideCharToMultiByte(CP_UTF8, 0, str, -1, output, len, NULL, NULL); + if (len == 0) { + free(output); + return NULL; + } + return output; +} +#endif + +static int lpm_ls(lua_State *L) { + const char *path = luaL_checkstring(L, 1); + +#ifdef _WIN32 + lua_settop(L, 1); + lua_pushstring(L, path[0] == 0 || strchr("\\/", path[strlen(path) - 1]) != NULL ? "*" : "/*"); + lua_concat(L, 2); + path = lua_tostring(L, -1); + + LPWSTR wpath = utfconv_utf8towc(path); + if (wpath == NULL) + return luaL_error(L, "can't ls %s: invalid utf8 character conversion", path); + + WIN32_FIND_DATAW fd; + HANDLE find_handle = FindFirstFileExW(wpath, FindExInfoBasic, &fd, FindExSearchNameMatch, NULL, 0); + free(wpath); + if (find_handle == INVALID_HANDLE_VALUE) + return luaL_error(L, "can't ls %s: %d", path, GetLastError()); + char mbpath[MAX_PATH * 4]; // utf-8 spans 4 bytes at most + int len, i = 1; + lua_newtable(L); + + do + { + if (wcscmp(fd.cFileName, L".") == 0) { continue; } + if (wcscmp(fd.cFileName, L"..") == 0) { continue; } + + len = WideCharToMultiByte(CP_UTF8, 0, fd.cFileName, -1, mbpath, MAX_PATH * 4, NULL, NULL); + if (len == 0) { break; } + lua_pushlstring(L, mbpath, len - 1); // len includes \0 + lua_rawseti(L, -2, i++); + } while (FindNextFileW(find_handle, &fd)); + + int err = GetLastError(); + FindClose(find_handle); + if (err != ERROR_NO_MORE_FILES) + return luaL_error(L, "can't ls %s: %d", path, GetLastError()); + return 1; +#else + DIR *dir = opendir(path); + if (!dir) + return luaL_error(L, "can't ls %s: %d", path, strerror(errno)); + lua_newtable(L); + int i = 1; + struct dirent *entry; + while ( (entry = readdir(dir)) ) { + if (strcmp(entry->d_name, "." ) == 0) { continue; } + if (strcmp(entry->d_name, "..") == 0) { continue; } + lua_pushstring(L, entry->d_name); + lua_rawseti(L, -2, i); + i++; + } + closedir(dir); + return 1; +#endif +} + +static int lpm_rmdir(lua_State *L) { + const char *path = luaL_checkstring(L, 1); +#ifdef _WIN32 + LPWSTR wpath = utfconv_utf8towc(path); + int deleted = RemoveDirectoryW(wpath); + free(wpath); + if (!deleted) + return luaL_error(L, "can't rmdir %s: %d", path, GetLastError()); +#else + if (remove(path)) + return luaL_error(L, "can't rmdir %s: %s", path, strerror(errno)); +#endif + return 0; +} + +static int lpm_mkdir(lua_State *L) { + const char *path = luaL_checkstring(L, 1); +#ifdef _WIN32 + LPWSTR wpath = utfconv_utf8towc(path); + if (wpath == NULL) + return luaL_error(L, "can't mkdir %s: invalid utf8 character conversion", path); + int err = _wmkdir(wpath); + free(wpath); +#else + int err = mkdir(path, S_IRUSR|S_IWUSR|S_IXUSR|S_IRGRP|S_IXGRP|S_IROTH|S_IXOTH); +#endif + if (err < 0) + return luaL_error(L, "can't mkdir %s: %s", path, strerror(errno)); + return 0; +} + +static int lpm_stat(lua_State *L) { + const char *path = luaL_checkstring(L, 1); + lua_newtable(L); +#ifdef _WIN32 + #define realpath(x, y) _wfullpath(y, x, MAX_PATH) + struct _stat s; + LPWSTR wpath = utfconv_utf8towc(path); + if (wpath == NULL) + return luaL_error(L, "can't stat %s: invalid utf8 character conversion", path); + int err = _wstat(wpath, &s); + LPWSTR wfullpath = realpath(wpath, NULL); + free(wpath); + if (!wfullpath) return 0; + char *abs_path = utfconv_wctoutf8(wfullpath); + free(wfullpath); +#else + struct stat s; + int err = lstat(path, &s); + char *abs_path = realpath(path, NULL); +#endif + if (err || !abs_path) { + lua_pushnil(L); + lua_pushstring(L, strerror(errno)); + return 2; + } + lua_pushstring(L, abs_path); lua_setfield(L, -2, "abs_path"); + lua_pushvalue(L, 1); lua_setfield(L, -2, "path"); + +#if __linux__ + if (S_ISLNK(s.st_mode)) { + char buffer[PATH_MAX]; + ssize_t len = readlink(path, buffer, sizeof(buffer)); + if (len < 0) + return 0; + lua_pushlstring(L, buffer, len); + } else + lua_pushnil(L); + lua_setfield(L, -2, "symlink"); + if (S_ISLNK(s.st_mode)) + err = stat(path, &s); + if (err) + return 1; +#endif + lua_pushinteger(L, s.st_mtime); lua_setfield(L, -2, "modified"); + lua_pushinteger(L, s.st_size); lua_setfield(L, -2, "size"); + if (S_ISREG(s.st_mode)) { + lua_pushstring(L, "file"); + } else if (S_ISDIR(s.st_mode)) { + lua_pushstring(L, "dir"); + } else { + lua_pushnil(L); + } + lua_setfield(L, -2, "type"); + return 1; +} +/** END STOLEN LITE CODE **/ + +static const char* git_error_last_string() { + const git_error* last_error = git_error_last(); + return last_error->message; +} + +static int git_get_id(git_oid* commit_id, git_repository* repository, const char* name) { + int length = strlen(name); + int is_hex = length == 40; + for (int i = 0; is_hex && i < length; ++i) + is_hex = isxdigit(name[i]); + if (!is_hex) + return git_reference_name_to_id(commit_id, repository, name); + return git_oid_fromstr(commit_id, name); +} + +static git_repository* luaL_checkgitrepo(lua_State* L, int index) { + const char* path = luaL_checkstring(L, index); + git_repository* repository; + if (git_repository_open(&repository, path)) + return (void*)(long long)luaL_error(L, "git open error: %s", git_error_last_string()); + return repository; +} + + +static git_commit* git_retrieve_commit(git_repository* repository, const char* commit_name) { + git_oid commit_id; + git_commit* commit; + if (git_get_id(&commit_id, repository, commit_name) || git_commit_lookup(&commit, repository, &commit_id)) + return NULL; + return commit; +} + + +static int lpm_reset(lua_State* L) { + git_repository* repository = luaL_checkgitrepo(L, 1); + const char* commit_name = luaL_checkstring(L, 2); + const char* type = luaL_checkstring(L, 3); + git_commit* commit = git_retrieve_commit(repository, commit_name); + if (!commit) { + git_repository_free(repository); + return luaL_error(L, "git retrieve commit error: %s", git_error_last_string()); + } + git_reset_t reset_type = GIT_RESET_SOFT; + if (strcmp(type, "mixed") == 0) + reset_type = GIT_RESET_MIXED; + else if (strcmp(type, "hard") == 0) + reset_type = GIT_RESET_HARD; + int result = git_reset(repository, (git_object*)commit, reset_type, NULL); + git_commit_free(commit); + git_repository_free(repository); + if (result) + return luaL_error(L, "git reset error: %s", git_error_last_string()); + return 0; +} + + +static int lpm_init(lua_State* L) { + const char* path = luaL_checkstring(L, 1); + const char* url = luaL_checkstring(L, 2); + git_repository* repository; + if (git_repository_init(&repository, path, 0)) + return luaL_error(L, "git init error: %s", git_error_last_string()); + git_remote* remote; + if (git_remote_create(&remote, repository, "origin", url)) { + git_repository_free(repository); + return luaL_error(L, "git remote add error: %s", git_error_last_string()); + } + git_remote_free(remote); + git_repository_free(repository); + return 0; +} + + +static int lpm_fetch(lua_State* L) { + git_repository* repository = luaL_checkgitrepo(L, 1); + git_remote* remote; + if (git_remote_lookup(&remote, repository, "origin")) { + git_repository_free(repository); + return luaL_error(L, "git remote fetch error: %s", git_error_last_string()); + } + git_fetch_options fetch_opts = GIT_FETCH_OPTIONS_INIT; + if (git_remote_fetch(remote, NULL, &fetch_opts, NULL)) { + git_remote_free(remote); + git_repository_free(repository); + return luaL_error(L, "git remote fetch error: %s", git_error_last_string()); + } + git_remote_free(remote); + git_repository_free(repository); + return 0; +} + + +static CURL *curl; +static int lpm_certs(lua_State* L) { + const char* type = luaL_checkstring(L, 1); + const char* path = luaL_checkstring(L, 2); + if (strcmp(type, "dir") == 0) { + git_libgit2_opts(GIT_OPT_SET_SSL_CERT_LOCATIONS, NULL, path); + curl_easy_setopt(curl, CURLOPT_CAINFO, path); + } else { + git_libgit2_opts(GIT_OPT_SET_SSL_CERT_LOCATIONS, path, NULL); + curl_easy_setopt(curl, CURLOPT_CAPATH, path); + } + return 0; +} + +static int lpm_extract(lua_State* L) { + const char* src = luaL_checkstring(L, 1); + const char* dst = luaL_optstring(L, 2, "."); + + char error_buffer[1024] = {0}; + struct archive_entry *entry; + const void *buff; + int flags = 0; + int r; + size_t size; +#if ARCHIVE_VERSION_NUMBER >= 3000000 + int64_t offset; +#else + off_t offset; +#endif + struct archive *ar = archive_read_new(); + struct archive *aw = archive_write_disk_new(); + archive_write_disk_set_options(aw, flags); + archive_read_support_format_tar(ar); + archive_read_support_format_zip(ar); + archive_read_support_filter_gzip(ar); + if ((r = archive_read_open_filename(ar, src, 10240))) { + snprintf(error_buffer, sizeof(error_buffer), "error extracting archive %s: %s", src, archive_error_string(ar)); + goto cleanup; + } + for (;;) { + int r = archive_read_next_header(ar, &entry); + if (r == ARCHIVE_EOF) + break; + if (r != ARCHIVE_OK) { + snprintf(error_buffer, sizeof(error_buffer), "error extracting archive %s: %s", src, archive_error_string(ar)); + goto cleanup; + } + char path[MAX_PATH]; + strcpy(path, dst); strcat(path, "/"); + strncat(path, archive_entry_pathname(entry), sizeof(path) - 3); path[MAX_PATH-1] = 0; + archive_entry_set_pathname(entry, path); + if (archive_write_header(aw, entry) != ARCHIVE_OK) { + snprintf(error_buffer, sizeof(error_buffer), "error extracting archive %s: %s", src, archive_error_string(aw)); + goto cleanup; + } + for (;;) { + int r = archive_read_data_block(ar, &buff, &size, &offset); + if (r == ARCHIVE_EOF) + break; + if (r != ARCHIVE_OK) { + snprintf(error_buffer, sizeof(error_buffer), "error extracting archive %s: %s", src, archive_error_string(ar)); + goto cleanup; + } + if (archive_write_data_block(aw, buff, size, offset) != ARCHIVE_OK) { + snprintf(error_buffer, sizeof(error_buffer), "error extracting archive %s: %s", src, archive_error_string(aw)); + goto cleanup; + } + } + if (archive_write_finish_entry(aw) != ARCHIVE_OK) { + snprintf(error_buffer, sizeof(error_buffer), "error extracting archive %s: %s", src, archive_error_string(aw)); + goto cleanup; + } + } + cleanup: + archive_read_close(ar); + archive_read_free(ar); + archive_write_close(aw); + archive_write_free(aw); + if (error_buffer[0]) + return luaL_error(L, "error extracting archive %s: %s", src, archive_error_string(ar)); + return 0; +} + +static size_t lpm_curl_write_callback(char *ptr, size_t size, size_t nmemb, void *BL) { + luaL_Buffer* B = BL; + luaL_addlstring(B, ptr, size*nmemb); + return size*nmemb; +} + +static int lpm_get(lua_State* L) { + long response_code; + const char* url = luaL_checkstring(L, 1); + const char* path = luaL_optstring(L, 2, NULL); + // curl_easy_reset(curl); + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1); + #ifdef _WIN32 + curl_easy_setopt(curl, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA); + #endif + if (path) { + FILE* file = fopen(path, "wb"); + if (!file) + return luaL_error(L, "error opening file %s: %s", path, strerror(errno)); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, fwrite); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, file); + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + fclose(file); + return luaL_error(L, "curl error accessing %s: %s", url, curl_easy_strerror(res)); + } + fclose(file); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); + if (response_code != 200) + return luaL_error(L, "curl error accessing %s, non-200 response code: %d", url, response_code); + lua_pushnil(L); + lua_newtable(L); + return 2; + } else { + luaL_Buffer B; + luaL_buffinit(L, &B); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, lpm_curl_write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &B); + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) + return luaL_error(L, "curl error accessing %s: %s", url, curl_easy_strerror(res)); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); + if (response_code != 200) + return luaL_error(L, "curl error accessing %s, non-200 response code: %d", url, response_code); + luaL_pushresult(&B); + lua_newtable(L); + } + return 2; +} + + +static const luaL_Reg system_lib[] = { + { "ls", lpm_ls }, // Returns an array of files. + { "stat", lpm_stat }, // Returns info about a single file. + { "mkdir", lpm_mkdir }, // Makes a directory. + { "rmdir", lpm_rmdir }, // Removes a directory. + { "hash", lpm_hash }, // Returns a hex sha256 hash. + { "symlink", lpm_symlink }, // Creates a symlink. + { "chmod", lpm_chmod }, // Chmod's a file. + { "init", lpm_init }, // Initializes a git repository with the specified remote. + { "fetch", lpm_fetch }, // Updates a git repository with the specified remote. + { "reset", lpm_reset }, // Updates a git repository to the specified commit/hash/branch. + { "get", lpm_get }, // HTTP(s) GET request. + { "extract", lpm_extract }, // Extracts .tar.gz, and .zip files. + { "certs", lpm_certs }, // Sets the SSL certificate chain folder/file. + { NULL, NULL } +}; + + +#ifndef LPM_VERSION + #define LPM_VERSION "unknown" +#endif + + +#ifndef LITE_ARCH_TUPLE + #if __x86_64__ || _WIN64 || __MINGW64__ + #define ARCH_PROCESSOR "x86_64" + #else + #define ARCH_PROCESSOR "x86" + #endif + #if _WIN32 + #define ARCH_PLATFORM "windows" + #elif __linux__ + #define ARCH_PLATFORM "linux" + #elif __APPLE__ + #define ARCH_PLATFORM "darwin" + #else + #error "Please define -DLITE_ARCH_TUPLE." + #endif + #define LITE_ARCH_TUPLE ARCH_PROCESSOR "-" ARCH_PLATFORM +#endif + + +extern const char lpm_lua[]; +extern unsigned int lpm_lua_len; +int main(int argc, char* argv[]) { + curl = curl_easy_init(); + if (!curl) + return -1; + git_libgit2_init(); + lua_State* L = luaL_newstate(); + luaL_openlibs(L); + luaL_newlib(L, system_lib); + lua_setglobal(L, "system"); + lua_newtable(L); + for (int i = 0; i < argc; ++i) { + lua_pushstring(L, argv[i]); + lua_rawseti(L, -2, i+1); + } + lua_setglobal(L, "ARGV"); + lua_pushliteral(L, LPM_VERSION); + lua_setglobal(L, "VERSION"); + #if _WIN32 + lua_pushliteral(L, "windows"); + lua_pushliteral(L, "\\"); + #else + lua_pushliteral(L, "posix"); + lua_pushliteral(L, "/"); + #endif + lua_setglobal(L, "PATHSEP"); + lua_setglobal(L, "PLATFORM"); + lua_pushliteral(L, LITE_ARCH_TUPLE); + lua_setglobal(L, "ARCH"); + #if LPM_LIVE + if (luaL_loadfile(L, "lpm.lua") || lua_pcall(L, 0, 1, 0)) { + #else + if (luaL_loadbuffer(L, lpm_lua, lpm_lua_len, "lpm.lua") || lua_pcall(L, 0, 1, 0)) { + #endif + fprintf(stderr, "internal error when starting the application: %s\n", lua_tostring(L, -1)); + return -1; + } + int status = lua_tointeger(L, -1); + lua_close(L); + git_libgit2_shutdown(); + curl_easy_cleanup(curl); + return status; +} diff --git a/src/lpm.lua b/src/lpm.lua new file mode 100644 index 0000000..c0752f0 --- /dev/null +++ b/src/lpm.lua @@ -0,0 +1,1612 @@ +setmetatable(_G, { __index = function(t, k) if not rawget(t, k) then error("cannot get undefined global variable: " .. k, 2) end end, __newindex = function(t, k) error("cannot set global variable: " .. k, 2) end }) + +-- Begin rxi JSON library. +local json = { _version = "0.1.2" } +local encode +local escape_char_map = { + [ "\\" ] = "\\", + [ "\"" ] = "\"", + [ "\b" ] = "b", + [ "\f" ] = "f", + [ "\n" ] = "n", + [ "\r" ] = "r", + [ "\t" ] = "t", +} + +local escape_char_map_inv = { [ "/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if rawget(val, 1) ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + local line_count = 1 + local col_count = 1 + for i = 1, idx - 1 do + col_count = col_count + 1 + if str:sub(i, i) == "\n" then + line_count = line_count + 1 + col_count = 1 + end + end + error( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(1, 4), 16 ) + local n2 = tonumber( s:sub(7, 10), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local res = "" + local j = i + 1 + local k = j + + while j <= #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + + elseif x == 92 then -- `\`: Escape + res = res .. str:sub(k, j - 1) + j = j + 1 + local c = str:sub(j, j) + if c == "u" then + local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) + or str:match("^%x%x%x%x", j + 1) + or decode_error(str, j - 1, "invalid unicode escape in string") + res = res .. parse_unicode_escape(hex) + j = j + #hex + else + if not escape_chars[c] then + decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") + end + res = res .. escape_char_map_inv[c] + end + k = j + 1 + + elseif x == 34 then -- `"`: End of string + res = res .. str:sub(k, j - 1) + return res, j + 1 + end + + j = j + 1 + end + + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + local res, idx = parse(str, next_char(str, 1, space_chars, true)) + idx = next_char(str, idx, space_chars, true) + if idx <= #str then + decode_error(str, idx, "trailing garbage") + end + return res +end + +-- End JSON library. +local function is_commit_hash(hash) + return #hash == 40 and not hash:find("[^a-z0-9]") +end + + +local common = {} +function common.merge(dst, src) for k, v in pairs(src) do dst[k] = v end return dst end +function common.map(l, p) local t = {} for i, v in ipairs(l) do table.insert(t, p(v)) end return t end +function common.flat_map(l, p) local t = {} for i, v in ipairs(l) do local r = p(v) for k, w in ipairs(r) do table.insert(t, w) end end return t end +function common.concat(t1, t2) local t = {} for i, v in ipairs(t1) do table.insert(t, v) end for i, v in ipairs(t2) do table.insert(t, v) end return t end +function common.grep(l, p) local t = {} for i, v in ipairs(l) do if p(v) then table.insert(t, v) end end return t end +function common.first(l, p) for i, v in ipairs(l) do if p(v) then return v end end end +function common.slice(t, i, l) local n = {} for j = i, l ~= nil and (i - l) or #t do table.insert(n, t[j]) end return n end +function common.join(j, l) local s = "" for i, v in ipairs(l) do if i > 1 then s = s .. j .. v else s = v end end return s end +function common.sort(t, f) table.sort(t, f) return t end +function common.write(path, contents) local f, err = io.open(path, "wb") if not f then error("can't write to " .. path .. ": " .. err) end f:write(contents) f:flush() end +function common.split(splitter, str) + local o = 1 + local res = {} + while true do + local s, e = str:find(splitter, o) + table.insert(res, str:sub(o, s and (s - 1) or #str)) + if not s then break end + o = e + 1 + end + return table.unpack(res) +end + +function common.dirname(path) local s = path:reverse():find("[/\\]") if not s then return path end return path:sub(1, #path - s) end +function common.basename(path) local s = path:reverse():find("[/\\]") if not s then return path end return path:sub(#path - s + 2) end +function common.path(exec) return common.first(common.map({ common.split(":", os.getenv("PATH")) }, function(e) return e .. PATHSEP .. exec end), function(e) return system.stat(e) end) end +function common.rmrf(root) + local info = root and root ~= "" and system.stat(root) + if not info then return end + if info.type == "file" or info.symlink then return os.remove(root) end + for i,v in ipairs(system.ls(root)) do common.rmrf(root .. PATHSEP .. v) end + system.rmdir(root) +end +function common.mkdirp(path) + local stat = system.stat(path) + if stat and stat.type == "dir" then return true end + if stat and stat.type == "file" then error("path " .. path .. " exists") end + local target + for _, dirname in ipairs({ common.split("[/\\]", path) }) do + target = target and target .. PATHSEP .. dirname or dirname + if target ~= "" and not system.stat(target) then system.mkdir(target) end + end +end +function common.copy(src, dst) + local src_stat, dst_stat = system.stat(src), system.stat(dst) + if not src_stat then error("can't find " .. src) end + if dst_stat and dst_stat.type == "dir" then return common.copy(src, dst .. PATHSEP .. common.basename(src)) end + if src_stat.type == "dir" then + common.mkdirp(dst) + for i, file in ipairs(system.ls(src)) do common.copy(src .. PATHSEP .. file, dst .. PATHSEP .. file) end + else + local src_io, err1 = io.open(src, "rb") + if err1 then error("can't open for reading " .. src .. ": " .. err1) end + local dst_io, err2 = io.open(dst, "wb") + if err2 then error("can't open for writing " .. dst .. ": " .. err2) end + while true do + local chunk = src_io:read(64*1024) + if not chunk then break end + dst_io:write(chunk) + end + dst_io:close() + end +end +function common.rename(src, dst) + local _, err = os.rename(src, dst) + if err then error("can't rename file " .. src .. " to " .. dst .. ": " .. err) end +end +function common.reset(path, ref, type) + if is_commit_hash(ref) then + system.reset(path, ref, type) + else + if not pcall(system.reset, path, "refs/tags/" .. ref, type) then system.reset(path, "refs/remotes/origin/" .. ref, type) end + end +end + +local HOME, USERDIR, CACHEDIR, JSON, VERBOSE, MOD_VERSION, QUIET, FORCE, AUTO_PULL_REMOTES, ARCH, ASSUME_YES, NO_INSTALL_OPTIONAL, TMPDIR, repositories, lite_xls, system_bottle + +local Plugin, Repository, LiteXL, Bottle = {}, {}, {}, {} + +local actions, warnings = {}, {} +local function log_action(message) + if JSON then table.insert(actions, message) end + if not QUIET then io.stderr:write(message .. "\n") end +end +local function log_warning(message) + if JSON then table.insert(warnings, message) end + if not QUIET then io.stderr:write("warning: " .. message .. "\n") end +end +local function fatal_warning(message) + if not FORCE then error(message .. "; use --force to override") else log_warning(message) end +end +local function prompt(message) + io.stderr:write(message .. " [Y/n]: ") + if ASSUME_YES then io.stderr:write("Y\n") return true end + local response = io.stdin:read("*line") + return not response:find("%S") or response:find("^%s*[yY]%s*$") +end + +function common.get(source, target, checksum) + if not checksum then return system.get(source, target) end + if not system.stat(CACHEDIR .. PATHSEP .. "files") then common.mkdirp(CACHEDIR .. PATHSEP .. "files") end + local cache_path = CACHEDIR .. PATHSEP .. "files" .. PATHSEP .. checksum + if not system.stat(cache_path) then + system.get(source, cache_path) + if system.hash(cache_path, "file") ~= checksum then fatal_warning("checksum doesn't match for " .. source) end + end + common.copy(cache_path, target) +end + + +local function compare_version(a, b) -- compares semver + if not a or not b then return false end + local _, _, majora, minora, revisiona = tostring(a):find("(%d+)%.?(%d*)%.?(%d*)") + local _, _, majorb, minorb, revisionb = tostring(b):find("(%d+)%.?(%d*)%.?(%d*)") + if majora == nil then error("can't parse version " .. a) end + if majorb == nil then error("can't parse version " .. b) end + majora, minora, revisiona = majora or 0, minora or 0, revisiona or 0 + majorb, minorb, revisionb = majorb or 0, minorb or 0, revisionb or 0 + if majora ~= majorb then return tonumber(majora) < tonumber(majorb) and -1 or 1 end + if minora ~= minorb then return tonumber(minora) < tonumber(minorb) and -1 or 1 end + if revisiona ~= revisionb then return tonumber(revisiona) < tonumber(revisionb) and -1 or 1 end + return 0 +end + +local function match_version(version, pattern) + if not pattern then return true end + if pattern:find("^>=") then return compare_version(version, pattern:sub(3)) >= 0 end + if pattern:find("^<=") then return compare_version(version, pattern:sub(3)) <= 0 end + if pattern:find("^<") then return compare_version(version, pattern:sub(2)) == -1 end + if pattern:find("^>") then return compare_version(version, pattern:sub(2)) == 1 end + if pattern:find("^=") then return compare_version(version, pattern:sub(2)) == 0 end + return version == pattern +end + + + +function Plugin.__index(self, idx) return rawget(self, idx) or Plugin[idx] end +function Plugin.new(repository, metadata) + local type = metadata.type or "plugin" + local folder = metadata.type == "library" and "libraries" or "plugins" + if metadata.path then metadata.path = metadata.path:gsub("/", PATHSEP) end + local self = setmetatable(common.merge({ + repository = repository, + tags = {}, + type = type, + path = nil, + remote = nil, + version = "1.0", + dependencies = {}, + local_path = repository and (repository.local_path .. PATHSEP .. (repository.commit or repository.branch) .. (metadata.path and (PATHSEP .. metadata.path:gsub("^/", "")) or "")), + }, metadata), Plugin) + -- Directory. + self.organization = metadata.organization or (((self.files and #self.files > 0) or self.remote or (not self.path and not self.url)) and "complex" or "singleton") + return self +end + +function Plugin:get_install_path(bottle) + local folder = self.type == "library" and "libraries" or "plugins" + local path = (bottle.local_path and (bottle.local_path .. PATHSEP .. "user") or USERDIR) .. PATHSEP .. folder .. PATHSEP .. (self.path and common.basename(self.path):gsub("%.lua$", "") or self.name) + if self.organization == "singleton" then path = path .. ".lua" end + return path +end + +function Plugin:is_core(bottle) return self.type == "core" end +function Plugin:is_installed(bottle) return self:is_core(bottle) or (bottle.lite_xl:is_compatible(self) and system.stat(self:get_install_path(bottle))) end +function Plugin:is_incompatible(plugin) return self.dependencies[plugin.name] and not match_version(plugin.version, dependencies[plugin.name]) end + +function Plugin:get_compatibilities(bottle) + local compatible_plugins, incompatible_plugins = {}, {} + local installed_plugins = bottle:installed_plugins() + for plugin, v in pairs(self.dependencies) do + local potential_plugins = { bottle:get_plugin(plugin, v.version, { mod_version = bottle.lite_xl.mod_version }) } + for i, potential_plugin in ipairs(potential_plugins) do + local incomaptibilities = common.grep(installed_plugins, function(p) return p:is_incompatible(potential_plugin) end) + if #incomaptibilities == 0 then + if not compatible_plugins[plugin] or + potential_plugin:is_installed(bottle) or + (compare_version(compatible_plugins[plugin].version, potential_plugin.version) and not compatible_plugins[plugin]:is_installed(bottle)) + then + compatible_plugins[plugin] = potential_plugin + end + else + incompatible_plugins[plugin] = incompatibilities + end + end + end + return compatible_plugins, incompatible_plugins +end + + + +function Plugin:install(bottle, installing) + if self:is_installed(bottle) then log_warning("plugin " .. self.name .. " is already installed") return end + local install_path = self:get_install_path(bottle) + local temporary_install_path = TMPDIR .. PATHSEP .. install_path:sub(#USERDIR + 2) + local status, err = pcall(function() + installing = installing or {} + installing[self.name] = true + local compatible, incompatible = self:get_compatibilities(bottle) + for plugin, version in pairs(self.dependencies) do + if incompatible[plugin] then error("can't install " .. self.name .. ": incompatible with " .. incompatible[plugin][1].name .. ":" .. incompatible[plugin][1].version) end + end + for plugin, v in pairs(self.dependencies) do + if not compatible[plugin] then error("can't find dependency " .. plugin .. (v.version and (":" .. v.version) or "")) end + end + for plugin, v in pairs(self.dependencies) do + if not compatible[plugin]:is_core(bottle) and not compatible[plugin]:is_installed(bottle) then + if installing[plugin] then + error("circular dependency detected in " .. self.name .. ": requires " .. plugin .. " but, " .. plugin .. " requires " .. self.name) + end + if not NO_INSTALL_OPTIONAL and (not v.optional or prompt(plugin .. " is an optional dependency of " .. self.name .. ". Should we install it?")) then + compatible[plugin]:install(bottle, installing) + end + end + end + common.mkdirp(common.dirname(temporary_install_path)) + if self.status == "upgradable" then + log_action("Upgrading " .. self.organization .. "plugin located at " .. self.local_path .. " to " .. install_path) + common.rmrf(install_path) + else + log_action("Installing " .. self.organization .. " plugin located at " .. self.local_path .. " to " .. install_path) + end + + if self.organization == "complex" and self.path and system.stat(self.local_path).type ~= "dir" then common.mkdirp(install_path) end + if self.url then + log_action("Downloading file " .. self.url .. "...") + local path = temporary_install_path .. (self.organization == 'complex' and self.path and system.stat(self.local_path).type ~= "dir" and (PATHSEP .. "init.lua") or "") + common.get(self.url, path, self.checksum) + log_action("Downloaded file " .. self.url .. " to " .. path) + if system.hash(path, "file") ~= self.checksum then fatal_warning("checksum doesn't match for " .. path) end + elseif self.remote then + log_action("Cloning repository " .. self.remote .. " into " .. install_path) + common.mkdirp(temporary_install_path) + local _, _, url, branch = self.remote:find("^(.*):(.*)$") + system.init(temporary_install_path, url) + common.reset(temporary_install_path, branch) + else + local path = install_path .. (self.organization == 'complex' and self.path and system.stat(self.local_path).type ~= "dir" and (PATHSEP .. "init.lua") or "") + local temporary_path = temporary_install_path .. (self.organization == 'complex' and self.path and system.stat(self.local_path).type ~= "dir" and (PATHSEP .. "init.lua") or "") + log_action("Copying " .. self.local_path .. " to " .. path) + common.copy(self.local_path, temporary_path) + end + for i,file in ipairs(self.files or {}) do + if not file.arch or file.arch == ARCH then + if not NO_INSTALL_OPTIONAL and (not file.optional or prompt(common.basename(file.url) .. " is an optional dependency of " .. self.name .. ". Should we install it?")) then + if not file.checksum then error("requires a checksum") end + local path = install_path .. PATHSEP .. (file.path or common.basename(file.url)) + local temporary_path = temporary_install_path .. PATHSEP .. (file.path or common.basename(file.url)) + log_action("Downloading file " .. file.url .. "...") + common.get(file.url, temporary_path, file.checksum) + log_action("Downloaded file " .. file.url .. " to " .. path) + if system.hash(temporary_path, "file") ~= file.checksum then fatal_warning("checksum doesn't match for " .. path) end + end + end + end + end) + if not status then + common.rmrf(temporary_install_path) + error(err) + else + common.rmrf(install_path) + common.mkdirp(common.dirname(install_path)) + common.rename(temporary_install_path, install_path) + end +end + +function Plugin:depends_on(plugin) + if self.dependencies[plugin.name] and self.dependencies[plugin.name].optional ~= true then return true end + for i,v in ipairs(plugin.provides or {}) do if self.dependencies[v] and self.dependencies[v].optional ~= true then return true end end + return false +end + +function Plugin:uninstall(bottle) + local install_path = self:get_install_path(bottle) + if self:is_core(bottle) then error("can't uninstall " .. self.name .. " is a core plugin") end + log_action("Uninstalling plugin located at " .. install_path) + local incompatible_plugins = common.grep(bottle:installed_plugins(), function(p) return p:depends_on(self) end) + if #incompatible_plugins == 0 or prompt(self.name .. " is depended upon by " .. common.join(", ", common.map(incompatible_plugins, function(p) return p.name end)) .. ". Remove as well?") then + for i,plugin in ipairs(incompatible_plugins) do + if not plugin:uninstall(bottle) then return false end + end + common.rmrf(install_path) + return true + end + return false +end + + +function Repository.__index(self, idx) return rawget(self, idx) or Repository[idx] end +function Repository.new(hash) + if not hash.remote then error("requires a remote") end + if not hash.remote:find("^https?:") and not hash.remote:find("^file:") then error("only repositories with http and file transports are supported (" .. hash.remote .. ")") end + local self = setmetatable({ + commit = hash.commit, + remote = hash.remote, + branch = hash.branch, + plugins = nil, + lite_xls = {}, + local_path = CACHEDIR .. PATHSEP .. "repos" .. PATHSEP .. system.hash(hash.remote), + last_retrieval = nil + }, Repository) + if system.stat(self.local_path) and not self.commit and not self.branch then + -- In the case where we don't have a branch, and don't have a commit, check for the presence of `master` and `main`. + if system.stat(self.local_path .. PATHSEP .. "master") then + self.branch = "master" + elseif system.stat(self.local_path .. PATHSEP .. "main") then + self.branch = "main" + else + error("can't find branch for " .. self.remote) + end + end + return self +end + +function Repository.url(url) + if type(url) == "table" then return url.remote .. ":" .. (url.branch or url.commit) end + local e = url:reverse():find(":") + local s = e and (#url - e + 1) + local remote, branch_or_commit = url:sub(1, s and (s-1) or #url), s and url:sub(s+1) + if remote == "https" or remote == "file" then remote, branch_or_commit = url, nil end + if branch_or_commit and is_commit_hash(branch_or_commit) then + return Repository.new({ remote = remote, commit = branch_or_commit }) + end + return Repository.new({ remote = remote, branch = branch_or_commit }) +end + +function Repository:parse_manifest(already_pulling) + if self.manifest then return self.manifest, self.remotes end + if system.stat(self.local_path) and system.stat(self.local_path .. PATHSEP .. (self.commit or self.branch)) then + self.manifest_path = self.local_path .. PATHSEP .. (self.commit or self.branch) .. PATHSEP .. "manifest.json" + if not system.stat(self.manifest_path) then self:generate_manifest() end + self.manifest = json.decode(io.open(self.manifest_path, "rb"):read("*all")) + self.plugins = {} + self.remotes = {} + for i, metadata in ipairs(self.manifest["plugins"] or {}) do + if metadata.remote then + local _, _, url, branch_or_commit = metadata.remote:find("^(.-):?(.*)?$") + if branch_or_commit and is_commit_hash(branch_or_commit) then + repo = Repository.new({ remote = url, commit = branch_or_commit }) + table.insert(remotes, repo) + table.insert(self.plugins, Plugin.new(self, metadata)) + else + -- log_warning("plugin " .. metadata.name .. " specifies remote as source, but isn't a commit") + end + else + table.insert(self.plugins, Plugin.new(self, metadata)) + end + end + for i, metadata in ipairs(self.manifest["lite-xls"] or {}) do + if metadata.remote then + local _, _, url, branch_or_commit = metadata.remote:find("^(.-):?(.*)?$") + if branch_or_commit and is_commit_hash(branch_or_commit) then + repo = Repository.new({ remote = url, commit = branch_or_commit }) + table.insert(remotes, repo) + table.insert(self.lite_xls, LiteXL.new(self, metadata)) + else + -- log_warning("plugin " .. metadata.name .. " specifies remote as source, but isn't a commit") + end + else + table.insert(self.lite_xls, LiteXL.new(self, metadata)) + end + end + self.remotes = common.map(self.manifest["remotes"] or {}, function(r) return Repository.url(r) end) + end + return self.manifest, self.remotes +end + + +-- in the cases where we don't have a manifest, assume generalized structure, take plugins folder, trawl through it, build manifest that way +-- assuming each .lua file under the `plugins` folder is a plugin. also parse the README, if present, and see if any of the plugins +function Repository:generate_manifest() + if not self.commit and not self.branch then error("requires an instantiation") end + local path = self.local_path .. PATHSEP .. (self.commit or self.branch) + local plugin_dir = system.stat(path .. PATHSEP .. "plugins") and PATHSEP .. "plugins" .. PATHSEP or PATHSEP + local plugins, plugin_map = {}, {} + if system.stat(path .. PATHSEP .. "README.md") then -- If there's a README, parse it for a table like in our primary repository. + for line in io.lines(path .. PATHSEP .. "README.md") do + local _, _, name, path, description = line:find("^%s*%|%s*%[`([%w_]+)%??.-`%]%((.-)%).-%|%s*(.-)%s*%|%s*$") + if name then + plugin_map[name] = { name = name, description = description, files = {} } + if path:find("^http") then + if path:find("%.lua") then + plugin_map[name].url = path + local file = common.get(path) + plugin_map[name].checksum = system.hash(file) + else + plugin_map[name].remote = path + end + else + plugin_map[name].path = path:gsub("%?.*$", "") + end + end + end + end + for i, file in ipairs(system.ls(path .. plugin_dir)) do + if file:find("%.lua$") then + local plugin = { description = nil, files = {}, name = common.basename(file):gsub("%.lua$", ""), dependencies = {}, mod_version = 3, version = "1.0", tags = {}, path = plugin_dir .. file } + for line in io.lines(path .. plugin_dir .. file) do + local _, _, mod_version = line:find("%-%-.*mod%-version:%s*(%w+)") + if mod_version then plugin.mod_version = mod_version end + local _, _, required_plugin = line:find("require [\"']plugins.([%w_]+)") + if required_plugin then if required_plugin ~= plugin.name then plugin.dependencies[required_plugin] = ">=1.0" end end + end + if plugin_map[plugin.name] then + plugin = common.merge(plugin, plugin_map[plugin.name]) + plugin_map[plugin.name].plugin = plugin + end + table.insert(plugins, plugin) + end + end + for k, v in pairs(plugin_map) do + if not v.plugin then + table.insert(plugins, common.merge({ dependencies = {}, mod_version = self.branch == "master" and 2 or 3, version = "1.0", tags = {} }, v)) + end + end + common.write(path .. PATHSEP .. "manifest.json", json.encode({ plugins = plugins })) +end + +function Repository:add(pull_remotes) + -- If neither specified then pull onto `master`, and check the main branch name, and move if necessary. + if not self.branch and not self.commit then + local path = self.local_path .. PATHSEP .. "master" + common.mkdirp(path) + log_action("Retrieving " .. self.remote .. ":master/main...") + system.init(path, self.remote) + system.fetch(path) + if not pcall(system.reset, path, "refs/remotes/origin/master", "hard") then + if pcall(system.reset, path, "refs/remotes/origin/main", "hard") then + common.rename(path, self.local_path .. PATHSEP .. "main") + self.branch = "main" + else + error("can't find master or main.") + end + else + self.branch = "master" + end + log_action("Retrieved " .. self.remote .. ":master/main.") + else + local path = self.local_path .. PATHSEP .. (self.commit or self.branch) + common.mkdirp(path) + log_action("Retrieving " .. self.remote .. ":" .. (self.commit or self.branch) .. "...") + system.init(path, self.remote) + system.fetch(path) + common.reset(path, self.commit or self.branch, "hard") + log_action("Retrieved " .. self:url() .. "...") + self.manifest = nil + end + local manifest, remotes = self:parse_manifest() + if pull_remotes then -- any remotes we don't have in our listing, call add, and add into the list + for i, remote in ipairs(remotes) do + if not common.first(repositories, function(repo) return repo.remote == remote.remote and repo.branch == remote.branch and repo.commit == remote.commit end) then + remote:add(pull_remotes == "recursive" and "recursive" or false) + table.insert(repositories, remote) + end + end + end + return self +end + + +function Repository:update(pull_remotes) + local manifest, remotes = self:parse_manifest() + if self.branch then + local path = self.local_path .. PATHSEP .. self.branch + system.fetch(path) + common.reset(path, self.branch, "hard") + log_action("Updated " .. self:url()) + self.manifest = nil + manifest, remotes = self:parse_manifest() + end + if pull_remotes then -- any remotes we don't have in our listing, call add, and add into the list + for i, remote in ipairs(remotes) do + if common.first(repositories, function(repo) return repo.remote == remote.remote and repo.branch == remote.branch and repo.commit == remote.comit end) then + remote:add(pull_remotes == "recursive" and "recursive" or false) + table.insert(repositories, remote) + end + end + end +end + + +function Repository:remove() + common.rmrf(self.local_path .. PATHSEP .. (self.commit or self.branch)) + if #system.ls(self.local_path) == 0 then common.rmrf(self.local_path) end +end + + +function LiteXL.__index(t, k) return LiteXL[k] end +function LiteXL.new(repository, metadata) + if not metadata.version then error("lite-xl entry requires a version") end + local self = setmetatable({ + repository = repository, + version = metadata.version, + remote = metadata.remote, + url = metadata.url, + tags = metadata.tags or {}, + mod_version = metadata.mod_version, + path = metadata.path, + files = metadata.files or {} + }, LiteXL) + self.hash = system.hash((repository and repository:url() or "") .. "-" .. metadata.version .. common.join("", common.map(self.files, function(f) return f.checksum end))) + self.local_path = self:is_local() and self.path or (CACHEDIR .. PATHSEP .. "lite_xls" .. PATHSEP .. self.version .. PATHSEP .. self.hash) + return self +end + +function LiteXL:get_binary_path() return self.local_path .. PATHSEP .. "lite-xl" end +function LiteXL:get_data_directory() return self.local_path .. PATHSEP .. "data" end +function LiteXL:is_system() return system_bottle and system_bottle.lite_xl == self end +function LiteXL:is_local() return not self.repository and self.path end +function LiteXL:is_compatible(plugin) return compare_version(self.mod_version, plugin.mod_version) == 0 end +function LiteXL:is_installed() return system.stat(self.local_path) end + +function LiteXL:install() + if self:is_installed() then log_warning("lite-xl " .. self.version .. " already installed") return end + common.mkdirp(self.local_path) + if system_bottle.lite_xl == self then -- system lite-xl. We have to copy it because we can't really set the user directory. + local executable, datadir = common.path("lite-xl") + if not executable then error("can't find system lite-xl executable") end + local stat = system.stat(executable) + executable = stat.symlink and stat.symlink or executable + datadir = common.dirname(executable) .. PATHSEP .. "data" + if not system.stat(datadir) then error("can't find system lite-xl data dir") end + common.copy(executable, self.local_path .. PATHSEP .. "lite-xl") + system.chmod(self.local_path .. PATHSEP .. "lite-xl", 448) -- chmod to rwx------- + common.copy(datadir, self.local_path .. PATHSEP .. "data") + elseif self.path and not self.repository then -- local repository + system.symlink(self:get_binary_path(), self.path .. PATHSEP .. "lite_xl") + else + if self.remote then + system.init(self.local_path, self.remote) + common.reset(self.local_path, self.commit or self.branch) + end + for i,file in ipairs(self.files or {}) do + if file.arch and file.arch == ARCH then + if not file.checksum then error("requires a checksum") end + local basename = common.basename(file.url) + local archive = basename:find("%.zip$") or basename:find("%.tar%.gz$") + local path = self.local_path .. PATHSEP .. (archive and basename or "lite-xl") + log_action("Downloading file " .. file.url .. "...") + common.get(file.url, path, file.checksum) + log_action("Downloaded file " .. file.url .. " to " .. path) + if system.hash(path, "file") ~= file.checksum then fatal_warning("checksum doesn't match for " .. path) end + if archive then + log_action("Extracting file " .. basename .. " in " .. self.local_path) + system.extract(path, self.local_path) + end + end + end + end + if not system.stat(self.local_path .. PATHSEP .. "lite-xl") then error("can't find executable for lite-xl " .. self.version) end +end + +function LiteXL:uninstall() + if not system.stat(self.local_path) then error("lite-xl " .. self.version .. " not installed") end + common.rmrf(self.local_path) +end + + +function Bottle.__index(t, k) return Bottle[k] end +function Bottle.new(lite_xl, plugins, is_system) + local self = setmetatable({ + lite_xl = lite_xl, + plugins = plugins, + is_system = is_system + }, Bottle) + if not is_system then + table.sort(self.plugins, function(a, b) return (a.name .. ":" .. a.version) < (b.name .. ":" .. b.version) end) + self.hash = system.hash(lite_xl.version .. " " .. common.join(" ", common.map(self.plugins, function(p) return p.name .. ":" .. p.version end))) + self.local_path = CACHEDIR .. PATHSEP .. "bottles" .. PATHSEP .. self.hash + end + return self +end + +function Bottle:is_constructed() return self.is_system or system.stat(self.local_path) end + +function Bottle:construct() + if self.is_system then error("system bottle cannot be constructed") end + if self:is_constructed() then error("bottle " .. self.hash .. " already constructed") end + if not self.lite_xl:is_installed() then self.lite_xl:install() end + common.mkdirp(self.local_path .. PATHSEP .. "user") + common.copy(self.lite_xl.local_path .. PATHSEP .. "lite-xl", self.local_path .. PATHSEP .. "lite-xl") + system.chmod(self.local_path .. PATHSEP .. "lite-xl", 448) -- chmod to rwx------- + common.copy(self.lite_xl.local_path .. PATHSEP .. "data", self.local_path .. PATHSEP .. "data") + for i,plugin in ipairs(self.plugins) do plugin:install(self) end +end + +function Bottle:destruct() + if self.is_system then error("system bottle cannot be destructed") end + if not self:is_constructed() then error("lite-xl " .. self.version .. " not constructed") end + common.rmrf(self.local_path) +end + +function Bottle:run(args) + args = args or {} + if self.is_system then error("system bottle cannot be run") end + os.execute(self.local_path .. PATHSEP .. "lite-xl", table.unpack(args)) +end + +function Bottle:all_plugins() + local t = common.flat_map(repositories, function(r) return r.plugins end) + local hash = { } + for i, v in ipairs(t) do hash[v.name] = v end + local plugin_paths = { + (self.local_path and (self.local_path .. PATHSEP .. "user") or USERDIR) .. PATHSEP .. "plugins", + self.lite_xl:get_data_directory() .. PATHSEP .. "plugins" + } + for i, plugin_path in ipairs(common.grep(plugin_paths, function(e) return system.stat(e) end)) do + for k, v in ipairs(common.grep(system.ls(plugin_path), function(e) return not hash[e:gsub("%.lua$", "")] end)) do + table.insert(t, Plugin.new(nil, { + name = v:gsub("%.lua$", ""), + type = i == 2 and "core", + organization = (v:find("%.lua$") and "singleton" or "complex"), + mod_version = self.lite_xl.mod_version, + path = "plugins/" .. v, + version = "1.0" + })) + end + end + return t +end + +function Bottle:installed_plugins() + return common.grep(self:all_plugins(), function(p) return p:is_installed(self) end) +end + +function Bottle:get_plugin(name, version, filter) + local candidates = {} + local wildcard = name:find("%*$") + filter = filter or {} + for i,plugin in ipairs(self:all_plugins()) do + if not version and plugin.provides then + for k, provides in ipairs(plugin.provides) do + if provides == name then + table.insert(candidates, plugin) + end + end + end + if (plugin.name == name or (wildcard and plugin.name:find("^" .. name:sub(1, #name - 1)))) and match_version(plugin.version, version) then + if (not filter.mod_version or not plugin.mod_version or tonumber(plugin.mod_version) == tonumber(filter.mod_version)) then + table.insert(candidates, plugin) + end + end + end + return table.unpack(common.sort(candidates, function (a,b) return a.version < b.version end)) +end + + +local function get_repository(url) + if not url then error("requires a repository url") end + local r = Repository.url(url) + for i,v in ipairs(repositories) do + if v.remote == r.remote and v.branch == r.branch and v.commit == r.commit then return i, v end + end + return nil +end + + +local DEFAULT_REPOS +local function lpm_repo_init() + DEFAULT_REPOS = { Repository.url("https://github.com/adamharrison/lite-xl-plugin-manager.git:latest") } + if not system.stat(CACHEDIR .. PATHSEP .. "repos") then + for i, repository in ipairs(DEFAULT_REPOS) do + if not system.stat(repository.local_path) or not system.stat(repository.local_path .. PATHSEP .. (repository.commit or repository.branch)) then + table.insert(repositories, repository:add(true)) + end + end + end +end + + +local function lpm_repo_add(...) + for i, url in ipairs({ ... }) do + local idx, repo = get_repository(url) + if repo then -- if we're alreayd a repo, put this at the head of the resolution list + table.remove(repositories, idx) + else + repo = Repository.url(url):add(AUTO_PULL_REMOTES and "recursive" or false) + end + table.insert(repositories, 1, repo) + repo:update() + end +end + + +local function lpm_repo_rm(...) + for i, url in ipairs({ ... }) do + local idx, repo = get_repository(url) + if not repo then error("cannot find repository " .. url) end + table.remove(repositories, idx) + repo:remove() + end +end + + +local function lpm_repo_update(...) + local t = { ... } + if #t == 0 then table.insert(t, false) end + for i, url in ipairs(t) do + local repo = url and get_repository(url) + for i,v in ipairs(repositories) do + if not repo or v == repo then + v:update(AUTO_PULL_REMOTES and "recursive" or false) + end + end + end +end + +local function get_lite_xl(version) + return common.first(common.concat(lite_xls, common.flat_map(repositories, function(e) return e.lite_xls end)), function(lite_xl) return lite_xl.version == version end) +end + +local function lpm_lite_xl_save() + common.mkdirp(CACHEDIR .. PATHSEP .. "lite_xls") + common.write(CACHEDIR .. PATHSEP .. "lite_xls" .. PATHSEP .. "locals.json", + json.encode(common.map(common.grep(lite_xls, function(l) return l:is_local() and not l:is_system() end), function(l) return { version = l.version, mod_version = l.mod_version, path = l.path } end)) + ) +end + +local function lpm_lite_xl_add(version, path) + if not version then error("requires a version") end + if not path then error("requires a path") end + if not system.stat(path .. PATHSEP .. "lite-xl") then error("can't find " .. path .. PATHSEP .. "lite-xl") end + if not system.stat(path .. PATHSEP .. "data") then error("can't find " .. path .. PATHSEP .. "data") end + table.insert(lite_xls, LiteXL.new(nil, { version = version, path = path:gsub(PATHSEP .. "$", ""), mod_version = MOD_VERSION or 3 })) + lpm_lite_xl_save() +end + +local function lpm_lite_xl_rm(version) + if not version then error("requires a version") end + local lite_xl = get_lite_xl(version) or error("can't find lite_xl version " .. version) + lite_xls = common.grep(lite_xls, function(l) return l ~= lite_xl end) + lpm_lite_xl_save() +end + +local function lpm_lite_xl_install(version) + if not version then error("requires a version") end + (get_lite_xl(version) or error("can't find lite-xl version " .. version)):install() +end + + +local function lpm_lite_xl_switch(version, target) + if not version then error("requires a version") end + target = target or common.path("lite-xl") + if not target then error("can't find installed lite-xl. please provide a target to install the symlink explicitly") end + local lite_xl = get_lite_xl(version) or error("can't find lite-xl version " .. version) + if not lite_xl:is_installed() then log_action("Installing lite-xl " .. lite_xl.version) lite_xl:install() end + local stat = system.stat(target) + if stat and stat.symlink then os.remove(target) end + system.symlink(lite_xl:get_binary_path(), target) + if not common.path('lite-xl') then + os.remove(target) + error(target .. " is not on your $PATH; please supply a target that can be found on your $PATH, called `lite-xl`.") + end +end + + +local function lpm_lite_xl_uninstall(version) + (get_lite_xl(version) or error("can't find lite-xl version " .. version)):uninstall() +end + + +local function lpm_lite_xl_list() + local result = { ["lite-xl"] = { } } + local max_version = 0 + for i,lite_xl in ipairs(lite_xls) do + table.insert(result["lite-xl"], { + version = lite_xl.version, + mod_version = lite_xl.mod_version, + tags = lite_xl.tags, + is_system = lite_xl:is_system(), + status = lite_xl:is_installed() and (lite_xl:is_local() and "local" or "installed") or "available", + local_path = lite_xl.local_path + }) + max_version = math.max(max_version, #lite_xl.version) + end + for i,repo in ipairs(repositories) do + if not repo.lite_xls then error("can't find lite-xl for repo " .. repo:url()) end + for j, lite_xl in ipairs(repo.lite_xls) do + table.insert(result["lite-xl"], { + version = lite_xl.version, + mod_version = lite_xl.mod_version, + repository = repo:url(), + tags = lite_xl.tags, + is_system = lite_xl:is_system(), + status = lite_xl:is_installed() and (lite_xl:is_local() and "local" or "installed") or "available", + local_path = lite_xl.local_path + }) + max_version = math.max(max_version, #lite_xl.version) + end + end + if JSON then + io.stdout:write(json.encode(result) .. "\n") + else + if VERBOSE then + for i, lite_xl in ipairs(result["lite-xl"]) do + if i ~= 0 then print("---------------------------") end + print("Version: " .. lite_xl.version) + print("Status: " .. lite_xl.status) + print("Mod-Version: " .. (lite_xl.mod_version or "unknown")) + print("Tags: " .. common.join(", ", lite_xl.tags)) + end + else + max_version = max_version + 2 + print(string.format("%" .. max_version .. "s | %10s | %s", "Version", "Status", "Location")) + print(string.format("%" .. max_version .."s | %10s | %s", "-------", "---------", "---------------------------")) + for i, lite_xl in ipairs(result["lite-xl"]) do + print(string.format("%" .. max_version .. "s | %10s | %s", (lite_xl.is_system and "* " or "") .. lite_xl.version, lite_xl.status, (lite_xl.status ~= "available" and lite_xl.local_path or lite_xl.repository))) + end + end + end +end + +local function lpm_lite_xl_run(version, ...) + if not version then error("requires a version") end + local lite_xl = get_lite_xl(version) or error("can't find lite-xl version " .. version) + local plugins = {} + for i, str in ipairs({ ... }) do + local name, version = common.split(":", str) + local plugin = system_bottle:get_plugin(name, version, { mod_version = lite_xl.mod_version }) + if not plugin then error("can't find plugin " .. str) end + table.insert(plugins, plugin) + end + local bottle = Bottle.new(lite_xl, plugins) + if not bottle:is_constructed() then bottle:construct() end + bottle:run() +end + + +local function lpm_install(...) + for i, identifier in ipairs({ ... }) do + local s = identifier:find(":") + local name, version = (s and identifier:sub(1, s-1) or identifier), (s and identifier:sub(s+1) or nil) + if not name then error('unrecognized identifier ' .. identifier) end + if name == "lite-xl" then + lpm_lite_xl_install(version) + else + local plugins = { system_bottle:get_plugin(name, version, { mod_version = system_bottle.lite_xl.mod_version }) } + if #plugins == 0 then error("can't find plugin " .. name .. " mod-version: " .. (system_bottle.lite_xl.mod_version or 'any')) end + for j,v in ipairs(plugins) do v:install(system_bottle) end + end + end +end + + +local function lpm_plugin_uninstall(...) + for i, name in ipairs({ ... }) do + local plugins = { system_bottle:get_plugin(name) } + if #plugins == 0 then error("can't find plugin " .. name) end + local installed_plugins = common.grep(plugins, function(e) return e:is_installed(system_bottle) end) + if #installed_plugins == 0 then error("plugin " .. name .. " not installed") end + for i, plugin in ipairs(installed_plugins) do plugin:uninstall(system_bottle) end + end +end + +local function lpm_plugin_reinstall(...) for i, name in ipairs({ ... }) do pcall(lpm_plugin_uninstall, name) end lpm_install(...) end + +local function lpm_repo_list() + if JSON then + io.stdout:write(json.encode({ repositories = common.map(repositories, function(repo) return { remote = repo.remote, commit = repo.commit, branch = repo.branch, path = repo.local_path .. PATHSEP .. (repo.commit or repo.branch), remotes = common.map(repo.remotes or {}, function(r) return r:url() end) } end) }) .. "\n") + else + for i, repository in ipairs(repositories) do + local _, remotes = repository:parse_manifest() + if i ~= 0 then print("---------------------------") end + print("Remote : " .. repository:url()) + print("Path : " .. repository.local_path .. PATHSEP .. (repository.commit or repository.branch)) + print("Remotes: " .. json.encode(common.map(repository.remotes or {}, function(r) return r:url() end))) + end + end +end + +local function lpm_plugin_list() + local max_name = 0 + local result = { plugins = { } } + for j,plugin in ipairs(system_bottle:all_plugins()) do + max_name = math.max(max_name, #plugin.name) + local repo = plugin.repository + table.insert(result.plugins, { + name = plugin.name, + status = plugin.repository and (plugin:is_installed(system_bottle) and "installed" or (system_bottle.lite_xl:is_compatible(plugin) and "available" or "incompatible")) or (plugin:is_core(system_bottle) and "core" or "orphan"), + version = "" .. plugin.version, + dependencies = plugin.dependencies, + description = plugin.description, + mod_version = plugin.mod_version, + tags = plugin.tags, + type = plugin.type, + organization = plugin.organization, + repository = repo and repo:url() + }) + end + if JSON then + io.stdout:write(json.encode(result) .. "\n") + else + if not VERBOSE then + print(string.format("%" .. max_name .."s | %10s | %10s | %s", "Name", "Version", "ModVer", "Status")) + print(string.format("%" .. max_name .."s | %10s | %10s | %s", "--------------", "----------", "----------", "-----------")) + end + for i, plugin in ipairs(common.sort(result.plugins, function(a,b) return a.name < b.name end)) do + if VERBOSE then + if i ~= 0 then print("---------------------------") end + print("Name: " .. plugin.name) + print("Version: " .. plugin.version) + print("Status: " .. plugin.status) + print("Type: " .. plugin.type) + print("Orgnization: " .. plugin.organization) + print("Repository: " .. (plugin.repository or "orphan")) + print("Description: " .. (plugin.description or "")) + print("Mod-Version: " .. (plugin.mod_version or "unknown")) + print("Dependencies: " .. json.encode(plugin.dependencies)) + print("Tags: " .. common.join(", ", plugin.tags)) + elseif plugin.status ~= "incompatible" then + print(string.format("%" .. max_name .."s | %10s | %10s | %s", plugin.name, plugin.version, plugin.mod_version, plugin.status)) + end + end + end +end + +local function lpm_describe() + for i,v in ipairs(repositories) do + if #common.grep(DEFAULT_REPOS, function(r) return r:url() == v:url() end) == 0 then + io.stdout:write("lpm add " .. v:url() .. " && ") + end + end + print("lpm run " .. system_bottle.lite_xl.version .. " " .. common.join(" ", common.map(system_bottle:installed_plugins(), function(p) return p.name .. ":" .. p.version end))) +end + +local function lpm_plugin_upgrade() + for i,plugin in ipairs(system_bottle:installed_plugins()) do + local upgrade = common.sort(system_bottle:get_plugin(plugin.name, ">" .. plugin.version), function(a, b) return compare_version(b.version, a.version) end)[1] + if upgrade then upgrade:install(system_bottle) end + end +end + +local function lpm_purge() + -- local path = common.path("lite-xl") + -- if path then + -- local lite_xl = get_lite_xl("system") + -- if lite_xl then + -- os.remove(path) + -- system.symlink(lite_xl:get_binary_path(), target) + -- log_action("Reset lite-xl symlink to system.") + -- end + -- end + log_action("Removed " .. CACHEDIR .. ".") + common.rmrf(CACHEDIR) +end + +local function parse_arguments(arguments, options) + local args = {} + local i = 1 + while i <= #arguments do + local s,e, option, value = arguments[i]:find("%-%-([^=]+)=?(.*)") + if s then + local flag_type = options[option] + if not flag_type then error("unknown flag --" .. option) end + if flag_type == "flag" then + args[option] = true + elseif flag_type == "string" or flag_type == "number" then + if not value then + if i < #arguments then error("option " .. option .. " requires a " .. flag_type) end + value = arguments[i+1] + i = i + 1 + end + if flag_type == "number" and tonumber(flag_type) == nil then error("option " .. option .. " should be a number") end + args[option] = value + end + else + table.insert(args, arguments[i]) + end + i = i + 1 + end + return args +end + +local status = 0 +local function error_handler(err) + local s, e = err:find(":%d+") + local message = e and err:sub(e + 3) or err + if JSON then + if VERBOSE then + io.stderr:write(json.encode({ error = err, actions = actions, warnings = warnings, traceback = debug.traceback() }) .. "\n") + else + io.stderr:write(json.encode({ error = message or err, actions = actions, warnings = warnings }) .. "\n") + end + else + io.stderr:write((not VERBOSE and message or err) .. "\n") + if VERBOSE then io.stderr:write(debug.traceback() .. "\n") end + end + status = -1 +end + +local function run_command(ARGS) + if not ARGS[2]:find("%S") then return + elseif ARGS[2] == "init" then return + elseif ARGS[2] == "repo" and ARGV[3] == "add" then lpm_repo_add(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "repo" and ARGS[3] == "rm" then lpm_repo_rm(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "add" then lpm_repo_add(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "rm" then lpm_repo_rm(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "update" then lpm_repo_update(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "repo" and ARGS[3] == "update" then lpm_repo_update(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "repo" and (#ARGS == 2 or ARGS[3] == "list") then return lpm_repo_list() + elseif ARGS[2] == "plugin" and ARGS[3] == "install" then lpm_install(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "plugin" and ARGS[3] == "uninstall" then lpm_plugin_uninstall(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "plugin" and ARGS[3] == "reinstall" then lpm_plugin_reinstall(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "plugin" and (#ARGS == 2 or ARGS[3] == "list") then return lpm_plugin_list(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "plugin" and ARGS[3] == "upgrade" then return lpm_plugin_upgrade(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "upgrade" then return lpm_plugin_upgrade(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "install" then lpm_install(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "uninstall" then lpm_plugin_uninstall(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "reinstall" then lpm_plugin_reinstall(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "describe" then lpm_describe(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "list" then return lpm_plugin_list(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "lite-xl" and (#ARGS == 2 or ARGS[3] == "list") then return lpm_lite_xl_list(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "lite-xl" and ARGS[3] == "uninstall" then return lpm_lite_xl_uninstall(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "lite-xl" and ARGS[3] == "install" then return lpm_lite_xl_install(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "lite-xl" and ARGS[3] == "switch" then return lpm_lite_xl_switch(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "lite-xl" and ARGS[3] == "run" then return lpm_lite_xl_run(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "lite-xl" and ARGS[3] == "add" then return lpm_lite_xl_add(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "lite-xl" and ARGS[3] == "rm" then return lpm_lite_xl_rm(table.unpack(common.slice(ARGS, 4))) + elseif ARGS[2] == "run" then return lpm_lite_xl_run(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "switch" then return lpm_lite_xl_switch(table.unpack(common.slice(ARGS, 3))) + elseif ARGS[2] == "purge" then lpm_purge() + else error("unknown command: " .. ARGS[2]) end + if JSON then + io.stdout:write(json.encode({ actions = actions, warnings = warnings })) + end +end + + +xpcall(function() + local ARGS = parse_arguments(ARGV, { + json = "flag", userdir = "string", cachedir = "string", version = "flag", verbose = "flag", + quiet = "flag", version = "string", ["mod-version"] = "string", remotes = "flag", help = "flag", + remotes = "flag", ssl_certs = "string", force = "flag", arch = "string", ["assume-yes"] = "flag", + ["install-optional"] = "flag" + }) + if ARGS["version"] then + io.stdout:write(VERSION .. "\n") + return 0 + end + if ARGS["help"] or #ARGS == 1 or ARGS[2] == "help" then + io.stderr:write([[ +Usage: lpm COMMAND [...ARGUMENTS] [--json] [--userdir=directory] + [--cachedir=directory] [--quiet] [--version] [--help] [--remotes] + [--ssl_certs=directory/file] [--force] [--arch=]] .. _G.ARCH .. [[] + [--assume-yes] [--no-install-optional] [--verbose] [--mod-version=3] + +LPM is a package manager for `lite-xl`, written in C (and packed-in lua). + +It's designed to install packages from our central github repository (and +affiliated repositories), directly into your lite-xl user directory. It can +be called independently, for from the lite-xl `plugin_manager` plugin. + +LPM will always use https://github.com/lite-xl/lite-xl-plugins as its base +repository, if none are present, and the cache directory does't exist, +but others can be added, and this base one can be removed. + +It has the following commands: + + lpm init Implicitly called before all commands + if necessary, but can be called + independently to save time later. + lpm repo list List all extant repos. + lpm [repo] add <repository remote> Add a source repository. + [...<repository remote>] + lpm [repo] rm <repository remote> Remove a source repository. + [...<repository remote>] + lpm [repo] update [<repository remote>] Update all/the specified repos. + [...<repository remote>] + lpm [plugin] install Install specific plugins. + <plugin name>[:<version>] If installed, upgrades. + [...<plugin name>:<version>] + lpm [plugin] uninstall <plugin name> Uninstall the specific plugin. + [...<plugin name>] + lpm [plugin] reinstall <plugin name> Uninstall and installs the specific plugin. + [...<plugin name>] + lpm [plugin] list <repository remote> List all/associated plugins. + [...<repository remote>] + lpm [plugin] upgrade Upgrades all installed plugins + to new version if applicable. + lpm [lite-xl] install <version> Installs lite-xl. Infers the + [binary] [datadir] paths on your system if not + supplied. Automatically + switches to be your system default + if path auto inferred. + lpm lite-xl add <version> <path> Adds a local version of lite-xl to + the managed list, allowing it to be + easily bottled. + lpm lite-xl remove <path> Removes a local version of lite-xl + from the managed list. + lpm [lite-xl] switch <version> [<path>] Sets the active version of lite-xl + to be the specified version. Auto-detects + current install of lite-xl; if none found + path can be specifeid. + lpm lite-xl list Lists all installed versions of + lite-xl. + lpm run <version> [...plugins] Sets up a "bottle" to run the specified + lite version, with the specified plugins + and then opens it. + lpm describe [bottle] Describes the bottle specified in the form + of a list of commands, that allow someone + else to run your configuration. + + lpm purge Completely purge all state for LPM. + lpm - Read these commands from stdin in + an interactive print-eval loop. + lpm help Displays this help text. + +Flags have the following effects: + + --json Performs all communication in JSON. + --userdir=directory Sets the lite-xl userdir manually. + If omitted, uses the normal lite-xl logic. + --cachedir=directory Sets the directory to store all repositories. + --tmpdir=directory During install, sets the staging area. + --verbose Spits out more information, including intermediate + steps to install and whatnot. + --quiet Outputs nothing but explicit responses. + --mod-version Sets the mod version of lite-xl to install plugins. + --version Returns version information. + --remotes Automatically adds any specified remotes in the + repository to the end of the resolution list. + This is a potential security risk, so be careful. + --help Displays this help text. + --ssl_certs Sets the SSL certificate store. + --arch Sets the architecture (default: ]] .. _G.ARCH .. [[). + --force Ignores checksum inconsitencies. + Not recommended; security risk. + --assume-yes Ignores any prompts, and automatically answers yes + to all. + --no-install-optional On install, anything marked as optional + won't prompt. +]] + ) + return 0 + end + + VERBOSE = ARGS["verbose"] or false + JSON = ARGS["json"] or os.getenv("LPM_JSON") + QUIET = ARGS["quiet"] or os.getenv("LPM_QUIET") + FORCE = ARGS["force"] + NO_INSTALL_OPTIONAL = ARGS["no-install-optional"] + ARCH = ARGS["arch"] or _G.ARCH + ASSUME_YES = ARGS["assume-yes"] or FORCE + MOD_VERSION = ARGS["mod-version"] or os.getenv("LPM_MODVERSION") + if MOD_VERSION == "any" then MOD_VERSION = nil end + HOME = (os.getenv("USERPROFILE") or os.getenv("HOME")):gsub(PATHSEP .. "$", "") + USERDIR = ARGS["userdir"] or os.getenv("LITE_USERDIR") or (os.getenv("XDG_CONFIG_HOME") and os.getenv("XDG_CONFIG_HOME") .. PATHSEP .. "lite-xl") + or (HOME and (HOME .. PATHSEP .. '.config' .. PATHSEP .. 'lite-xl')) + AUTO_PULL_REMOTES = ARGS["remotes"] + if not system.stat(USERDIR) then error("can't find user directory " .. USERDIR) end + CACHEDIR = ARGS["cachedir"] or os.getenv("LPM_CACHE") or USERDIR .. PATHSEP .. "lpm" + TMPDIR = ARGS["tmpdir"] or CACHEDIR .. "/tmp" + + repositories = {} + if ARGS[2] == "purge" then return lpm_purge() end + if ARGS["ssl_certs"] then + local stat = system.stat(ARGS["ssl_certs"]) + if not stat then error("can't find " .. ARGS["ssl_certs"]) end + system.certs(stat.type, ARGS["ssl_certs"]) + elseif not os.getenv("SSL_CERT_DIR") and not os.getenv("SSL_CERT_FILE") then + local paths = { -- https://serverfault.com/questions/62496/ssl-certificate-location-on-unix-linux#comment1155804_62500 + "/etc/ssl/certs/ca-certificates.crt", -- Debian/Ubuntu/Gentoo etc. + "/etc/pki/tls/certs/ca-bundle.crt", -- Fedora/RHEL 6 + "/etc/ssl/ca-bundle.pem", -- OpenSUSE + "/etc/pki/tls/cacert.pem", -- OpenELEC + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", -- CentOS/RHEL 7 + "/etc/ssl/cert.pem", -- Alpine Linux + "/etc/ssl/certs", -- SLES10/SLES11, https://golang.org/issue/12139 + "/system/etc/security/cacerts", -- Android + "/usr/local/share/certs", -- FreeBSD + "/etc/pki/tls/certs", -- Fedora/RHEL + "/etc/openssl/certs", -- NetBSD + "/var/ssl/certs", -- AIX + } + for i, path in ipairs(paths) do + local stat = system.stat(path) + if stat then + system.certs(stat.type, path) + break + end + end + end + + + -- Base setup; initialize default repos if applicable, read them in. Determine Lite XL system binary if not specified, and pull in a list of all local lite-xl's. + lpm_repo_init() + repositories = {} + for i, remote_hash in ipairs(system.ls(CACHEDIR .. PATHSEP .. "repos")) do + local remote + for j, commit_or_branch in ipairs(system.ls(CACHEDIR .. PATHSEP .. "repos" .. PATHSEP .. remote_hash)) do + if system.stat(CACHEDIR .. PATHSEP .. "repos" .. PATHSEP .. remote_hash .. PATHSEP .. commit_or_branch .. PATHSEP .. ".git" .. PATHSEP .."config") then + for line in io.lines(CACHEDIR .. PATHSEP .. "repos" .. PATHSEP .. remote_hash .. PATHSEP .. commit_or_branch .. PATHSEP .. ".git" .. PATHSEP .."config") do + local s,e = line:find("url = ") + if s then remote = line:sub(e+1) break end + end + if remote then + table.insert(repositories, Repository.url(remote .. ":" .. commit_or_branch)) + repositories[#repositories]:parse_manifest() + end + end + end + end + + lite_xls = {} + if system.stat(CACHEDIR .. PATHSEP .. "lite_xls" .. PATHSEP .. "locals.json") then + for i, lite_xl in ipairs(json.decode(io.open(CACHEDIR .. PATHSEP .. "lite_xls" .. PATHSEP .. "locals.json", "rb"):read("*all"))) do + table.insert(lite_xls, LiteXL.new(nil, { version = lite_xl.version, mod_version = lite_xl.mod_version, path = lite_xl.path, tags = { "local" } })) + end + end + local lite_xl_binary = common.path("lite-xl") + if lite_xl_binary then + lite_xl_binary = system.stat(lite_xl_binary).symlink or lite_xl_binary + local directory = common.dirname(lite_xl_binary) + local hash = system.hash(lite_xl_binary, "file") + local system_lite_xl = common.first(common.concat(common.flat_map(repositories, function(r) return r.lite_xls end), lite_xls), function(lite_xl) return lite_xl.local_path == directory end) + if not system_lite_xl then + if #common.grep(lite_xls, function(e) return e.version == "system" end) > 0 then error("can't create new system lite, please `lpm rm lite-xl system`, or resolve otherwise") end + system_lite_xl = LiteXL.new(nil, { path = directory, mod_version = 3, version = "system", tags = { "system", "local" } }) + table.insert(lite_xls, system_lite_xl) + lpm_lite_xl_save() + else + table.insert(system_lite_xl.tags, "system") + end + system_bottle = Bottle.new(system_lite_xl, nil, true) + end + if not system_bottle then system_bottle = Bottle.new(nil, nil, true) end + + if ARGS[2] ~= '-' then + run_command(ARGS) + else + while true do + local line = io.stdin:read("*line") + if line == "quit" or line == "exit" then return 0 end + local args = { ARGS[1] } + local s = 1 + while true do + local a,e = line:find("%s+", s) + table.insert(args, line:sub(s, a and (a - 1) or #line)) + if not e then break end + s = e + 1 + end + xpcall(function() + run_command(args) + end, error_handler) + actions, warnings = {}, {} + end + end + +end, error_handler) + + +return status |