325 lines
10 KiB
Plaintext
Executable File
325 lines
10 KiB
Plaintext
Executable File
#!/usr/bin/env lua
|
|
-- zeta-cli — interactive recipe creation wizard for zeta-makepkg.
|
|
--
|
|
-- Walks through every recipe field with prompts, validation, and
|
|
-- smart defaults. Writes a ready-to-use .recipe file.
|
|
|
|
local here
|
|
if arg and arg[0] and arg[0]:match("[/\\]") then
|
|
here = arg[0]:match("^(.*)[/\\][^/\\]*$")
|
|
elseif arg and arg[-1] then
|
|
here = arg[-1]:match("^(.*)[/\\][^/\\]*$")
|
|
else
|
|
here = "."
|
|
end
|
|
if here then
|
|
local f = io.popen("cd " .. here:gsub("'", "'\\''") .. " 2>/dev/null && pwd")
|
|
if f then local real = f:read("*l"); f:close(); if real and real ~= "" then here = real end end
|
|
end
|
|
|
|
-- Library path: works both from the repo tree (../lib/) and when installed
|
|
-- via 'make install' (/usr/local/lib/zeta-toolchain/).
|
|
package.path = here .. "/../lib/?.lua;"
|
|
.. here .. "/../lib/zeta-toolchain/?.lua;"
|
|
.. package.path
|
|
|
|
local recipe = require("recipe")
|
|
local path = require("path")
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Terminal helpers
|
|
---------------------------------------------------------------------------
|
|
|
|
local function bold(s) return "\27[1m" .. s .. "\27[0m" end
|
|
local function dim(s) return "\27[2m" .. s .. "\27[0m" end
|
|
local function green(s) return "\27[32m" .. s .. "\27[0m" end
|
|
local function red(s) return "\27[31m" .. s .. "\27[0m" end
|
|
local function cyan(s) return "\27[36m" .. s .. "\27[0m" end
|
|
|
|
local function prompt(label, default, validator)
|
|
local suffix = ""
|
|
if default and default ~= "" then
|
|
suffix = dim(" [" .. default .. "]")
|
|
end
|
|
io.write(label .. suffix .. ": ")
|
|
io.flush()
|
|
local line = io.read("*l")
|
|
if not line then
|
|
io.write("\n")
|
|
os.exit(0)
|
|
end
|
|
line = line:gsub("^%s+", ""):gsub("%s+$", "")
|
|
if line == "" and default then line = default end
|
|
if validator then
|
|
local ok, err = validator(line)
|
|
if not ok then
|
|
io.write(red(" " .. err .. "\n"))
|
|
return prompt(label, default, validator)
|
|
end
|
|
end
|
|
return line
|
|
end
|
|
|
|
local function confirm(msg)
|
|
io.write(msg .. " [Y/n] ")
|
|
io.flush()
|
|
local line = io.read("*l")
|
|
if not line then io.write("\n"); os.exit(0) end
|
|
line = line:gsub("^%s+", ""):gsub("%s+$", ""):lower()
|
|
return line == "" or line == "y" or line == "yes"
|
|
end
|
|
|
|
local function choose(label, options)
|
|
io.write(bold(label) .. "\n")
|
|
for i, opt in ipairs(options) do
|
|
io.write(string.format(" %d) %s\n", i, opt))
|
|
end
|
|
io.write(" Choice [1]: ")
|
|
io.flush()
|
|
local line = io.read("*l")
|
|
if not line then io.write("\n"); os.exit(0) end
|
|
line = line:gsub("^%s+", ""):gsub("%s+$", "")
|
|
local n = tonumber(line)
|
|
if not n or n < 1 or n > #options then n = 1 end
|
|
return options[n]
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Field validators
|
|
---------------------------------------------------------------------------
|
|
|
|
local function valid_name(name)
|
|
if name == "" then return false, "name cannot be empty" end
|
|
if not path.sanitize_name(name) then
|
|
return false, "name must contain only alphanumeric characters, dots, underscores, plus, and hyphens"
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function valid_version(v)
|
|
if v == "" then return false, "version cannot be empty" end
|
|
return true
|
|
end
|
|
|
|
local function valid_summary(s)
|
|
if s == "" then return false, "summary cannot be empty" end
|
|
return true
|
|
end
|
|
|
|
local function valid_url(u)
|
|
if u == "" then return false, "url cannot be empty" end
|
|
return true
|
|
end
|
|
|
|
local function valid_sha256(h)
|
|
if h == "" then return true end -- ok to skip
|
|
if not h:lower():match("^" .. string.rep("%x", 64) .. "$") then
|
|
return false, "sha256 must be exactly 64 hex characters (or press Enter to skip)"
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function valid_jobs(j)
|
|
if j == "" then return true end
|
|
local n = tonumber(j)
|
|
if not n or n < 1 or n % 1 ~= 0 then
|
|
return false, "jobs must be a positive integer (or press Enter to skip)"
|
|
end
|
|
return true
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Render the recipe as Lua source
|
|
---------------------------------------------------------------------------
|
|
|
|
local function render(rcp)
|
|
local lines = { "return {" }
|
|
local function e(s) return s:gsub('"', '\\"'):gsub("\n", "\\n") end
|
|
|
|
lines[#lines + 1] = string.format(' name = "%s",', e(rcp.name))
|
|
lines[#lines + 1] = string.format(' version = "%s",', e(rcp.version))
|
|
lines[#lines + 1] = string.format(' summary = "%s",', e(rcp.summary))
|
|
lines[#lines + 1] = string.format(' url = "%s",', e(rcp.url))
|
|
|
|
if rcp.sha256 and rcp.sha256 ~= "" then
|
|
lines[#lines + 1] = string.format(' sha256 = "%s",', rcp.sha256)
|
|
else
|
|
lines[#lines + 1] = " sha256 = nil,"
|
|
end
|
|
|
|
if #rcp.deps > 0 then
|
|
local d = {}
|
|
for _, dep in ipairs(rcp.deps) do d[#d+1] = '"' .. e(dep) .. '"' end
|
|
lines[#lines + 1] = string.format(" deps = { %s },", table.concat(d, ", "))
|
|
else
|
|
lines[#lines + 1] = " deps = {},"
|
|
end
|
|
|
|
lines[#lines + 1] = string.format(' build_system = "%s",', rcp.build_system)
|
|
|
|
if rcp.configure_args and #rcp.configure_args > 0 then
|
|
local a = {}
|
|
for _, arg in ipairs(rcp.configure_args) do a[#a+1] = '"' .. e(arg) .. '"' end
|
|
lines[#lines + 1] = string.format(" configure_args = { %s },", table.concat(a, ", "))
|
|
end
|
|
|
|
if rcp.build_system == "custom" then
|
|
lines[#lines + 1] = string.format(' build_script = "%s",', e(rcp.build_script or "build.sh"))
|
|
end
|
|
|
|
if rcp.test and rcp.test ~= "" then
|
|
lines[#lines + 1] = string.format(' test = "%s",', e(rcp.test):gsub("${DESTDIR}", "${DESTDIR}"))
|
|
end
|
|
|
|
if rcp.files and #rcp.files > 0 then
|
|
local f = {}
|
|
for _, file in ipairs(rcp.files) do f[#f+1] = '"' .. e(file) .. '"' end
|
|
lines[#lines + 1] = string.format(" files = { %s },", table.concat(f, ", "))
|
|
end
|
|
|
|
lines[#lines + 1] = "}"
|
|
return table.concat(lines, "\n") .. "\n"
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Wizard
|
|
---------------------------------------------------------------------------
|
|
|
|
local function wizard()
|
|
io.write("\n")
|
|
io.write(bold(" ═══ zeta-makepkg recipe wizard ═══") .. "\n\n")
|
|
|
|
local rcp = {}
|
|
|
|
-- 1. Name
|
|
rcp.name = prompt("Package name", nil, valid_name)
|
|
|
|
-- 2. Version
|
|
rcp.version = prompt("Version", nil, valid_version)
|
|
|
|
-- 3. Summary
|
|
rcp.summary = prompt("Summary", nil, valid_summary)
|
|
|
|
-- 4. URL
|
|
rcp.url = prompt("Source URL", nil, valid_url)
|
|
|
|
-- 5. SHA256
|
|
rcp.sha256 = prompt("Source SHA256", "", valid_sha256)
|
|
|
|
-- 6. Dependencies
|
|
io.write("Dependencies" .. dim(" [comma-separated, e.g. libfoo, libbar>=2.0]") .. ": ")
|
|
io.flush()
|
|
local deps_line = io.read("*l")
|
|
if not deps_line then io.write("\n"); os.exit(0) end
|
|
deps_line = deps_line:gsub("^%s+", ""):gsub("%s+$", "")
|
|
rcp.deps = {}
|
|
if deps_line ~= "" then
|
|
for raw in deps_line:gmatch("[^,]+") do
|
|
local dep = raw:gsub("^%s+", ""):gsub("%s+$", "")
|
|
if dep ~= "" then rcp.deps[#rcp.deps + 1] = dep end
|
|
end
|
|
end
|
|
|
|
-- 7. Build system
|
|
rcp.build_system = choose("Build system:", {
|
|
"autotools (./configure && make && make install)",
|
|
"cmake (cmake -B build && cmake --build && cmake --install)",
|
|
"meson (meson setup build && ninja && ninja install)",
|
|
"make (make && make install)",
|
|
"cargo (cargo build --release)",
|
|
"zig (zig build && zig build install --prefix /usr)",
|
|
"build.sh (sh build.sh with DESTDIR staging)",
|
|
"custom (run build_script)",
|
|
})
|
|
|
|
-- Map the selected string back to the key
|
|
rcp.build_system = rcp.build_system:match("^(%S+)")
|
|
|
|
-- 8. Configure args (skip for cargo)
|
|
rcp.configure_args = {}
|
|
if rcp.build_system ~= "cargo" and rcp.build_system ~= "build.sh" then
|
|
io.write("Configure args" .. dim(" [comma-separated, e.g. --enable-feature, --without-x]") .. ": ")
|
|
io.flush()
|
|
local args_line = io.read("*l")
|
|
if not args_line then io.write("\n"); os.exit(0) end
|
|
args_line = args_line:gsub("^%s+", ""):gsub("%s+$", "")
|
|
if args_line ~= "" then
|
|
for raw in args_line:gmatch("[^,]+") do
|
|
local arg = raw:gsub("^%s+", ""):gsub("%s+$", "")
|
|
if arg ~= "" then rcp.configure_args[#rcp.configure_args + 1] = arg end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 9. Build script (custom only)
|
|
if rcp.build_system == "custom" then
|
|
rcp.build_script = prompt("Build script path", "build.sh")
|
|
end
|
|
|
|
-- 10. Test command
|
|
io.write("Test command" .. dim(" [${DESTDIR} = install staging dir, Enter to skip]") .. "\n")
|
|
io.write(" Example: ${DESTDIR}/usr/bin/" .. rcp.name .. " --version\n")
|
|
rcp.test = prompt(" Test", "")
|
|
if rcp.test == "" then rcp.test = nil end
|
|
|
|
-- 11. Files whitelist
|
|
io.write("Files whitelist" .. dim(" [comma-separated relative paths, Enter = all files]") .. "\n")
|
|
io.write(" Example: usr/bin/" .. rcp.name .. ", usr/lib/lib" .. rcp.name .. ".so\n")
|
|
io.write(" Files: ")
|
|
io.flush()
|
|
local files_line = io.read("*l")
|
|
if not files_line then io.write("\n"); os.exit(0) end
|
|
files_line = files_line:gsub("^%s+", ""):gsub("%s+$", "")
|
|
if files_line ~= "" then
|
|
rcp.files = {}
|
|
for raw in files_line:gmatch("[^,]+") do
|
|
local file = raw:gsub("^%s+", ""):gsub("%s+$", ""):gsub("^/*", "")
|
|
if file ~= "" then rcp.files[#rcp.files + 1] = file end
|
|
end
|
|
end
|
|
|
|
-- 12. Preview
|
|
io.write("\n")
|
|
io.write(bold(" ═══ Preview ═══") .. "\n\n")
|
|
io.write(render(rcp))
|
|
io.write("\n")
|
|
|
|
if not confirm("Write this recipe?") then
|
|
io.write("Aborted.\n")
|
|
os.exit(0)
|
|
end
|
|
|
|
-- 13. Output path
|
|
local default_path = rcp.name .. ".recipe"
|
|
local out_path = prompt("Save as", default_path)
|
|
|
|
local f, err = io.open(out_path, "w")
|
|
if not f then
|
|
io.write(red("Error: ") .. tostring(err) .. "\n")
|
|
os.exit(1)
|
|
end
|
|
f:write(render(rcp))
|
|
f:close()
|
|
|
|
-- Final validation pass
|
|
local ok, verr = pcall(recipe.load, out_path)
|
|
if not ok then
|
|
io.write(red("Warning: generated recipe fails validation: ") .. tostring(verr) .. "\n")
|
|
end
|
|
|
|
io.write("\n" .. green(" recipe written to " .. out_path) .. "\n")
|
|
end
|
|
|
|
---------------------------------------------------------------------------
|
|
-- Main
|
|
---------------------------------------------------------------------------
|
|
|
|
local args = {}
|
|
for i = 1, #arg do args[i] = arg[i] end
|
|
|
|
local ok, err = pcall(wizard)
|
|
if not ok then
|
|
io.write(red("Error: ") .. tostring(err) .. "\n")
|
|
os.exit(1)
|
|
end
|