feat(cli): remove template for init

This commit is contained in:
Nurul Huda (Apon) 2026-05-31 18:13:45 +06:00
parent 1ff589506a
commit 6ed681c651
No known key found for this signature in database
GPG Key ID: 5D3F1DE2855A2F79
3 changed files with 234 additions and 9 deletions

View File

@ -15,7 +15,7 @@ pub fn register(writer: *std.Io.Writer, reader: *std.Io.Reader, allocator: std.m
const template_flag = zli.Flag{
.name = "template",
.shortcut = "t",
.description = "Template to use (default, docker)",
.description = "Template to use: a builtin (default, docker) or any github:ziex-dev/template-<name> (e.g. cloudflare, vercel)",
.type = .String,
.default_value = .{ .String = "default" },
};
@ -87,13 +87,24 @@ fn init(ctx: zli.CommandContext) !void {
std.debug.print("{s}Initializing with existing files, overriding if files already exist.{s}\n", .{ colors.yellow, colors.reset });
}
const template_name = if (std.meta.stringToEnum(TemplateFile.Name, t_val)) |name| name else {
std.debug.print("\x1b[33mUnknown template:\x1b[0m {s}\n\nTemplates:\n", .{t_val});
// Non-builtin templates are fetched from `github:ziex-dev/template-<name>`.
const template_name = std.meta.stringToEnum(TemplateFile.Name, t_val) orelse {
printer.header("{s} Initializing ZX project!", .{tui.Printer.emoji("")});
printer.info("[{s}] (github:{s}/template-{s})", .{ t_val, "ziex-dev", t_val });
for (std.enums.values(TemplateFile.Name)) |name| {
std.debug.print(" - \x1b[34m{s}\x1b[0m\n", .{@tagName(name)});
}
std.debug.print("\n", .{});
remote.fetch(io, ctx.allocator, t_val, init_path) catch |err| switch (err) {
error.TemplateNotFound, error.NoReleaseFound => {
printer.warning("Template '{s}' not found in the ziex-dev org (looked for github:ziex-dev/template-{s} with a published release).", .{ t_val, t_val });
return;
},
error.NetworkError => {
printer.warning("Failed to download template '{s}'. Check your network connection and try again.", .{t_val});
return;
},
else => return err,
};
try printInitFooter(&printer, ctx.allocator, init_path, has_init_path_arg);
return;
};
@ -147,9 +158,13 @@ fn init(ctx: zli.CommandContext) !void {
try file.writeStreamingAll(io, content);
}
try printInitFooter(&printer, ctx.allocator, init_path, has_init_path_arg);
}
fn printInitFooter(printer: *tui.Printer, allocator: std.mem.Allocator, init_path: []const u8, has_init_path_arg: bool) !void {
if (has_init_path_arg) {
const suggested_cmd = try std.fmt.allocPrint(ctx.allocator, "cd {s} && zig build dev", .{init_path});
defer ctx.allocator.free(suggested_cmd);
const suggested_cmd = try std.fmt.allocPrint(allocator, "cd {s} && zig build dev", .{init_path});
defer allocator.free(suggested_cmd);
printer.footer("Now run {s}\n\n{s}{s}{s}", .{ tui.Printer.emoji(""), colors.cyan, suggested_cmd, colors.reset });
} else {
printer.footer("Now run {s}\n\n{s}", .{ tui.Printer.emoji(""), colors.Fns.cyan("zig build dev") });
@ -228,6 +243,7 @@ const templates = app_template.files;
const std = @import("std");
const app_template = @import("app_template");
const remote = @import("init/remote.zig");
const zli = @import("zli");
const tui = @import("../tui/main.zig");
const AppContext = @import("shared/context.zig").AppContext;

183
src/cli/init/remote.zig Normal file
View File

@ -0,0 +1,183 @@
const org = "ziex-dev";
pub fn fetch(
io: std.Io,
allocator: std.mem.Allocator,
template: []const u8,
dest_dir: []const u8,
) !void {
var client: std.http.Client = .{ .allocator = allocator, .io = io };
defer client.deinit();
const repo = try std.fmt.allocPrint(allocator, "template-{s}", .{template});
defer allocator.free(repo);
const tag = try fetchLatestReleaseTag(&client, allocator, repo);
defer allocator.free(tag);
// codeload serves the gzipped source tarball for a given ref.
const tarball_url = try std.fmt.allocPrint(
allocator,
"https://codeload.github.com/{s}/{s}/tar.gz/refs/tags/{s}",
.{ org, repo, tag },
);
defer allocator.free(tarball_url);
const gz = try get(&client, allocator, tarball_url);
defer allocator.free(gz);
try std.Io.Dir.cwd().createDirPath(io, dest_dir);
var dir = try std.Io.Dir.cwd().openDir(io, dest_dir, .{});
defer dir.close(io);
var gz_reader: std.Io.Reader = .fixed(gz);
var decompress_buf: [std.compress.flate.max_window_len]u8 = undefined;
var decompress: std.compress.flate.Decompress = .init(&gz_reader, .gzip, &decompress_buf);
try std.tar.extract(io, dir, &decompress.reader, .{
.strip_components = 1,
.mode_mode = .executable_bit_only,
});
try customize(io, allocator, dest_dir);
}
/// Query the GitHub API for the latest release tag of `repo`.
fn fetchLatestReleaseTag(
client: *std.http.Client,
allocator: std.mem.Allocator,
repo: []const u8,
) ![]const u8 {
const url = try std.fmt.allocPrint(
allocator,
"https://api.github.com/repos/{s}/{s}/releases/latest",
.{ org, repo },
);
defer allocator.free(url);
const body = try get(client, allocator, url);
defer allocator.free(body);
const parsed = std.json.parseFromSlice(struct {
tag_name: []const u8,
}, allocator, body, .{ .ignore_unknown_fields = true }) catch return error.NoReleaseFound;
defer parsed.deinit();
return allocator.dupe(u8, parsed.value.tag_name);
}
fn get(client: *std.http.Client, allocator: std.mem.Allocator, url: []const u8) ![]u8 {
var aw = std.Io.Writer.Allocating.init(allocator);
defer aw.deinit();
const result = client.fetch(.{
.location = .{ .url = url },
.method = .GET,
.redirect_behavior = @enumFromInt(5),
.response_writer = &aw.writer,
.extra_headers = &.{
.{ .name = "user-agent", .value = "ziex-cli" },
.{ .name = "accept", .value = "application/vnd.github+json" },
},
}) catch return error.NetworkError;
switch (result.status) {
.ok => {},
.not_found => return error.TemplateNotFound,
else => return error.NetworkError,
}
return allocator.dupe(u8, aw.written());
}
fn customize(io: std.Io, allocator: std.mem.Allocator, dest_dir: []const u8) !void {
const project_name = try sanitizeProjectName(allocator, std.fs.path.basename(dest_dir));
defer allocator.free(project_name);
var dir = try std.Io.Dir.cwd().openDir(io, dest_dir, .{ .iterate = true });
defer dir.close(io);
try replaceInTree(io, allocator, dir, "ziex_app", project_name);
try updateFingerprint(io, allocator, dest_dir, project_name);
}
fn replaceInTree(
io: std.Io,
allocator: std.mem.Allocator,
dir: std.Io.Dir,
from: []const u8,
to: []const u8,
) !void {
var walker = try dir.walk(allocator);
defer walker.deinit();
while (try walker.next(io)) |entry| {
if (entry.kind != .file) continue;
const data = entry.dir.readFileAlloc(io, entry.basename, allocator, .limited(8 * 1024 * 1024)) catch continue;
defer allocator.free(data);
if (std.mem.indexOf(u8, data, from) == null) continue;
const replaced = try std.mem.replaceOwned(u8, allocator, data, from, to);
defer allocator.free(replaced);
try entry.dir.writeFile(io, .{ .sub_path = entry.basename, .data = replaced });
}
}
fn updateFingerprint(
io: std.Io,
allocator: std.mem.Allocator,
dest_dir: []const u8,
project_name: []const u8,
) !void {
const zon_path = try std.fs.path.join(allocator, &.{ dest_dir, "build.zig.zon" });
defer allocator.free(zon_path);
const data = std.Io.Dir.cwd().readFileAlloc(io, zon_path, allocator, .limited(1024 * 1024)) catch return;
defer allocator.free(data);
const needle = ".fingerprint = 0x";
const start = std.mem.indexOf(u8, data, needle) orelse return;
const hex_start = start + needle.len;
var hex_end = hex_start;
while (hex_end < data.len and std.ascii.isHex(data[hex_end])) hex_end += 1;
const fingerprint = Fingerprint.generate(io, project_name);
var out = std.Io.Writer.Allocating.init(allocator);
defer out.deinit();
try out.writer.writeAll(data[0..start]);
try out.writer.print(".fingerprint = 0x{x:0>16}", .{fingerprint.int()});
try out.writer.writeAll(data[hex_end..]);
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = zon_path, .data = out.written() });
}
const Fingerprint = packed struct(u64) {
id: u32,
checksum: u32,
fn generate(io: std.Io, name: []const u8) Fingerprint {
var source: std.Random.IoSource = .{ .io = io };
return .{
.id = source.interface().intRangeLessThan(u32, 1, 0xffffffff),
.checksum = std.hash.Crc32.hash(name),
};
}
fn int(n: Fingerprint) u64 {
return @bitCast(n);
}
};
fn sanitizeProjectName(allocator: std.mem.Allocator, name: []const u8) ![]const u8 {
const out = try allocator.alloc(u8, name.len);
for (name, 0..) |c, i| {
out[i] = if (std.ascii.isAlphanumeric(c) or c == '_') c else '_';
}
return out;
}
const std = @import("std");

View File

@ -233,6 +233,32 @@ test "init -t docker" {
});
}
test "init -t <remote>" {
if (!test_util.shouldRunSlowTest()) return error.SkipZigTest;
// Fetches github:ziex-dev/template-cloudflare, renames the project to the
// target directory name, and regenerates the build.zig.zon fingerprint.
try test_cmd(.{
.args = &.{ "init", "--template", "cloudflare", "remote-app", "--force" },
.expected_exit_code = 0,
.expected_stderr_strings = &.{
"Initializing ZX project!",
"github:ziex-dev/template-cloudflare",
},
.expected_files = &.{
"remote-app/build.zig.zon",
"remote-app/build.zig",
"remote-app/app/main.zig",
},
.expected_file_contains = &.{
// Project renamed from ziex_app to the directory name.
.{ .path = "remote-app/build.zig.zon", .needle = ".name = .remote_app" },
},
.expected_file_excludes = &.{
.{ .path = "remote-app/build.zig.zon", .needle = "ziex_app" },
},
});
}
test "fmt" {
try test_cmd(.{
.args = &.{ "fmt", "app" ++ std.fs.path.sep_str ++ "pages" },