diff --git a/zx b/zx new file mode 100755 index 0000000..60bb198 --- /dev/null +++ b/zx @@ -0,0 +1,1902 @@ +#! /usr/bin/env escript + +%%% zx +%%% +%%% A general dependency and packaging tool that works together with the zomp +%%% package manager. Given a project directory with a standard layout, zx can: +%%% - Initialize your project for packaging and semver tracking under zomp. +%%% - Add dependencies (recursively) defined in any zomp repository realm. +%%% - Update dependencies (recursively) defined in any zomp repository realm. +%%% - Remove dependencies. +%%% - Update, upgrade or run any application from source that zomp tracks. +%%% - Locally install packages from files and locally stored public keys. +%%% - Build and run a local project from source using zomp dependencies. + +-module(zx). +-mode(compile). +-export([main/1]). + + +-record(s, + {realm = "otpr" :: name(), + app = none :: none | name(), + version = {z, z, z} :: version(), + pid = none :: none | pid(), + mon = none :: none | reference()}). + + +-type state() :: #s{}. +-type name() :: string(). +-type version() :: {Major :: z | non_neg_integer(), + Minor :: z | non_neg_integer(), + Patch :: z | non_neg_integer()}. +-type app_id() :: {Realm :: name(), + App :: name(), + Version :: version()}. +-type option() :: {string(), term()}. +-type peer() :: {inet:hostname() | inet:ip_address(), inet:port_number()}. + + + +-spec main(Args) -> no_return() + when Args :: [string()]. +%% @private +%% The automatically exposed function initially called by escript to kick things off. +%% Args is a list of command-line provided arguments, all presented as a list of strings +%% delimited by whitespace in the shell. + +main(Args) -> + ok = ensure_zomp_home(), + start(Args). + + +-spec start(Args) -> no_return() + when Args :: [string()]. +%% Dispatch work functions based on the nature of the input arguments. + +start(["help"]) -> + usage_exit(0); +start(["run", AppString | Args]) -> + execute(AppString, Args); +start(["init", "app", AppString]) -> + AppID = appstring_to_appid(AppString), + initialize(app, AppID); +start(["init", "lib", AppString]) -> + AppID = appstring_to_appid(AppString), + initialize(lib, AppID); +start(["install", PackageFile]) -> + assimilate(PackageFile); +start(["set", "dep", AppString]) -> + set_dep(AppString); +start(["set", "version", VersionString]) -> + set_version(VersionString); +start(["drop", "dep", AppString]) -> + AppID = appstring_to_appid(AppString), + drop_dep(AppID); +start(["drop", "key", KeyID]) -> + drop_key(KeyID); +start(["verup", Level]) -> + verup(Level); +start(["runlocal"]) -> + run_local([]); +start(["runlocal", Args]) -> + run_local(Args); +start(["package"]) -> + {ok, TargetDir} = file:get_cwd(), + package(TargetDir); +start(["package", TargetDir]) -> + case filelib:is_dir(TargetDir) of + true -> + package(TargetDir); + false -> + ok = log(error, "Target directory ~tp does not exist!", [TargetDir]), + halt(22) + end; +start(["submit", PackageFile]) -> + submit(PackageFile); +start(["keygen"]) -> + keygen(); +start(["genplt"]) -> + genplt(); +start(["dialyze"]) -> + dialyze(); +start(_) -> + usage_exit(22). + + + +%%% Execution of application + + +-spec execute(Identifier, Args) -> no_return() + when Identifier :: string(), + Args :: [string()]. +%% @private +%% Given a program Identifier and a list of Args, attempt to locate the program and its +%% dependencies and run the program. This implies determining whether the program and +%% its dependencies are installed, available, need to be downloaded, or are inaccessible +%% given the current system condition (they could also be bogus, of course). The +%% Identifier provided should be a valid AppString of the form `realm-appname-version' +%% where the realm and appname should follow standard realm and app package naming +%% conventions and the version should be represented as a semver in string form (where +%% ommitted elements of the version always default to whatever is most current). +%% +%% Once the target program is running this process, which will run with the registered +%% name `vx' will sit in an `exec_wait' state, waiting for either a direct message from +%% a child program or for calls made via vx_lib to assist in environment discovery. +%% +%% If there is a problem anywhere in the locationg, discovery, building, and loading +%% procedure the runtime will halt with an error message. + +execute(Identifier, Args) -> + true = register(zx, self()), + ok = inets:start(), + AppID = {Realm, App, Version} = appstring_to_appid(Identifier), + ok = file:set_cwd(zomp_dir()), + AppRoot = filename:join("lib", Identifier), + ok = ensure_installed(AppID), + {ok, Meta} = file:consult(filename:join(AppRoot, "zomp.meta")), + {deps, Deps} = lists:keyfind(deps, 1, Meta), + Required = [AppID | Deps], + Needed = scrub(Required), + ok = fetch(Needed), + ok = lists:foreach(fun install/1, Needed), + ok = lists:foreach(fun build/1, Required), + ok = file:set_cwd(AppRoot), + case lists:keyfind(type, 1, Meta) of + {type, app} -> + ok = log(info, "Starting ~ts", [appid_to_appstring(AppID)]), + AppMod = list_to_atom(App), + {ok, Pid} = AppMod:start(normal, Args), + Mon = monitor(process, Pid), + Shell = spawn(shell, start, []), + ok = log(info, "Your shell is ~p, application is: ~p", [Shell, Pid]), + State = #s{realm = Realm, + app = App, + version = Version, + pid = Pid, + mon = Mon}, + exec_wait(State); + {type, lib} -> + Message = "Lib ~ts is available on the system, but is not a standalone app.", + ok = log(info, Message, [appid_to_appstring(AppID)]), + halt(0) + end. + + + +%%% Project initialization + + +-spec initialize(Type, AppID) -> no_return() + when Type :: app | lib, + AppID :: app_id(). +%% @private +%% Initialize an application in the local directory based on the AppID provided. +%% This function does not care about the name of the current directory and leaves +%% providing a complete, proper and accurate AppID. +%% This function will check the current `lib/' directory for zomp-style dependencies. +%% If this is not the intended function or if there are non-compliant directory names +%% in `lib/' then the project will need to be rearranged to become zomp compliant or +%% the `deps' section of the resulting meta file will need to be manually updated. + +initialize(Type, AppID) -> + AppString = appid_to_appstring(AppID), + ok = log(info, "Initializing ~s...", [AppString]), + Meta = [{app_id, AppID}, + {deps, []}, + {type, Type}], + ok = write_terms("zomp.meta", Meta), + ok = log(info, "Project ~tp initialized.", [AppString]), + Message = + "NOTICE:~n" + " This project is currently listed as having no dependencies.~n" + " If this is not true then run `zx set dep DepID` for each current dependency.~n" + " (run `zx help` for more information on usage)~n", + ok = io:format(Message), + halt(0). + + + +%%% Add a package from a local file + + +-spec assimilate(PackageFile) -> AppID + when PackageFile :: file:filename(), + AppID :: app_id(). +%% @private +%% Receives a path to a file containing package data, examines it, and copies it to a +%% canonical location under a canonical name, returning the AppID of the package +%% contents. + +assimilate(PackageFile) -> + Files = extract_zrp(PackageFile), + {ok, CWD} = file:get_cwd(), + ok = file:set_cwd(zomp_dir()), + {"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files), + Meta = binary_to_term(MetaBin), + {package_id, AppID} = lists:keyfind(package_id, 1, Meta), + TgzFile = namify_tgz(AppID), + {TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files), + {sig, {KeyID, Signature}} = lists:keyfind(sig, 1, Meta), + KeyFile = filename:join("key", KeyID), + {ok, PubKey} = loadkey(public, KeyFile), + ok = + case public_key:verify(TgzData, sha512, Signature, PubKey) of + true -> + ZrpPath = filename:join("zrp", namify_zrp(AppID)), + erl_tar:create(ZrpPath, Files); + false -> + error_exit("Bad package signature: ~ts", [PackageFile], ?FILE, ?LINE) + end, + ok = file:set_cwd(CWD), + Message = "~ts is now locally available.", + ok = log(info, Message, [appid_to_appstring(AppID)]), + halt(0). + + + +%%% Set dependency + + +-spec set_dep(AppString) -> no_return() + when AppString :: string(). +%% @private +%% Set a specific dependency in the current project. If the project currently has a +%% dependency on the same Realm-App then the version of that dependency is updated to +%% reflect that in the AppString argument. The AppString is permitted to be incomplete. +%% Incomplete elements of the included VersionString (if included) will default to the +%% latest version available at the indicated level. + +set_dep(AppString) -> + AppID = appstring_to_appid(AppString), + Meta = read_meta(), + {deps, Deps} = lists:keyfind(deps, 1, Meta), + case lists:member(AppID, Deps) of + true -> + ok = log(info, "~ts is already a dependency", [AppString]), + halt(0); + false -> + set_dep(AppID, Deps, Meta) + end. + + +-spec set_dep(AppID, Deps, Meta) -> no_return() + when AppID :: app_id(), + Deps :: [app_id()], + Meta :: [term()]. +%% @private +%% Given the AppID, list of Deps and the current contents of the project Meta, add or +%% update Deps to include (or update) Deps to reflect a dependency on AppID, if such a +%% dependency is not already present. Then write the project meta back to its file and +%% exit. + +set_dep(AppID = {Realm, App, NewVersion}, Deps, Meta) -> + {ok, CWD} = file:get_cwd(), + ok = file:set_cwd(zomp_dir()), + ok = ensure_installed(AppID), + ok = file:set_cwd(CWD), + ExistingApp = fun ({R, A, _}) -> {R, A} == {Realm, App} end, + NewDeps = + case lists:partition(ExistingApp, Deps) of + {[{Realm, App, OldVersion}], Rest} -> + Message = "Updating dep ~ts to ~ts", + OldAppString = appid_to_appstring({Realm, App, OldVersion}), + NewAppString = appid_to_appstring({Realm, App, NewVersion}), + ok = log(info, Message, [OldAppString, NewAppString]), + [AppID | Rest]; + {[], Deps} -> + ok = log(info, "Adding dep ~ts", [appid_to_appstring(AppID)]), + [AppID | Deps] + end, + NewMeta = lists:keystore(deps, 1, Meta, {deps, NewDeps}), + ok = write_terms("zomp.meta", NewMeta), + halt(0). + + +-spec ensure_installed(app_id()) -> ok | no_return(). +%% @private +%% Given an AppID, check whether it is installed on the system, and if not, ensure +%% that the package is either in the cache or can be downloaded. If all attempts at +%% locating or acquiring the package fail, then exit with an error. + +ensure_installed(AppID) -> + AppString = appid_to_appstring(AppID), + AppDir = filename:join("lib", AppString), + case filelib:is_dir(AppDir) of + true -> ok; + false -> ensure_dep(AppID) + end. + + +-spec ensure_dep(app_id()) -> ok | no_return(). +%% @private +%% Given an AppID as an argument, check whether its package file exists in the system +%% cache, and if not download it. Should return `ok' whenever the file is sourced, but +%% exit with an error if it cannot locate or acquire the package. + +ensure_dep(AppID) -> + ZrpFile = filename:join("zrp", namify_zrp(AppID)), + ok = + case filelib:is_regular(ZrpFile) of + true -> + ok; + false -> + AppString = appid_to_appstring(AppID), + log(error, "Would fetch ~ts now, but not implemented", [AppString]), + halt(0) + end, + install(AppID). + + + +%%% Set version + + +-spec set_version(VersionString) -> no_return() + when VersionString :: string(). +%% @private +%% Convert a version string to a new version, sanitizing it in the process and returning +%% a reasonable error message on bad input. + +set_version(VersionString) -> + NewVersion = + case check_version(VersionString) of + {_, _, z} -> + Message = "'set version' arguments must be complete, ex: 1.2.3", + ok = log(error, Message), + halt(22); + Version -> + Version + end, + update_version(NewVersion). + + +-spec update_version(Level) -> no_return() + when Level :: major + | minor + | patch + | VersionString, + VersionString :: string(). % Of the form "Major.Minor.Patch" +%% @private +%% Update a project's `zomp.meta' file by either incrementing the indicated component, +%% or setting the version number to the one specified in VersionString. +%% This part of the procedure guards for the case when the zomp.meta file cannot be +%% read for some reason. + +update_version(Arg) -> + Meta = read_meta(), + {app_id, AppID} = lists:keyfind(app_id, 1, Meta), + update_version(Arg, AppID, Meta). + + +-spec update_version(Level, AppID, Meta) -> no_return() + when Level :: major + | minor + | patch + | version(), + AppID :: app_id(), + Meta :: [{atom(), term()}]. +%% @private +%% Update a project's `zomp.meta' file by either incrementing the indicated component, +%% or setting the version number to the one specified in VersionString. +%% This part of the procedure does the actual update calculation, to include calling to +%% convert the VersionString (if it is passed) to a `version()' type and check its +%% validity (or halt if it is a bad string). + +update_version(major, {Realm, App, OldVersion = {Major, _, _}}, OldMeta) -> + NewVersion = {Major + 1, 0, 0}, + update_version(Realm, App, OldVersion, NewVersion, OldMeta); +update_version(minor, {Realm, App, OldVersion = {Major, Minor, _}}, OldMeta) -> + NewVersion = {Major, Minor + 1, 0}, + update_version(Realm, App, OldVersion, NewVersion, OldMeta); +update_version(patch, {Realm, App, OldVersion = {Major, Minor, Patch}}, OldMeta) -> + NewVersion = {Major, Minor, Patch + 1}, + update_version(Realm, App, OldVersion, NewVersion, OldMeta); +update_version(NewVersion, {Realm, App, OldVersion}, OldMeta) -> + update_version(Realm, App, OldVersion, NewVersion, OldMeta). + + +-spec update_version(Realm, App, OldVersion, NewVersion, OldMeta) -> no_return() + when Realm :: name(), + App :: name(), + OldVersion :: version(), + NewVersion :: version(), + OldMeta :: [{atom(), term()}]. +%% @private +%% Update a project's `zomp.meta' file by either incrementing the indicated component, +%% or setting the version number to the one specified in VersionString. +%% This part of the procedure updates the meta and does the final write, if the write +%% turns out to be possible. If successful it will indicate to the user what was +%% changed. + +update_version(Realm, App, OldVersion, NewVersion, OldMeta) -> + NewMeta = lists:keystore(app_id, 1, OldMeta, {app_id, {Realm, App, NewVersion}}), + ok = write_terms("zomp.meta", NewMeta), + ok = log(info, + "Version changed from ~s to ~s.", + [version_to_string(OldVersion), version_to_string(NewVersion)]), + halt(0). + + + +%%% Drop dependency + + +-spec drop_dep(app_id()) -> no_return(). +%% @private +%% Remove the indicate dependency from the local project's zomp.meta record. + +drop_dep(AppID) -> + AppString = appid_to_appstring(AppID), + Meta = read_meta(), + {deps, Deps} = lists:keyfind(deps, 1, Meta), + case lists:member(AppID, Deps) of + true -> + NewDeps = lists:delete(AppID, Deps), + NewMeta = lists:keystore(deps, 1, Meta, {deps, NewDeps}), + ok = write_terms("zomp.meta", NewMeta), + Message = "~ts removed from dependencies.", + ok = log(info, Message, [AppString]), + halt(0); + false -> + ok = log(info, "~ts not found in dependencies.", [AppString]), + halt(0) + end. + + + +%%% Drop key + + +-spec drop_key(KeyID) -> no_return() + when KeyID :: file:filename(). +%% @private +%% Given a KeyID, remove the related public and private keys from the keystore, if they +%% exist. If not, exit with a message that no keys were found, but do not return an +%% error exit value (this instruction is idempotent if used in shell scripts). + +drop_key(KeyID) -> + ok = file:set_cwd(zomp_dir()), + KeyDir = filename:join(zomp_dir(), "key"), + Pattern = KeyID ++ ".{key,pub}.der", + case filelib:wildcard(filename:join(KeyDir, Pattern)) of + [] -> + ok = log(warning, "KeyID ~ts not found", [KeyID]), + halt(0); + Files -> + ok = lists:foreach(fun file:delete/1, Files), + ok = log(info, "Keyset ~ts removed", [KeyID]), + halt(0) + end. + + + +%%% Update version + + +-spec verup(Level) -> no_return() + when Level :: string(). +%% @private +%% Convert input string arguments to acceptable atoms for use in update_version/1. + +verup("major") -> update_version(major); +verup("minor") -> update_version(minor); +verup("patch") -> update_version(patch); +verup(_) -> usage_exit(22). + + + +%%% Run local project + +-spec run_local(Args) -> no_return() + when Args :: [term()]. +%% @private +%% Execute a local project from source from the current directory, satisfying dependency +%% requirements via the locally installed zomp lib cache. The project must be +%% initialized as a zomp project (it must have a valid `zomp.meta' file). +%% +%% The most common use case for this function is during development. Using zomp support +%% via the local lib cache allows project authors to worry only about their own code +%% and use zx commands to add or drop dependencies made available via zomp. + +run_local(Args) -> + true = register(zx, self()), + ok = inets:start(), + {ok, ProjectRoot} = file:get_cwd(), + {ok, Meta} = file:consult("zomp.meta"), + {app_id, AppID = {Realm, App, Version}} = lists:keyfind(app_id, 1, Meta), + ok = build(), + ok = file:set_cwd(zomp_dir()), + {deps, Deps} = lists:keyfind(deps, 1, Meta), + Needed = scrub(Deps), + ok = fetch(Needed), + ok = lists:foreach(fun install/1, Needed), + ok = lists:foreach(fun build/1, Deps), + ok = file:set_cwd(ProjectRoot), + case lists:keyfind(type, 1, Meta) of + {type, app} -> + ok = log(info, "Starting ~ts", [appid_to_appstring(AppID)]), + AppMod = list_to_atom(App), + {ok, Pid} = AppMod:start(normal, Args), + Mon = monitor(process, Pid), + Shell = spawn(shell, start, []), + ok = log(info, "Your shell is ~p, application is: ~p", [Shell, Pid]), + State = #s{realm = Realm, + app = App, + version = Version, + pid = Pid, + mon = Mon}, + exec_wait(State); + {type, lib} -> + Message = "Lib ~ts is available on the system, but is not a standalone app", + ok = log(info, Message, [appid_to_appstring(AppID)]), + halt(0) + end. + + + +%%% Package generation + +-spec package(TargetDir) -> no_return() + when TargetDir :: file:filename(). +%% @private +%% Turn a target project directory into a package, prompting the user for appropriate +%% key selection or generation actions along the way. + +package(TargetDir) -> + ok = log(info, "Packaging ~ts", [TargetDir]), + KeyDir = filename:join(zomp_dir(), "key"), + ok = force_dir(KeyDir), + Pattern = KeyDir ++ "/*.key.der", + case [filename:basename(F, ".key.der") || F <- filelib:wildcard(Pattern)] of + [] -> + ok = log(info, "Need to generate key"), + KeyPrefix = prompt_keygen(KeyDir), + ok = generate_rsa(KeyPrefix), + package(KeyPrefix, TargetDir); + [KeyPrefix] -> + ok = log(info, "Using key: ~ts", [KeyPrefix]), + package(KeyPrefix, TargetDir); + KeyPrefixes -> + KeyPrefix = select_string(KeyPrefixes), + package(KeyPrefix, TargetDir) + end. + + +-spec package(KeyPrefix, TargetDir) -> no_return() + when KeyPrefix :: string(), + TargetDir :: file:filename(). +%% @private +%% Accept a KeyPrefix for signing and a TargetDir containing a project to package and +%% build a zrp package file ready to be submitted to a repository. + +package(KeyPrefix, TargetDir) -> + KeyID = KeyPrefix ++ ".key.der", + PubID = KeyPrefix ++ ".pub.der", + {ok, Meta} = file:consult(filename:join(TargetDir, "zomp.meta")), + {app_id, AppID} = lists:keyfind(app_id, 1, Meta), + AppString = appid_to_appstring(AppID), + ZrpFile = AppString ++ ".zrp", + TgzFile = AppString ++ ".tgz", + ok = halt_if_exists(ZrpFile), + ok = remove_binaries(TargetDir), + {ok, Everything} = file:list_dir(TargetDir), + DotFiles = filelib:wildcard(".*", TargetDir), + Ignores = ["lib" | DotFiles], + Targets = lists:subtract(Everything, Ignores), + {ok, CWD} = file:get_cwd(), + ok = file:set_cwd(TargetDir), + ok = erl_tar:create(filename:join(CWD, TgzFile), Targets, [compressed]), + ok = file:set_cwd(CWD), + KeyFile = filename:join([zomp_dir(), "key", KeyID]), + {ok, Key} = loadkey(private, KeyFile), + {ok, TgzBin} = file:read_file(TgzFile), + Sig = public_key:sign(TgzBin, sha512, Key), + FinalMeta = [{sig, {PubID, Sig}} | Meta], + ok = file:write_file("zomp.meta", term_to_binary(FinalMeta)), + ok = erl_tar:create(ZrpFile, ["zomp.meta", TgzFile]), + ok = file:delete(TgzFile), + ok = file:delete("zomp.meta"), + ok = log(info, "Wrote archive ~ts", [ZrpFile]), + halt(0). + + +-spec remove_binaries(TargetDir) -> ok + when TargetDir :: file:filename(). +%% @private +%% Procedure to delete all .beam and .ez files from a given directory starting at +%% TargetDir. Called as part of the pre-packaging sanitization procedure. + +remove_binaries(TargetDir) -> + Beams = filelib:wildcard("**/*.{beam,ez}", TargetDir), + case [filename:join(TargetDir, Beam) || Beam <- Beams] of + [] -> + ok; + ToDelete -> + ok = log(info, "Removing: ~tp", [ToDelete]), + lists:foreach(fun file:delete/1, ToDelete) + end. + + + +%%% App execution loop + +-spec exec_wait(State) -> no_return() + when State :: state(). +%% @private +%% Execution maintenance loop. +%% Once an application is started by zompc this process will wait for a message from +%% the application if that application was written in a way to take advantage of zompc +%% facilities such as post-start upgrade checking. +%% +%% NOTE: +%% Adding clauses to this `receive' is where new functionality belongs. +%% It may make sense to add a `zompc_lib' as an available dependency authors could +%% use to interact with zompc without burying themselves under the complexity that +%% can come with naked send operations. (Would it make sense, for example, to have +%% the registered zompc process convert itself to a gen_server via zompc_lib to +%% provide more advanced functionality?) + +exec_wait(State = #s{pid = Pid, mon = Mon}) -> + receive + {check_update, Requester, Ref} -> + {Response, NewState} = check_update(State), + Requester ! {Ref, Response}, + exec_wait(NewState); + {exit, Reason} -> + ok = log(info, "Exiting with: ~tp", [Reason]), + halt(0); + {'DOWN', Mon, process, Pid, normal} -> + ok = log(info, "Application exited normally."), + halt(0); + {'DOWN', Mon, process, Pid, Reason} -> + ok = log(warning, "Application exited with: ~tp", [Reason]), + halt(1); + Unexpected -> + ok = log(warning, "Unexpected message: ~tp", [Unexpected]), + exec_wait(State) + end. + + +-spec check_update(State) -> {Response, NewState} + when State :: state(), + Response :: term(), + NewState :: state(). +%% @private +%% Check for updated version availability of the current application. +%% The return value should probably provide up to three results, a Major, Minor and +%% Patch update, and allow the Requestor to determine what to do with it via some +%% interaction. + +check_update(State) -> + ok = log(info, "Would be checking for an update of the current application now..."), + Response = "Nothing was checked, but you can imagine it to have been.", + {Response, State}. + + + +%%% Package submission + + +-spec submit(PackageFile) -> no_return() + when PackageFile :: file:filename(). +%% @private +%% Submit a package to the appropriate "prime" server for the given realm. + +submit(PackageFile) -> + Files = extract_zrp(PackageFile), + {ok, PackageData} = file:read_file(PackageFile), + {"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files), + Meta = binary_to_term(MetaBin), + {app_id, {Realm, Package, Version}} = lists:keyfind(app_id, 1, Meta), + {sig, {PublicKeyFile, _}} = lists:keyfind(sig, 1, Meta), + KeyID = filename:rootname(PublicKeyFile, ".pub.der"), + true = ensure_keypair(KeyID), + RealmData = realm_data(Realm), + {prime, Prime} = lists:keyfind(prime, 1, RealmData), + Socket = connect(Prime), + ok = send(Socket, {submit, {Realm, Package, Version}}), + ok = + receive + {tcp, Socket, Response} -> + case binary_to_term(Response, [safe]) of + ready -> + ok; + {error, Reason} -> + ok = log(info, "Server refused with ~tp", [Reason]), + halt(0) + end; + after 5000 -> + ok = log(warning, "Server timed out!"), + halt(0) + end, + ok = send(Socket, PackageData), + ok = log(info, "Done sending contents of ~tp", [PackageFile]), + ok = + receive + {tcp, Socket, Response} -> + log(info, "Response: ~tp", [Response]); + Other -> + log(warning, "Unexpected message: ~tp", [Other]) + after 5000 -> + log(warning, "Server timed out!") + end, + ok = disconnect(Socket), + halt(0). + + +-spec send(Socket, Message) -> ok + when Socket :: gen_tcp:socket(), + Message :: term(). +%% @private +%% Wrapper for the procedure necessary to send an internal message over the wire. + +send(Socket, Message) -> + BinMessage = term_to_binary(Message), + gen_tcp:send(Socket, Message). + + +-spec connect(peer()) -> inet:socket() | no_return(). +%% @private +%% Connect to one of the servers in the realm constellation. + +connect({Host, Port}) -> + Options = [{packet, 4}, {mode, binary}, {active, true}], + case gen_tcp:connect(Host, Port, Options) of + {ok, Socket} -> + confirm_server(Socket); + {error, Error} -> + ok = log(warning, "Connection problem: ~tp", [Error]), + halt(0) + end. + + +-spec confirm_server(inet:socket()) -> inet:socket() | no_return(). +%% @private +%% Send a protocol ID string to notify the server what we're up to, disconnect +%% if it does not return an "OK" response within 5 seconds. + +confirm_server(Socket) -> + {ok, {Addr, Port}} = inet:peername(Socket), + Host = inet:ntoa(Addr), + ok = gen_tcp:send(Socket, <<"OTPR 1 CLIENT">>), + receive + {tcp, Socket, <<"OK">>} -> + ok = log(info, "Connected to ~s:~p", [Host, Port]), + Socket; + Other -> + Message = "Unexpected response from ~s:~p:~n~tp", + ok = log(warning, Message, [Host, Port, Other]), + ok = disconnect(Socket), + halt(0) + after 5000 -> + ok = log(warning, "Host ~s:~p timed out.", [Host, Port]), + halt(0) + end. + + +-spec disconnect(inet:socket()) -> ok. +%% @private +%% Gracefully shut down a socket, logging (but sidestepping) the case when the socket +%% has already been closed by the other side. + +disconnect(Socket) -> + case gen_tcp:shutdown(Socket, read_write) of + ok -> + log(info, "Disconnected from ~tp", [Socket]); + {error, Error} -> + Message = "Shutdown connection ~p failed with: ~p", + log(warning, Message, [Socket, Error]) + end. + + +-spec ensure_keypair(KeyID) -> true | no_return() + when KeyID :: string(). +%% @private +%% Check if both the public and private key based on KeyID exists. + +ensure_keypair(KeyID) -> + case {have_public_key(KeyID), have_private_key(KeyID)} of + {true, true} -> + true; + {false, true} -> + ok = log(error, "Public key for ~tp cannot be found", [KeyID]), + halt(1); + {true, false} -> + ok = log(error, "Private key for ~tp cannot be found", [KeyID]), + halt(1); + {false, false} -> + Message = "Key pair for ~tp cannot be found", + ok = log(error, Message, [KeyID]), + halt(1) + end. + + +-spec have_public_key(KeyID) -> boolean() + when KeyID :: string(). +%% @private +%% Determine whether the public key indicated by KeyID is in the keystore. + +have_public_key(KeyID) -> + PublicKeyFile = KeyID ++ ".pub.der", + PublicKeyPath = filename:join([zomp_dir(), "key", PublicKeyFile]), + filelib:is_regular(PublicKeyPath). + + +-spec have_private_key(KeyID) -> boolean() + when KeyID :: string(). +%% @private +%% Determine whether the private key indicated by KeyID is in the keystore. + +have_private_key(KeyID) -> + PrivateKeyFile = KeyID ++ ".key.der", + PrivateKeyPath = filename:join([zomp_dir(), "key", PrivateKeyFile]), + filelib:is_regular(PrivateKeyPath). + + +-spec realm_data(Realm) -> Data | no_return() + when Realm :: string(), + Data :: [{atom(), term()}]. +%% @private +%% Given a realm name, try to locate and read the realm's configuration file if it +%% exists, exiting with an appropriate error message if there is a problem reading +%% the file. + +realm_data(Realm) -> + RealmFile = filename:join([zomp_dir(), Realm ++ ".realm"]), + case file:consult(RealmFile) of + {ok, Data} -> + Data; + {error, enoent} -> + ok = log(error, "No realm file for ~ts", [Realm]), + halt(1); + Error -> + Message = "Open realm file ~ts failed with ~ts", + error_exit(Message, [RealmFile, Error], ?FILE, ?LINE) + end. + + +%%% Key generation + + +-spec prompt_keygen(KeyDir) -> KeyPrefix + when KeyDir :: file:filename(), + KeyPrefix :: string(). +%% @private +%% Prompt the user for a valid KeyPrefix to use for naming a new RSA keypair. + +prompt_keygen(KeyDir) -> + Message = + " Enter a name for your new keys.~n" + " Valid names must start with a lower-case letter, and can include~n" + " only lower-case letters, numbers, and periods, but no series of~n" + " consecutive periods.~n", + ok = io:format(Message), + Input = io:get_line("(^C to quit): "), + case validate_prefix(Input) of + {ok, Value} -> + Value; + error -> + ok = io:format("Bad name. Try again.~n"), + prompt_keygen(KeyDir) + end. + + +-spec validate_prefix(Prefix) -> {ok, Validated} | error + when Prefix :: string(), + Validated :: string(). +%% @private +%% Validate that a provided key prefix is legal according to the naming convention +%% provided in the prefix explanation prompt. + +validate_prefix([Char | Rest]) + when $a =< Char, Char =< $z -> + validate_prefix(Rest, Char, [Char]); +validate_prefix(_) -> + error. + + +-spec validate_prefix(Prefix, Last, Accumulator) -> {ok, Validated} | error + when Prefix :: string(), + Last :: char(), + Accumulator :: [char()], + Validated :: string(). + +%% @private +%% Validate that a provided key prefix is legal according to the naming convention +%% provided in the prefix explanation prompt. + +validate_prefix([$. | _], $., _) -> + error; +validate_prefix([Char | Rest], _, Acc) + when $a =< Char, Char =< $z; + $0 =< Char, Char =< $9; + Char == $. -> + validate_prefix(Rest, Char, [Char | Acc]); +validate_prefix([$\n], _, Acc) -> + {ok, lists:reverse(Acc)}; +validate_prefix(_, _, _) -> + error. + + +-spec keygen() -> no_return(). +%% @private +%% Execute the key generation procedure for 16k RSA keys once and then terminate. + +keygen() -> + ok = file:set_cwd(zomp_dir()), + KeyDir = filename:join(zomp_dir(), "key"), + Prefix = prompt_keygen(KeyDir), + case generate_rsa(Prefix) of + ok -> + halt(0); + Error -> + error_exit("keygen failed with ~tp", [Error], ?FILE, ?LINE) + end. + + +-spec generate_rsa(Prefix) -> Result + when Prefix :: string(), + Result :: {ok, KeyFile, PubFile} + | {error, keygen_fail}, + KeyFile :: file:filename(), + PubFile :: file:filename(). +%% @private +%% Generate an RSA keypair and write them in der format to the current directory, using +%% filenames derived from Prefix. +%% NOTE: The current version of this command is likely to only work on a unix system. + +generate_rsa(Prefix) -> + ZompDir = zomp_dir(), + PemFile = filename:join([ZompDir, "key", Prefix ++ ".pub.pem"]), + KeyFile = filename:join([ZompDir, "key", Prefix ++ ".key.der"]), + PubFile = filename:join([ZompDir, "key", Prefix ++ ".pub.der"]), + ok = lists:foreach(fun halt_if_exists/1, [PemFile, KeyFile, PubFile]), + ok = log(info, "Generating ~p and ~p. Please be patient...", [KeyFile, PubFile]), + ok = gen_p_key(KeyFile), + ok = der_to_pem(KeyFile, PemFile), + {ok, PemBin} = file:read_file(PemFile), + [PemData] = public_key:pem_decode(PemBin), + Pub = public_key:pem_entry_decode(PemData), + PubDer = public_key:der_encode('RSAPublicKey', Pub), + ok = file:write_file(PubFile, PubDer), + case check_key(KeyFile, PubFile) of + true -> + ok = file:delete(PemFile), + ok = log(info, "~ts and ~ts agree", [KeyFile, PubFile]), + ok = log(info, "Wrote private key to: ~ts.", [KeyFile]), + ok = log(info, "Wrote public key to: ~ts.", [PubFile]), + ok; + false -> + ok = lists:foreach(fun file:delete/1, [PemFile, KeyFile, PubFile]), + ok = log(error, "Something has gone wrong."), + {error, keygen_fail} + end. + + +-spec halt_if_exists(file:filename()) -> ok | no_return(). +%% @private +%% A helper function to guard against overwriting an existing file. Halts execution if +%% the file is found to exist. + +halt_if_exists(Path) -> + case filelib:is_file(Path) of + true -> + ok = log(error, "~ts already exists! Halting.", [Path]), + halt(1); + false -> + ok + end. + + +-spec gen_p_key(KeyFile) -> ok + when KeyFile :: file:filename(). +%% @private +%% Format an openssl shell command that will generate proper 16k RSA keys. + +gen_p_key(KeyFile) -> + Command = + io_lib:format("~ts genpkey" + " -algorithm rsa" + " -out ~ts" + " -outform DER" + " -pkeyopt rsa_keygen_bits:16384", + [openssl(), KeyFile]), + Out = os:cmd(Command), + io:format(Out). + + +-spec der_to_pem(KeyFile, PemFile) -> ok + when KeyFile :: file:filename(), + PemFile :: file:filename(). +%% @private +%% Format an openssl shell command that will convert the given keyfile to a pemfile. +%% The reason for this conversion is to sidestep some formatting weirdness that OpenSSL +%% injects into its generated DER formatted key output (namely, a few empty headers) +%% which Erlang's ASN.1 defintion files do not take into account. A conversion to PEM +%% then a conversion back to DER (via Erlang's ASN.1 module) resolves this in a reliable +%% way. + +der_to_pem(KeyFile, PemFile) -> + Command = + io_lib:format("~ts rsa" + " -inform DER" + " -in ~ts" + " -outform PEM" + " -pubout" + " -out ~ts", + [openssl(), KeyFile, PemFile]), + Out = os:cmd(Command), + io:format(Out). + + +-spec check_key(KeyFile, PubFile) -> Result + when KeyFile :: file:filename(), + PubFile :: file:filename(), + Result :: true | false. +%% @private +%% Compare two keys for pairedness. + +check_key(KeyFile, PubFile) -> + {ok, KeyBin} = file:read_file(KeyFile), + {ok, PubBin} = file:read_file(PubFile), + Key = public_key:der_decode('RSAPrivateKey', KeyBin), + Pub = public_key:der_decode('RSAPublicKey', PubBin), + TestMessage = <<"Some test data to sign.">>, + Signature = public_key:sign(TestMessage, sha512, Key), + public_key:verify(TestMessage, sha512, Signature, Pub). + + +-spec openssl() -> Executable | no_return() + when Executable :: file:filename(). +%% @private +%% Attempt to locate the installed openssl executable for use in shell commands. +%% Halts execution with an error message if the executable cannot be found. + +openssl() -> + OpenSSL = + case os:type() of + {unix, _} -> "openssl"; + {win32, _} -> "openssl.exe" + end, + ok = + case os:find_executable(OpenSSL) of + false -> + ok = log(error, "OpenSSL could not be found in this system's PATH."), + ok = log(error, "Install OpenSSL and then retry."), + error_exit("Missing system dependenct: OpenSSL", ?FILE, ?LINE); + Path -> + log(info, "OpenSSL executable found at: ~ts", [Path]) + end, + OpenSSL. + + +-spec loadkey(Type, File) -> Result + when Type :: private | public, + File :: file:filename(), + Result :: {ok, DecodedKey :: term()} + | {error, Reason :: term()}. +%% @private +%% Hide the details behind reading and loading DER encoded RSA key files. + +loadkey(Type, File) -> + DerType = + case Type of + private -> 'RSAPrivateKey'; + public -> 'RSAPublicKey' + end, + ok = log(info, "Loading key from file ~ts", [File]), + case file:read_file(File) of + {ok, Bin} -> {ok, public_key:der_decode(DerType, Bin)}; + Error -> Error + end. + + + +%%% Generate PLT + + +-spec genplt() -> no_return(). +%% @private +%% Generate a fresh PLT file that includes most basic core applications needed to +%% make a resonable estimate of a type system, write the name of the PLT to stdout, +%% and exit. + +genplt() -> + ok = build_plt(), + halt(0). + + +build_plt() -> + PLT = default_plt(), + Template = + "dialyzer --build_plt" + " --output_plt ~ts" + " --apps asn1 reltool wx common_test crypto erts eunit inets" + " kernel mnesia public_key sasl ssh ssl stdlib", + Command = io_lib:format(Template, [PLT]), + Message = + "Generating PLT file and writing to: ~tp~n" + " There will be a list of \"unknown functions\" in the final output.~n" + " Don't panic. This is normal. Turtles all the way down, after all...", + ok = log(info, Message, [PLT]), + ok = log(info, "This may take a while. Patience is a virtue."), + Out = os:cmd(Command), + log(info, Out). + + +default_plt() -> + filename:join(zomp_dir(), "basic.plt"). + + + +%%% Dialyze + +-spec dialyze() -> no_return(). +%% @private +%% Preps a copy of this script for typechecking with Dialyzer. + +dialyze() -> + PLT = default_plt(), + ok = + case filelib:is_regular(PLT) of + true -> log(info, "Using PLT: ~tp", [PLT]); + false -> build_plt() + end, + TmpDir = filename:join(zomp_dir(), "tmp"), + Me = escript:script_name(), + EvilTwin = filename:join(TmpDir, filename:basename(Me ++ ".erl")), + ok = log(info, "Temporarily reconstructing ~tp as ~tp", [Me, EvilTwin]), + Sed = io_lib:format("sed 's/^#!.*$//' ~s > ~s", [Me, EvilTwin]), + "" = os:cmd(Sed), + ok = case dialyzer:run([{init_plt, PLT}, {from, src_code}, {files, [EvilTwin]}]) of + [] -> + io:format("Dialyzer found no errors and returned no warnings! Yay!~n"); + Warnings -> + Mine = [dialyzer:format_warning({Tag, {Me, Line}, Msg}) + || {Tag, {_, Line}, Msg} <- Warnings], + lists:foreach(fun io:format/1, Mine) + end, + ok = file:delete(EvilTwin), + halt(0). + + + +%%% Network operations and package utilities + + +-spec install(app_id()) -> ok. +%% @private +%% Install a package from the cache into the local system. + +install(AppID) -> + AppString = appid_to_appstring(AppID), + ok = log(info, "Installing ~ts", [AppString]), + ZrpFile = filename:join("zrp", namify_zrp(AppID)), + Files = extract_zrp(ZrpFile), + TgzFile = namify_tgz(AppID), + {TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files), + {"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files), + Meta = binary_to_term(MetaBin), + {sig, {KeyID, Signature}} = lists:keyfind(sig, 1, Meta), + KeyFile = filename:join("key", KeyID), + {ok, PubKey} = loadkey(public, KeyFile), + ok = ensure_app_dirs(AppID), + AppDir = filename:join("lib", AppString), + ok = force_dir(AppDir), + ok = verify(TgzData, Signature, PubKey), + ok = erl_tar:extract({binary, TgzData}, [compressed, {cwd, AppDir}]), + log(info, "~ts installed", [AppString]). + + +-spec extract_zrp(FileName) -> Files | no_return() + when FileName :: file:filename(), + Files :: [{file:filename(), binary()}]. +%% @private +%% Extract a zrp archive, if possible. If not possible, halt execution with as accurate +%% an error message as can be managed. + +extract_zrp(FileName) -> + case erl_tar:extract(FileName, [memory]) of + {ok, Files} -> + Files; + {error, {FileName, enoent}} -> + Message = "Can't find file ~ts.", + error_exit(Message, [FileName], ?FILE, ?LINE); + {error, invalid_tar_checksum} -> + Message = "~ts is not a valid zrp archive.", + error_exit(Message, [FileName], ?FILE, ?LINE); + {error, Reason} -> + Message = "Extracting package file failed with: ~tp.", + error_exit(Message, [Reason], ?FILE, ?LINE) + end. + + +-spec read_meta() -> [term()] | no_return(). +%% @private +%% Read the `zomp.meta' file from the current directory, if possible. If not possible +%% then halt execution with an appropriate error message. + +read_meta() -> + case file:consult("zomp.meta") of + {ok, Meta} -> + Meta; + Error -> + ok = log(error, "Failed to open \"zomp.meta\" with ~tp", [Error]), + ok = log(error, "Wrong directory?"), + halt(1) + end. + + +-spec verify(Data, Signature, PubKey) -> ok | no_return() + when Data :: binary(), + Signature :: binary(), + PubKey :: public_key:rsa_public_key(). +%% @private +%% Verify the RSA Signature of some Data against the given PubKey or halt execution. +%% This function always assumes sha512 is the algorithm being used. + +verify(Data, Signature, PubKey) -> + case public_key:verify(Data, sha512, Signature, PubKey) of + true -> ok; + false -> error_exit("Bad package signature!", ?FILE, ?LINE) + end. + + +-spec fetch([app_id()]) -> ok. +%% @private +%% Download a list of deps to the local package cache. + +fetch(Needed) -> + Namified = lists:map(fun namify_zrp/1, Needed), + {Have, Lack} = lists:partition(fun filelib:is_regular/1, Namified), + ok = lists:foreach(fun(A) -> log(info, "Have ~ts", [A]) end, Have), + ok = lists:foreach(fun(A) -> log(info, "Lack ~ts", [A]) end, Lack), + log(info, "Done fake fetching"). + + +% Grouped = group_by_realm(Needed), +% Realms = [R || {R, _} <- Grouped], +% Sockets = connect(Realms), +% fetch(Sockets, Groups). +% +% +%-spec group_by_realm(AppIDs) -> GroupedAppIDs +% when AppIDs :: [app_id()], +% GroupedAppIDs :: [{realm(), [app_id()]}]. +%%% @private +%%% Group apps by realm. +% +%group_by_realm(AppIDs) -> +% Group = +% fun(AppID = {Realm, _, _}, Groups) -> +% case lists:keyfind(Realm, Groups) of +% {Realm, Members} -> +% lists:keystore(Realm, 1, Groups, {Realm, [AppID | Members]}); +% false -> +% lists:keystore(Realm, 1, Groups, {Realm, [AppID]}) +% end +% end, +% lists:foldl(Group, AppIDs). +% +% +% ZrpFile = namify_zrp(AppID), +% case filelib:is_regular(filename:join("zrp", ZrpFile)) of +% true -> +% log(info, "Found in cache: ~ts", [ZrpFile]); +% false -> +% log(info, "Would download: ~ts", [ZrpFile]) +% end. + + + +%%% Utility functions + +-spec write_terms(Filename, Terms) -> ok + when Filename :: file:filename(), + Terms :: [term()]. +%% @private +%% Provides functionality roughly inverse to file:consult/1. + +write_terms(Filename, List) -> + Format = fun(Term) -> io_lib:format("~tp.~n", [Term]) end, + Text = lists:map(Format, List), + file:write_file(Filename, Text). + + +-spec build(app_id()) -> ok. +%% @private +%% Given an AppID, build the project from source and add it to the current lib path. + +build(AppID) -> + {ok, CWD} = file:get_cwd(), + ok = file:set_cwd(app_home(AppID)), + ok = build(), + file:set_cwd(CWD). + + +-spec build() -> ok. +%% @private +%% Run any local `zxmake' script needed by the project for non-Erlang code (if present), +%% then add the local `ebin/' directory to the runtime search path, and finally build +%% the Erlang part of the project with make:all/0 according to the local `Emakefile'. + +build() -> + ZxMake = "zxmake", + ok = + case filelib:is_regular(ZxMake) of + true -> + Out = os:cmd(ZxMake), + log(info, Out); + false -> + ok + end, + true = code:add_patha(filename:absname("ebin")), + up_to_date = make:all(), + ok. + + +-spec scrub(Deps) -> Scrubbed + when Deps :: [app_id()], + Scrubbed :: [app_id()]. +%% @private +%% Take a list of dependencies and return a list of dependencies that are not yet +%% installed on the system. + +scrub(Deps) -> + {ok, Names} = file:list_dir("lib"), + Existing = lists:map(fun appstring_to_appid/1, Names), + Need = ordsets:from_list(Deps), + Have = ordsets:from_list(Existing), + ordsets:to_list(ordsets:subtract(Need, Have)). + + + +%%% Input argument mangling + +-spec check_name(string()) -> string() | no_return(). +%% @private +%% Ensure that the name provided adheres to the rules: +%% Name must start with a character between and including `a' and `z' +%% Name can only include lowercase `a' through `z', numbers and underscores. +%% This function halts execution with an error message to STDOUT if an invalid string +%% is provided as an argument. + +check_name([Char | Rest]) + when $a =< Char, Char =< $z -> + case check_name(Rest, [Char]) of + {ok, Name} -> Name; + error -> error_exit("Bad name.", ?FILE, ?LINE) + end; +check_name(_) -> + error_exit("Bad name.", ?FILE, ?LINE). + + +-spec check_name(string(), list()) -> Result + when Result :: {ok, AppName :: string()} + | error. +%% @private +%% Does the work of checking whether the argument to check_name/1 is valid. + +check_name([Char | Rest], Acc) + when $a =< Char, Char =< $z; + $0 =< Char, Char =< $9; + Char == $_ -> + check_name(Rest, [Char | Acc]); +check_name("", Acc) -> + App = lists:reverse(Acc), + {ok, App}; +check_name(_, _) -> + error. + + +-spec check_version(string()) -> version() | no_return(). +%% @private +%% Checks that a version string is a valid string of the form `X.Y.Z'. Halts execution +%% on bad input after printing a message to STDOUT. + +check_version(String) -> + case string_to_version(String) of + {true, Version} -> Version; + false -> error_exit("Bad version ~tp", [String], ?FILE, ?LINE) + end. + + +-spec string_to_version(String) -> Result + when String :: string(), + Result :: {true, version()} + | false. +%% @private +%% @equiv string_to_version(string(), "", {z, z, z}) + +string_to_version(String) -> + string_to_version(String, "", {z, z, z}). + + +-spec string_to_version(String, Acc, Version) -> Result + when String :: string(), + Acc :: list(), + Version :: version(), + Result :: {true, version()} + | false. +%% @private +%% Accepts a full or partial version string of the form `X.Y.Z', `X.Y' or `X' and +%% returns an internal representation of the version indicated as `{true, Version}' or +%% `false'. The reason for this return type is to allow it to work smoothly with +%% functions from the `lists' module. + +string_to_version([Char | Rest], Acc, Version) when $0 =< Char andalso Char =< $9 -> + string_to_version(Rest, [Char | Acc], Version); +string_to_version([$.], _, _) -> + false; +string_to_version([$. | Rest], Acc, {z, z, z}) -> + X = list_to_integer(lists:reverse(Acc)), + string_to_version(Rest, "", {X, z, z}); +string_to_version([$. | Rest], Acc, {X, z, z}) -> + Y = list_to_integer(lists:reverse(Acc)), + string_to_version(Rest, "", {X, Y, z}); +string_to_version("", "", Version) -> + {true, Version}; +string_to_version([], Acc, {z, z, z}) -> + X = list_to_integer(lists:reverse(Acc)), + {true, {X, z, z}}; +string_to_version([], Acc, {X, z, z}) -> + Y = list_to_integer(lists:reverse(Acc)), + {true, {X, Y, z}}; +string_to_version([], Acc, {X, Y, z}) -> + Z = list_to_integer(lists:reverse(Acc)), + {true, {X, Y, Z}}; +string_to_version(_, _, _) -> + false. + + +-spec version_to_string(version()) -> string(). +%% @private +%% Inverse of string_to_version/3. + +version_to_string({z, z, z}) -> + ""; +version_to_string({X, z, z}) -> + integer_to_list(X); +version_to_string({X, Y, z}) -> + lists:flatten(lists:join($., [integer_to_list(Element) || Element <- [X, Y]])); +version_to_string({X, Y, Z}) -> + lists:flatten(lists:join($., [integer_to_list(Element) || Element <- [X, Y, Z]])). + + +-spec appstring_to_appid(string()) -> app_id(). +%% @private +%% Converts a proper appstring to an app_id(). +%% This function takes into account missing version elements. +%% Examples: +%% `{"foo", "bar", {1, 2, 3}} = appstring_to_appid("foo-bar-1.2.3")' +%% `{"foo", "bar", {1, 2, z}} = appstring_to_appid("foo-bar-1.2")' +%% `{"foo", "bar", {1, z, z}} = appstring_to_appid("foo-bar-1")' +%% `{"foo", "bar", {z, z, z}} = appstring_to_appid("foo-bar")' + +appstring_to_appid(String) -> + case string:lexemes(String, [$-]) of + [Realm, App, VersionString] -> + Realm = check_name(Realm), + App = check_name(App), + Version = check_version(VersionString), + {Realm, App, Version}; + [Realm, App] -> + Realm = check_name(Realm), + App = check_name(App), + {Realm, App, {z, z, z}}; + [App] -> + App = check_name(App), + {"otpr", App, {z, z, z}} + end. + + +-spec appid_to_appstring(app_id()) -> string(). +%% @private +%% Map an AppID to a correct string representation. +%% This function takes into account missing version elements. +%% Examples: +%% `"foo-bar-1.2.3" = appid_to_appstring({"foo", "bar", {1, 2, 3}})' +%% `"foo-bar-1.2" = appid_to_appstring({"foo", "bar", {1, 2, z}})' +%% `"foo-bar-1" = appid_to_appstring({"foo", "bar", {1, z, z}})' +%% `"foo-bar" = appid_to_appstring({"foo", "bar", {z, z, z}})' + +appid_to_appstring({Realm, App, {z, z, z}}) -> + lists:flatten(lists:join($-, [Realm, App])); +appid_to_appstring({Realm, App, Version}) -> + VersionString = version_to_string(Version), + lists:flatten(lists:join($-, [Realm, App, VersionString])). + + +-spec namify_zrp(AppID) -> ZrpFileName + when AppID :: app_id(), + ZrpFileName :: file:filename(). +%% @private +%% Map an AppID to its correct .zrp package file name. + +namify_zrp(AppID) -> namify(AppID, "zrp"). + + +-spec namify_tgz(AppID) -> TgzFileName + when AppID :: app_id(), + TgzFileName :: file:filename(). +%% @private +%% Map an AppID to its correct gzipped tarball source bundle filename. + +namify_tgz(AppID) -> namify(AppID, "tgz"). + + +-spec namify(AppID, Suffix) -> FileName + when AppID :: app_id(), + Suffix :: string(), + FileName :: file:filename(). +%% @private +%% Converts an AppID to a canonical string, then appends the provided filename Suffix. + +namify(AppID, Suffix) -> + AppString = appid_to_appstring(AppID), + AppString ++ "." ++ Suffix. + + + +%%% User menu interface (terminal) + + +-spec select(Options) -> Selected + when Options :: [option()], + Selected :: term(). +%% @private +%% Take a list of Options to present the user, then return the indicated option to the +%% caller once the user selects something. + +select(Options) -> + Max = show(Options), + case pick(string:to_integer(io:get_line("(or ^C to quit)~n ? ")), Max) of + error -> + ok = hurr(), + select(Options); + I -> + {_, Value} = lists:nth(I, Options), + Value + end. + + +-spec select_string(Strings) -> Selected + when Strings :: [string()], + Selected :: string(). +%% @private +%% @equiv select([{S, S} || S <- Strings]) + +select_string(Strings) -> + select([{S, S} || S <- Strings]). + + +-spec show(Options) -> Index + when Options :: [option()], + Index :: pos_integer(). +%% @private +%% @equiv show(Options, 0). + +show(Options) -> + show(Options, 0). + + +-spec show(Options, Index) -> Count + when Options :: [option()], + Index :: non_neg_integer(), + Count :: pos_integer(). +%% @private +%% Display the list of options needed to the user, and return the option total count. + +show([], I) -> + I; +show([{Label, _} | Rest], I) -> + Z = I + 1, + ok = io:format(" ~2w - ~ts~n", [Z, Label]), + show(Rest, Z). + + +-spec pick({Selection, term()}, Max) -> Result + when Selection :: error | integer(), + Max :: pos_integer(), + Result :: pos_integer() | error. +%% @private +%% Interpret a user's selection returning either a valid selection index or `error'. + +pick({error, _}, _) -> error; +pick({I, _}, Max) when 0 < I, I =< Max -> I; +pick(_, _) -> error. + + +-spec hurr() -> ok. +%% @private +%% Present an appropriate response when the user derps on selection. + +hurr() -> io:format("That isn't an option.~n"). + + + +%%% Directory Management + + +-spec ensure_zomp_home() -> ok. +%% @private +%% Ensure the zomp home directory exists and is populated. +%% Every entry function should run this initially. + +ensure_zomp_home() -> + ZompDir = zomp_dir(), + case filelib:is_dir(ZompDir) of + true -> + ok; + false -> + {ok, CWD} = file:get_cwd(), + force_dir(ZompDir), + ok = file:set_cwd(ZompDir), + SubDirs = ["tmp", "key", "var", "lib", "zrp", "etc"], + ok = lists:foreach(fun file:make_dir/1, SubDirs), + ok = write_terms(default_realm_file(), default_realm()), + ok = file:write_file(default_pubkey_file(), default_pubkey()), + ok = log(info, "Zomp userland directory initialized."), + file:set_cwd(CWD) + end. + + +-spec zomp_dir() -> file:filename(). +%% @private +%% Check the host OS and return the absolute path to the zomp filesystem root. + +zomp_dir() -> + case os:type() of + {unix, _} -> + Home = os:getenv("HOME"), + Dir = ".zomp", + filename:join(Home, Dir); + {win32, _} -> + Drive = os:getenv("HOMEDRIVE"), + Path = os:getenv("HOMEPATH"), + Dir = "zomp", + filename:join([Drive, Path, Dir]) + end. + + +-spec ensure_app_dirs(app_id()) -> ok. +%% @private +%% Procedure to guarantee that directory locations necessary for the indicated app to +%% run have been created or halt execution. + +ensure_app_dirs(AppID) -> + AppHome = app_home(AppID), + AppData = app_dir("var", AppID), + AppConf = app_dir("etc", AppID), + Dirs = [AppHome, AppData, AppConf], + ok = lists:foreach(fun force_dir/1, Dirs), + log(info, "Created dirs:~n\t~ts~n\t~ts~n\t~ts", Dirs). + + +-spec app_home(AppID) -> AppHome + when AppID :: app_id(), + AppHome :: file:filename(). +%% @private +%% Accept an AppID and return the installation directory for the indicated application. +%% NOTE: +%% This system does NOT anticipate symlinks of incomplete versions to their latest +%% installed version (for example, an incomplete `{1, 2, z}' resolving to a symlink +%% `lib/foo-bar-1.2' which is always updated to point to the latest version 1.2.x). + +app_home(AppID) -> + filename:join([zomp_dir(), "lib", appid_to_appstring(AppID)]). + + +-spec app_dir(Prefix, AppID) -> AppDataDir + when Prefix :: string(), + AppID :: app_id(), + AppDataDir :: file:filename(). +%% @private +%% Create an absolute path to an application directory prefixed by the inclued argument. + +app_dir(Prefix, {Realm, App, _}) -> + RealmApp = Realm ++ "-" ++ App, + filename:join([zomp_dir(), Prefix, RealmApp]). + + +-spec force_dir(Path) -> Result + when Path :: file:filename(), + Result :: ok + | {error, file:posix()}. +%% @private +%% Guarantee a directory path is created if it is possible to create or if it already +%% exists. + +force_dir(Path) -> + case filelib:is_dir(Path) of + true -> ok; + false -> filelib:ensure_dir(filename:join(Path, "foo")) + end. + + + +%%% Persistent Zomp State +%%% +%%% The following functions maintain constants or very light convenience functions +%%% that make use of system-wide constants such as the default realm name, default +%%% public key, and other data necessary to bootstrap the system. + + +-spec default_realm_file() -> RealmFileName + when RealmFileName :: file:filename(). +%% @private +%% Return the base filename of the default realm file. + +default_realm_file() -> + realm_file(default_realm_name()). + + +-spec default_realm_name() -> Name + when Name :: string(). +%% @private +%% Return the name of the default realm. + +default_realm_name() -> + "otpr". + + +-spec realm_file(Realm) -> RealmFileName + when Realm :: string(), + RealmFileName :: file:filename(). +%% @private +%% Take a realm name, and return the name of the realm filename that would result. + +realm_file(Realm) -> + Realm ++ ".realm". + + +-spec default_realm() -> RealmData + when RealmData :: [{atom(), term()}]. +%% @private +%% Returns the default realm file's data contents for the default "otpr" realm. + +default_realm() -> + [{name, "otpr"}, + {prime, {"repo.psychobitch.party", 11311}}, + {pubkey, default_pubkey_file()}, + {serial, 0}, + {mirrors, []}]. + + +-spec default_pubkey_file() -> file:filename(). +%% @private +%% Returns the default filename of the default public key. + +default_pubkey_file() -> + "key/otpr.1.pub.der". + + +-spec default_pubkey() -> binary(). +%% @private +%% This function stores the binary contents of the default public key in DER format. +%% Doing this in a function is essentially like using a herefile in Bash. + +default_pubkey() -> + <<1170526623609313331798826318972097080557896621948083159586373016346811570540623523814426011993490293167510658875163780852129579343891111576406428675491227868125570029553836721253582239727832008666977889522526670373902361492830918639140761847180559556687253204307494936306069307171528877400209962142787058308740221939755312361860413975531508150223291108422638532792576263104963638096818852870688582998502102536693308795214193253585166432265144396969870581676155216529809785753049835842318198805379857414606363727445230640910295705259948273015496668069952400995675937310182784823621435512613227257682702758407858036197683634666558131083559654726498186745235163628111760825026969986395769481087197994986427088838210048234736434112178729013032345213637282290815839027637504309538687095441687636356524193476275012030156571775013858217400602512194340193440681965829477411264954556799403863486012736713903706193506878410947578154040532592626008808497608835124296529017804159705286317715644155305350224257244260965453649874471033452253082499940845996170964558413968751507443986189697350023010630553524737002017543233621194508406455743465763341654345366274867913624544034721065244860576536017333528275040881674913063184272153529110886460286503455305330851192409414251325951739930514383412398798397169143552351929225078321776410580550088942920371869276662363003778677125037156672547734001647521245835866935294771855501141038223226549689862909953059166203977747766244019902323008152684587630882351278639697258019126733864910210128726118293540255959256597047563483255422642476502931615170150279457262340283463240052013184396583425577647694479205638188304711342708918448926127873312263215725459445847837288305565332375343953499300740443134048756072889091269830410360478466021318806219775513929766205963920179119585525683940978348051934951531003108279739296413836014878110298764281501544267131870022937730804080147990914120458451989661878314958524775077357936603530507381414681306510832486998678859256113064859502255575935373463709491675650991615474781558091560988205268798622776917923800097513069754689850980381850490662482961403587126912566501355644996590916922352735199214989138312856189875740424585814665007172318472691695957369820456341466786796592662135451831988129143024570641715114511174217352536557906484463741194647291182294337994718316817204867347695280457082728945911284833182013377663664430399865622471148347081767689841618546822415385957879982189694968028649835009229433349600455509536803831313830531746047425571328569634168722980491088303167148354034563578494773467308480373559768673286318322053544821433023042519594934353482721862948302631174310147010936830502360070216922584309962755494775082047690911998699570511078636376582952731884810583686700059291779239354983531555181641890341326596564548265142534064300496297926337825631921898202540803202984034394784105085299461195927463845508809485122164029528839799785844442443320039670416946533059409608926282019699119974258605650707621139750878861541251137000244133051922824004103328765954846809123299108643057388278106591988288588948726535195014652816533709432709443954841948958140326831643843906635067011302827793775757370405519240285135798743643797930642236731316466261629711900502696653563418054590076289653489719291121150266002099855392711464520928906433276097459061937053797457493166175864841786220404163949123451204756345296224150348843428777300804284267552332261715309580834396983346300530128191498649494481252342446265281499938013011901846865327797551832040198638001834019915656624142453248674154025598865589700532971711073725385563400552303085348956272116643141577079495146481883784661063082399836741351657205056526803513667042134368197760702297797779121467458147679998609606364108573808591354056588770173469993932525100580531597375569598234130308636537517111449993369547954653263045866310538500083896993112212303722148462970005994202673301481606806414256044693412925931019110552827804892050433228331280670562421203245853375417503339200768693046544396231856765919093252078432727627221414636522639011044239359484661269388619025699989119275623338551483041590123908337000853294371085777425084159087803966041644743349623092361390058115563570787103198976595362533473421958104661160820266906203090829648646902638295050336316799572281472144754384682003756790117478811155220025535420078485563985223415989335480370251115982845339833011445261624828025843177973576483738938303080242315615333593789609241843889852679993907685041671938947639167721054076202421174520920916996533102606039402530751673420224664619445635819542316713250359524898015718931583729548373297621243023115983588963506710418076848806241677618452223292483533560902680339825696167663170302616711331313490757011906928728140010004845723770802553281761773606296962670294026670439797097955228790673139658966545527614535446680187443733681726438681960897106751213102099950826404109307953852794370653372653043187474529370340385390651752792530497659674388660225:16496>>. + + + + +%%% Usage + + +-spec usage_exit(Code) -> no_return() + when Code :: integer(). +%% @private +%% A convenience function that will display the zx usage message before halting +%% with the provided exit code. + +usage_exit(Code) -> + ok = usage(), + halt(Code). + + +-spec usage() -> ok. +%% @private +%% Display the zx command line usage message. + +usage() -> + T = "~n" + "zx~n" + "~n" + "Usage:~n" + " zx help~n" + " zx run AppID [Args]~n" + " zx init Type AppID~n" + " zx install Package~n" + " zx set dep AppID~n" + " zx set version Version~n" + " zx drop dep AppID~n" + " zx drop key KeyID~n" + " zx verup Level~n" + " zx runlocal~n" + " zx package [Path]~n" + " zx submit Package~n" + " zx keygen~n" + " zx genplt~n" + "~n" + "Where~n" + " AppID :: A string of the form Realm-App-Version~n" + " Args :: Arguments to pass to the application~n" + " Package :: A .zrp package path/filename~n" + " Type :: The project type: a standalone \"app\" or a \"lib\"~n" + " Version :: Version string X, X.Y, or X.Y.Z: \"1\", \"1.2\", \"1.2.3\"~n" + " KeyID :: The prefix of a keypair to drop~n" + " Level :: The version level, one of \"major\", \"minor\", or \"patch\"~n" + " Path :: Path to a valid project directory~n" + "~n", + io:format(T). + + + +%%% Error exits + + +-spec error_exit(Error, Path, Line) -> no_return() + when Error :: term(), + Path :: file:filename(), + Line :: non_neg_integer(). +%% @private +%% Format an error message in a way that makes it easy to locate. + +error_exit(Error, Path, Line) -> + File = filename:basename(Path), + ok = log(error, "~ts:~tp: ~tp", [File, Line, Error]), + halt(1). + + +-spec error_exit(Format, Args, Path, Line) -> no_return() + when Format :: string(), + Args :: [term()], + Path :: file:filename(), + Line :: non_neg_integer(). +%% @private +%% Format an error message in a way that makes it easy to locate. + +error_exit(Format, Args, Path, Line) -> + File = filename:basename(Path), + ok = log(error, "~ts:~tp: " ++ Format, [File, Line | Args]), + halt(1). + + + +%%% Logger + +-spec log(Level, Format) -> ok + when Level :: info + | warning + | error, + Format :: string(). +%% @private +%% @equiv log(Level, Format, []) + +log(Level, Format) -> + log(Level, Format, []). + + +-spec log(Level, Format, Args) -> ok + when Level :: info + | warning + | error, + Format :: string(), + Args :: [term()]. +%% @private +%% A logging abstraction to hide whatever logging back end is actually in use. +%% Format must adhere to Erlang format string rules, and the arity of Args must match +%% the provided format. + +log(Level, Format, Args) -> + Tag = + case Level of + info -> "[INFO]"; + warning -> "[WARNING]"; + error -> "[ERROR]" + end, + io:format("~s ~p: " ++ Format ++ "~n", [Tag, self() | Args]).