///|
let cli_current_version : String = "0.1.12"
///|
let cli_manifest_url : String = "https://mooncakes.io/api/v0/manifest/justjavac/proton_cli"
///|
priv struct CliVersion {
major : Int
minor : Int
patch : Int
}
///|
priv suberror CliUsageError {
Invalid(message~ : String)
} derive(Debug)
///|
priv suberror CliCodegenOutputError {
WriteFailed(path~ : String, detail~ : String)
} derive(Debug)
///|
fn CliUsageError::message(self : CliUsageError) -> String {
match self {
Invalid(message~) => message
}
}
///|
fn CliCodegenOutputError::message(self : CliCodegenOutputError) -> String {
match self {
WriteFailed(path~, detail~) =>
"failed to write generated source " + path + ": " + detail
}
}
///|
impl Show for CliUsageError with fn output(self, logger) {
logger.write_string(self.message())
}
///|
impl Show for CliCodegenOutputError with fn output(self, logger) {
logger.write_string(self.message())
}
///|
fn env_flag_is_enabled(value : String) -> Bool {
match value.trim().to_owned().to_lower() {
"" | "0" | "false" | "no" | "off" => false
_ => true
}
}
///|
fn cli_update_check_disabled() -> Bool {
match @sys.get_env_var("PROTON_NO_UPDATE_CHECK") {
Some(value) => env_flag_is_enabled(value)
None => false
}
}
///|
fn parse_cli_version_part(part : StringView) -> Int? {
let text = part.to_owned()
guard text != "" else { return None }
let mut value = 0
for ch in text {
guard ch.is_ascii_digit() else { return None }
value = value * 10 + ch.to_int() - '0'.to_int()
}
Some(value)
}
///|
fn parse_cli_version(version : String) -> CliVersion? {
let core = match version.split_once("-") {
Some((prefix, _)) => prefix.to_owned()
None => version
}
match core.split(".").collect() {
[major, minor, patch] =>
match
(
parse_cli_version_part(major),
parse_cli_version_part(minor),
parse_cli_version_part(patch),
) {
(Some(major), Some(minor), Some(patch)) =>
Some(CliVersion::{ major, minor, patch })
_ => None
}
_ => None
}
}
///|
fn compare_cli_version(left : CliVersion, right : CliVersion) -> Int {
if left.major != right.major {
left.major.compare(right.major)
} else if left.minor != right.minor {
left.minor.compare(right.minor)
} else {
left.patch.compare(right.patch)
}
}
///|
fn cli_version_is_newer(candidate : String, current : String) -> Bool {
match (parse_cli_version(candidate), parse_cli_version(current)) {
(Some(candidate), Some(current)) =>
compare_cli_version(candidate, current) > 0
_ => false
}
}
///|
fn latest_version_from_manifest_text(text : String) -> String? {
let manifest = @json.parse(text) catch { _ => return None }
match manifest {
Object(fields) =>
match fields.get("latest_version") {
Some(String(version)) => Some(version)
_ => None
}
_ => None
}
}
///|
fn cli_version_text() -> String {
"proton_cli " + cli_current_version
}
///|
async fn fetch_latest_cli_version() -> String? {
let (code, output) = @process.collect_output_merged("curl", [
"-fsSL", "--max-time", "2", cli_manifest_url,
]) catch {
_ => return None
}
guard code == 0 else { return None }
let text = output.text() catch { _ => return None }
latest_version_from_manifest_text(text)
}
///|
async fn check_cli_update() -> Unit {
guard !cli_update_check_disabled() else { return }
match fetch_latest_cli_version() {
Some(latest) =>
if cli_version_is_newer(latest, cli_current_version) {
@output.write_stderr_line(
"warning: proton_cli " +
latest +
" is available (current " +
cli_current_version +
"). Set PROTON_NO_UPDATE_CHECK=1 to disable this check.",
)
}
None => ()
}
}
///|
async fn print_error_message(message : String) -> Unit {
if message.has_prefix("error: ") {
@output.write_stderr_line(message)
} else {
@output.write_stderr_line("error: " + message)
}
}
///|
fn codegen_command() -> @argparse.Command {
@argparse.Command(
"codegen",
about="Generate typed Proton command registrars from #proton attributes",
flags=[
FlagArg(
"extension-identity",
about="Generate a contract identity from one moon.ext input",
),
],
options=[
OptionArg(
"output-file",
short='o',
about="Output .g.mbt file",
required=true,
),
OptionArg(
"identity-name",
about="Generated binding name for --extension-identity",
),
],
positionals=[
PositionArg(
"input_files",
about="Input MoonBit source files",
num_args=@argparse.ValueRange(lower=1),
),
],
)
}
///|
fn doctor_command() -> @argparse.Command {
@argparse.Command(
"doctor",
about="Inspect and diagnose a Proton project",
flags=[
FlagArg("dry-run", about="Report changes without applying them"),
FlagArg("verbose", about="Include detailed project diagnostics"),
FlagArg("deep", about="Run deeper runtime checks"),
FlagArg("run", about="Select and run frontend diagnostics"),
FlagArg("fix", about="Select and apply safe automatic fixes"),
FlagArg("frontend", about="Select the frontend section"),
FlagArg("release", about="Select the release section"),
FlagArg("json", about="Render one JSON document", conflicts_with=[
"json-lines", "quiet",
]),
FlagArg("json-lines", about="Render newline-delimited JSON", conflicts_with=[
"json", "quiet",
]),
FlagArg("quiet", about="Render only failing diagnostics", conflicts_with=[
"json", "json-lines",
]),
],
options=[
OptionArg(
"section",
action=Append,
about="Section to inspect; may be repeated",
),
OptionArg("output", about="Write the report to a file"),
],
)
}
///|
fn version_command() -> @argparse.Command {
@argparse.Command("version", about="Print the Proton CLI version")
}
///|
fn cli_command() -> @argparse.Command {
@argparse.Command(
"proton_cli",
about="Build, run, diagnose, and package Proton desktop applications",
version=cli_version_text(),
options=[
OptionArg(
"cwd",
short='C',
default_values=["."],
allow_hyphen_values=true,
global=true,
about="Run as if started in this directory",
),
],
subcommands=[
version_command(),
@new_project.command(),
@dev.command(),
@build.command(),
@package.command(),
doctor_command(),
codegen_command(),
@cef.command(),
],
arg_required_else_help=true,
subcommand_required=true,
)
}
///|
async fn run_doctor(cwd : String, matches : @argparse.Matches) -> Bool {
let dry_run = @cli_args.flag(matches, "dry-run")
let verbose = @cli_args.flag(matches, "verbose")
let deep = @cli_args.flag(matches, "deep")
let run_frontend = @cli_args.flag(matches, "run")
let fix = @cli_args.flag(matches, "fix")
let json = @cli_args.flag(matches, "json")
let json_lines = @cli_args.flag(matches, "json-lines")
let quiet = @cli_args.flag(matches, "quiet")
let sections = @cli_args.values(matches, "section")
if @cli_args.flag(matches, "frontend") && !sections.contains("frontend") {
sections.push("frontend")
}
if @cli_args.flag(matches, "release") && !sections.contains("release") {
sections.push("release")
}
if run_frontend && !sections.contains("frontend") {
sections.push("frontend")
}
if fix && !sections.contains("fixes") {
sections.push("fixes")
}
if sections.contains("fixes") && !fix {
raise CliUsageError::Invalid(message="the fixes section requires --fix")
}
for section in sections {
if ![
"project", "environment", "runtime", "platform", "packages", "extensions",
"app", "frontend", "release", "fixes",
].contains(section) {
raise CliUsageError::Invalid(message="unknown doctor section: " + section)
}
}
let output = match @cli_args.value(matches, "output") {
Some(path) => Some(@fsutil.resolve_path(cwd, path))
None => None
}
@doctor.run(
cwd~,
dry_run~,
verbose~,
deep~,
run_frontend~,
fix~,
json~,
json_lines~,
quiet~,
sections~,
output~,
)
}
///|
async fn run_codegen(cwd : String, matches : @argparse.Matches) -> Unit {
let inputs = @cli_args.values(matches, "input_files")
let output = @cli_args.required_value(matches, "output-file")
let resolved_inputs = inputs.map(input => @fsutil.resolve_path(cwd, input))
let resolved_output = @fsutil.resolve_path(cwd, output)
let extension_identity = @cli_args.flag(matches, "extension-identity")
let identity_name = @cli_args.value(matches, "identity-name")
if !extension_identity && identity_name is Some(_) {
raise CliUsageError::Invalid(
message="codegen --identity-name requires --extension-identity",
)
}
let result = if extension_identity {
guard resolved_inputs is [metadata_path] else {
raise CliUsageError::Invalid(
message="codegen --extension-identity requires exactly one moon.ext input",
)
}
@codegen.generate_extension_identity_from_file(
metadata_path,
binding_name=identity_name.unwrap_or("generated_extension_contract"),
)
} else {
@codegen.generate_commands_from_files(resolved_inputs)
}
@async_fs.write_file(
resolved_output,
result.source,
create_mode=CreateOrTruncate,
) catch {
error =>
raise CliCodegenOutputError::WriteFailed(
path=resolved_output,
detail=@debug.render(Repr(error)),
)
}
println("wrote " + resolved_output)
for warning in result.warnings {
@output.write_stderr_line("warning: " + warning)
}
}
///|
async fn dispatch_cli(cwd : String, matches : @argparse.Matches) -> Bool {
match matches.subcommand {
Some(("version", _)) => println(cli_version_text())
Some(("doctor", doctor_matches)) => return run_doctor(cwd, doctor_matches)
Some(("new", new_matches)) => @new_project.run(cwd, new_matches)
Some(("dev", dev_matches)) => @dev.run(cwd, dev_matches)
Some(("build", build_matches)) => @build.run(cwd, build_matches)
Some(("package", package_matches)) => @package.run(cwd, package_matches)
Some(("codegen", codegen_matches)) => run_codegen(cwd, codegen_matches)
Some(("cef", cef_matches)) => @cef.run(cwd, cef_matches)
_ => abort("argparse invariant violated: missing subcommand")
}
true
}
///|
async fn run(args : ArrayView[String]) -> Int {
let matches = @argparse.parse(cli_command(), argv=args, env=Map([])) catch {
error => {
print_error_message(error.to_string())
return 2
}
}
let cwd = @cli_args.required_value(matches, "cwd")
if !(matches.subcommand is Some(("version", _))) {
check_cli_update()
}
try dispatch_cli(cwd, matches) catch {
error => {
print_error_message(error.to_string())
1
}
} noraise {
success => if success { 0 } else { 1 }
}
}
///|
async fn main {
let code = run(@env.args()[1:])
if code != 0 {
@sys.exit(code)
}
}