using GLib; namespace Spry.Cli { /** * `spry` — the Spry application CLI. * * Scaffolds Statum + Spry applications (`spry new`, including their USM * packaging), grows them with app-owned page/action/resource/auth * templates (`spry add …`), maintains the static key material in * `web-config.json` (`spry keys`), and ships them as container images * through USM (`spry deploy`). Edits to generated projects happen only * within `// spry:*-begin/end` markers in `src/main.vala` and * `# spry:*-begin/end` markers in `meson.build`, so `add` commands are * idempotent and never reformat surrounding user code. */ public class SpryCli : Object { private static string? tools_dir = null; private static string? libdir = null; private static bool force = false; private static string? route = null; private static string? out_path = null; private static string? usm = null; private static string? deploy_exec = null; private static string? deploy_base = null; [CCode (array_length = false, null_terminated = true)] private static string[] deploy_repositories = null; private static string? deploy_installer_url = null; private static bool deploy_no_build = false; private static bool deploy_verbose = false; private static int dev_port = 8080; private static bool dev_fresh = false; private static bool dev_no_run = false; private const OptionEntry[] global_options = { { "tools-dir", '\0', 0, OptionArg.STRING, ref tools_dir, "Directory containing the statum-* tools (default: search PATH, then ~/.local/bin)", "DIR" }, { "libdir", '\0', 0, OptionArg.STRING, ref libdir, "Library directory prepended to spawned tools' LD_LIBRARY_PATH/PKG_CONFIG_PATH (default: pkg-config spry-0.2 libdir)", "DIR" }, { null } }; private const OptionEntry[] new_options = { { "force", 'f', 0, OptionArg.NONE, ref force, "Overwrite generated files when the target directory already exists", null }, { null } }; private const OptionEntry[] page_options = { { "route", 'r', 0, OptionArg.STRING, ref route, "Page route (default: /)", "PATH" }, { null } }; private const OptionEntry[] keys_options = { { "out", 'o', 0, OptionArg.FILENAME, ref out_path, "Configuration file to merge into (default: web-config.json)", "FILE" }, { null } }; private const OptionEntry[] deploy_options = { { "usm", '\0', 0, OptionArg.FILENAME, ref usm, "usm binary to delegate to (default: search PATH)", "FILE" }, { "exec", '\0', 0, OptionArg.STRING, ref deploy_exec, "Container command (default: \" 8080\")", "CMD" }, { "base", '\0', 0, OptionArg.STRING, ref deploy_base, "Base image (default: usm's fedora:43)", "IMAGE" }, { "repository", '\0', 0, OptionArg.STRING_ARRAY, ref deploy_repositories, "USM repository (.usmr) to resolve from; repeatable (default: the machine's configured repositories)", "FILE" }, { "installer-url", '\0', 0, OptionArg.STRING, ref deploy_installer_url, "USM installer source override (file://… for local testing)", "URL" }, { "no-build", '\0', 0, OptionArg.NONE, ref deploy_no_build, "Only generate the deploy context; do not build the image", null }, { "verbose", 'v', 0, OptionArg.NONE, ref deploy_verbose, "Stream package script output to the terminal during the image build", null }, { null } }; private const OptionEntry[] dev_options = { { "port", 'p', 0, OptionArg.INT, ref dev_port, "Port to run the application on (default: 8080)", "N" }, { "fresh", '\0', 0, OptionArg.NONE, ref dev_fresh, "Delete the SQLite database before starting", null }, { "no-run", '\0', 0, OptionArg.NONE, ref dev_no_run, "Build once and exit; do not run or watch", null }, { null } }; private const string MESON_PAGES_BEGIN = "# spry:pages-begin"; private const string MESON_PAGES_END = "# spry:pages-end"; private const string MESON_RESOURCES_BEGIN = "# spry:resources-begin"; private const string MESON_RESOURCES_END = "# spry:resources-end"; private const string MESON_SOURCES_BEGIN = "# spry:sources-begin"; private const string MESON_SOURCES_END = "# spry:sources-end"; private const string MESON_GENERATED_BEGIN = "# spry:generated-begin"; private const string MESON_GENERATED_END = "# spry:generated-end"; private const string VALA_PAGES_BEGIN = "// spry:pages-begin"; private const string VALA_PAGES_END = "// spry:pages-end"; private const string VALA_ACTIONS_BEGIN = "// spry:actions-begin"; private const string VALA_ACTIONS_END = "// spry:actions-end"; private const string VALA_RESOURCES_BEGIN = "// spry:resources-begin"; private const string VALA_RESOURCES_END = "// spry:resources-end"; private const string VALA_AUTH_BEGIN = "// spry:auth-begin"; private const string VALA_AUTH_END = "// spry:auth-end"; private const string NAV_BEGIN = ""; private const string NAV_END = ""; private const string NAV_AUTH_BEGIN = ""; private const string NAV_AUTH_END = ""; public static int main(string[] args) { if (args.length < 2) { print_usage(); return 1; } var command = args[1]; if (command == "--help" || command == "-h" || command == "help") { print_usage(); return 0; } try { switch (command) { case "new": return cmd_new(slice(args, 1)); case "add": return cmd_add(slice(args, 1)); case "keys": return cmd_keys(slice(args, 1)); case "deploy": return cmd_deploy(slice(args, 1)); case "dev": return cmd_dev(slice(args, 1)); default: stderr.printf("Error: Unknown command '%s'.\n", command); print_usage(); return 1; } } catch (OptionError e) { stderr.printf("Error: %s\n", e.message); return 1; } catch (Error e) { stderr.printf("Error: %s\n", e.message); return 1; } } private static void print_usage() { stdout.printf( "Usage:\n" + " spry [options]\n" + "\n" + "Commands:\n" + " new Scaffold a new Statum + Spry application\n" + " add page [--route PATH] Add a page (HTML + entrypoint + wiring)\n" + " add action Add a StatumAction skeleton\n" + " add resource Embed a static resource (statum-mkres)\n" + " add login Add the login page (Spry.Actions.LoginAction)\n" + " add register Add the registration page\n" + " add user-management Add the admin user-management page\n" + " keys [--out FILE] Generate/merge static keys into web-config.json\n" + " deploy [--exec CMD] [--base I] Build the USM container image (usm manifest\n" + " deploy; --repository/--installer-url/\n" + " --no-build pass through)\n" + " dev [--port N] [--fresh] Build, run and watch: rebuild + restart on save\n" + "\n" + "Options:\n" + " --tools-dir DIR Directory containing the statum-* tools (default: PATH)\n" + " --libdir DIR Library dir for spawned tools' LD_LIBRARY_PATH\n" + " (default: pkg-config spry-0.2 libdir)\n" + " --help Show this help\n"); } private static void print_add_usage() { stdout.printf( "Usage:\n" + " spry add page [--route /path]\n" + " spry add action \n" + " spry add resource \n" + " spry add login\n" + " spry add register\n" + " spry add user-management\n"); } // ---------------------------------------------------------------------- // spry new // ---------------------------------------------------------------------- private static int cmd_new(string[] args) throws Error, OptionError { parse(args, " - scaffold a new Statum + Spry application", new_options); if (args.length < 2) { stderr.printf("Error: No application name specified.\n"); return 1; } var name = args[1].down(); if (!Generator.valid_name(name)) { stderr.printf("Error: '%s' is not a valid application name (letters, digits, '-' and '_', starting with a letter).\n", name); return 1; } var app_dir = Path.build_filename(Environment.get_current_dir(), name); if (FileUtils.test(app_dir, FileTest.EXISTS) && !force) { stderr.printf("Error: %s already exists (use --force to overwrite its generated files).\n", app_dir); return 1; } var vars = base_vars(name); write_app_file(app_dir, "meson.build", Generator.fill(Templates.PROJECT_MESON, vars)); write_app_file(app_dir, "src/main.vala", Generator.fill(Templates.APP_MAIN, vars)); write_app_file(app_dir, "src/pages/main.html", Generator.fill(Templates.LAYOUT, vars)); write_app_file(app_dir, "src/pages/home.html", Generator.fill(Templates.HOME, vars)); write_app_file(app_dir, "src/entrypoints/HomeEntrypoint.vala", Generator.fill(Templates.HOME_ENTRYPOINT, vars)); write_app_file(app_dir, ".gitignore", Generator.fill(Templates.GITIGNORE, vars)); write_app_file(app_dir, "README.md", Generator.fill(Templates.README, vars)); // USM packaging: the manifest + scripts that let `spry deploy` // (usm manifest deploy) build and install the app in-container write_app_file(app_dir, "MANIFEST.usm", Generator.fill(Templates.MANIFEST_USM, vars)); write_app_file(app_dir, "usm-scripts/build.sh", Templates.USM_BUILD_SH, 0755); write_app_file(app_dir, "usm-scripts/install.sh", Templates.USM_INSTALL_SH, 0755); write_app_file(app_dir, ".usmignore", Templates.USMIGNORE); var config_path = Path.build_filename(app_dir, "web-config.json"); try { Keys.run(config_path, tools_dir, libdir); } catch (Error e) { stderr.printf("Warning: %s — wrote an empty config; run `spry keys` later.\n", e.message); Generator.write_file(config_path, "{\n \"statum\": {\n }\n}\n"); } stdout.printf("Created %s\n", app_dir); stdout.printf("Next:\n cd %s\n spry dev\n", name); return 0; } // ---------------------------------------------------------------------- // spry add … // ---------------------------------------------------------------------- private static int cmd_add(string[] args) throws Error, OptionError { if (args.length < 2) { print_add_usage(); return 1; } var what = args[1]; var rest = slice(args, 1); switch (what) { case "page": return cmd_add_page(rest); case "action": return cmd_add_action(rest); case "resource": return cmd_add_resource(rest); case "login": return cmd_add_login(rest); case "register": return cmd_add_register(rest); case "user-management": return cmd_add_user_management(rest); default: stderr.printf("Error: Unknown add command '%s'.\n", what); print_add_usage(); return 1; } } private static int cmd_add_page(string[] args) throws Error, OptionError { parse(args, " - add a page (HTML + entrypoint + wiring)", page_options); if (args.length < 2) { stderr.printf("Error: No page name specified.\n"); return 1; } var name = normalise(args[1]); if (!Generator.valid_name(name)) { stderr.printf("Error: '%s' is not a valid page name.\n", name); return 1; } var route_path = route != null ? (!)route : "/" + name; if (!route_path.has_prefix("/")) { route_path = "/" + route_path; } var app = require_app(); add_page(app, name, Generator.pascal_case(name), route_path, Generator.fill(Templates.PAGE, page_vars(app, name, route_path)), Generator.fill(Templates.PAGE_ENTRYPOINT, page_vars(app, name, route_path))); stdout.printf("Added page %s at %s\n", name, route_path); return 0; } private static int cmd_add_action(string[] args) throws Error, OptionError { parse(args, " - add a StatumAction skeleton", null); if (args.length < 2) { stderr.printf("Error: No action name specified.\n"); return 1; } var name = normalise(args[1]); if (!Generator.valid_name(name)) { stderr.printf("Error: '%s' is not a valid action name.\n", name); return 1; } var cls = Generator.pascal_case(name); var app = require_app(); var vars = app.vars; vars.set("CLASS", cls); write_new_app_file(app.dir, "src/actions/%sAction.vala".printf(cls), Generator.fill(Templates.ACTION, vars)); edit_meson(app, "'src/actions/%sAction.vala',".printf(cls), MESON_SOURCES_BEGIN, MESON_SOURCES_END); edit_main(app, "statum.action<%sAction>();".printf(cls), VALA_ACTIONS_BEGIN, VALA_ACTIONS_END); stdout.printf("Added action %s\n", cls); return 0; } private static int cmd_add_resource(string[] args) throws Error, OptionError { parse(args, " - embed a static resource", null); if (args.length < 2) { stderr.printf("Error: No resource file specified.\n"); return 1; } var source = args[1]; if (!FileUtils.test(source, FileTest.EXISTS)) { stderr.printf("Error: %s does not exist.\n", source); return 1; } var file_name = Path.get_basename(source); var stem = file_name.contains(".") ? file_name.substring(0, file_name.last_index_of(".")) : file_name; var cls = Generator.pascal_case(stem) + "Resource"; var var_name = Generator.snake_case(stem) + "_resource"; var content_type = content_type_for(file_name); var app = require_app(); var resource_path = Path.build_filename(app.dir, "src", "resources", file_name); if (!FileUtils.test(resource_path, FileTest.EXISTS)) { Generator.write_file(resource_path, Generator.read_file(source)); } var command = "[statum_mkres, '-o', '@OUTPUT@', '-n', '%s', '--class-name', '%s'".printf(file_name, cls); if (content_type != null) { command += ", '-c', '%s'".printf((!)content_type); } command += ", '@INPUT@']"; var target = "%s = custom_target('%s-resource',\n".printf(var_name, Generator.snake_case(stem)) + " input: 'src/resources/%s',\n".printf(file_name) + " output: '%s.vala',\n".printf(cls) + " command: %s\n".printf(command) + ")"; edit_meson(app, target, MESON_RESOURCES_BEGIN, MESON_RESOURCES_END); edit_meson(app, "%s,".printf(var_name), MESON_GENERATED_BEGIN, MESON_GENERATED_END); edit_main(app, "statum.add_resource<%s>();".printf(cls), VALA_RESOURCES_BEGIN, VALA_RESOURCES_END); stdout.printf("Added resource %s as %s\n", source, cls); return 0; } private static int cmd_add_login(string[] args) throws Error, OptionError { parse(args, "- add the login page", null); var app = require_app(); add_page(app, "login", "Login", "/login", Generator.fill(Templates.LOGIN, app.vars), Generator.fill(Templates.LOGIN_ENTRYPOINT, app.vars)); // Keep Spry.Actions registrations in sync with // SpryAuth.register_actions (Spry/src/Auth.vala), the canonical // list of shipped actions. edit_main(app, "statum.action();", VALA_AUTH_BEGIN, VALA_AUTH_END); edit_main(app, "statum.action();", VALA_AUTH_BEGIN, VALA_AUTH_END); edit_layout(app, "Login", NAV_BEGIN, NAV_END); edit_layout(app, "", NAV_AUTH_BEGIN, NAV_AUTH_END); Generator.append_section(Path.build_filename(app.dir, "README.md"), "## Guarding pages", Generator.fill(Templates.LOGIN_README, app.vars)); stdout.printf("Added login page at /login\n"); return 0; } private static int cmd_add_register(string[] args) throws Error, OptionError { parse(args, "- add the registration page", null); var app = require_app(); add_page(app, "register", "Register", "/register", Generator.fill(Templates.REGISTER, app.vars), Generator.fill(Templates.REGISTER_ENTRYPOINT, app.vars)); edit_main(app, "statum.action();", VALA_AUTH_BEGIN, VALA_AUTH_END); edit_layout(app, "Register", NAV_BEGIN, NAV_END); stdout.printf("Added register page at /register\n"); return 0; } private static int cmd_add_user_management(string[] args) throws Error, OptionError { parse(args, "- add the admin user-management page", null); var app = require_app(); add_page(app, "user-management", "UserManagement", "/users", Generator.fill(Templates.USER_MANAGEMENT, app.vars), Generator.fill(Templates.USER_MANAGEMENT_ENTRYPOINT, app.vars)); edit_main(app, "statum.action();", VALA_AUTH_BEGIN, VALA_AUTH_END); edit_main(app, "statum.action();", VALA_AUTH_BEGIN, VALA_AUTH_END); edit_main(app, "statum.action();", VALA_AUTH_BEGIN, VALA_AUTH_END); edit_main(app, "statum.action();", VALA_AUTH_BEGIN, VALA_AUTH_END); edit_main(app, "statum.action();", VALA_AUTH_BEGIN, VALA_AUTH_END); edit_layout(app, "Users", NAV_BEGIN, NAV_END); stdout.printf("Added user-management page at /users\n"); return 0; } // ---------------------------------------------------------------------- // spry dev // ---------------------------------------------------------------------- private static int cmd_dev(string[] args) throws Error, OptionError { parse(args, "- build, run and watch the application", dev_options); var app = require_app(); return Dev.run(app.dir, app.name, dev_port, dev_fresh, dev_no_run, libdir); } // ---------------------------------------------------------------------- // spry keys / spry deploy // ---------------------------------------------------------------------- private static int cmd_keys(string[] args) throws Error, OptionError { parse(args, "- generate and merge static keys into web-config.json", keys_options); Keys.run(out_path ?? "web-config.json", tools_dir, libdir); return 0; } private static int cmd_deploy(string[] args) throws Error, OptionError { string[] repositories; string[] remaining; extract_repositories(args, out repositories, out remaining); parse(remaining, "- build the USM container image via usm manifest deploy", deploy_options); var app = require_app(); return Deploy.run(app.dir, app.name, usm, deploy_exec, deploy_base, repositories, deploy_installer_url, deploy_no_build, deploy_verbose); } /** * Collects repeatable `--repository FILE` / `--repository=FILE` * arguments into {@link repositories}, leaving the rest in * {@link remaining} so the GLib option context (whose string-array * capture is unreliable here) never sees them. */ private static void extract_repositories(string[] args, out string[] repositories, out string[] remaining) { var found = new string[0]; var rest = new string[args.length]; rest[0] = args[0]; int out_index = 1; for (int i = 1; i < args.length; i++) { if (args[i] == "--repository" && i + 1 < args.length) { found += args[++i]; } else if (args[i].has_prefix("--repository=")) { found += args[i].substring("--repository=".length); } else { rest[out_index++] = args[i]; } } rest.length = out_index; repositories = found; remaining = rest; } // ---------------------------------------------------------------------- // Shared helpers // ---------------------------------------------------------------------- /** * The surrounding spry application: the current directory, the app * name (directory basename) and the namespace its generated pages * use (read back from meson.build, falling back to the directory * name). */ private class AppContext { public string dir; public string name; public string ns; public HashTable vars; public AppContext(string dir, string name, string ns) { this.dir = dir; this.name = name; this.ns = ns; this.vars = new HashTable(str_hash, str_equal); this.vars.insert("APP_NAME", name); this.vars.insert("NS", ns); } } private static AppContext require_app() throws Error { var cwd = Environment.get_current_dir(); if (!FileUtils.test(Path.build_filename(cwd, "meson.build"), FileTest.EXISTS) || !FileUtils.test(Path.build_filename(cwd, "src", "main.vala"), FileTest.EXISTS)) { throw Generator.cli_error("not a spry application directory (run from the project root)"); } var name = Path.get_basename(cwd); var ns = detect_ns(Generator.read_file(Path.build_filename(cwd, "meson.build"))); if (ns.length == 0) { ns = Generator.pascal_case(name); } return new AppContext(cwd, name, ns); } private static string detect_ns(string meson) { var marker = "'--ns', '"; var index = meson.index_of(marker); if (index < 0) { return ""; } var start = index + marker.length; var stop = meson.index_of("'", start); return stop > start ? meson.substring(start, stop - start) : ""; } private static HashTable base_vars(string name) { var vars = new HashTable(str_hash, str_equal); vars.insert("APP_NAME", name); vars.insert("NS", Generator.pascal_case(name)); return vars; } private static HashTable page_vars(AppContext app, string name, string route_path) { app.vars.set("CLASS", Generator.pascal_case(name)); app.vars.set("ROUTE", route_path); return app.vars; } private static void add_page(AppContext app, string name, string cls, string route_path, string html, string entrypoint) throws Error { write_new_app_file(app.dir, "src/pages/%s.html".printf(name), html); write_new_app_file(app.dir, "src/entrypoints/%sEntrypoint.vala".printf(cls), entrypoint); var var_name = Generator.snake_case(name) + "_page"; var target = "%s = custom_target('%s-page',\n".printf(var_name, Generator.snake_case(name)) + " input: 'src/pages/%s.html',\n".printf(name) + " output: '%sPage.vala',\n".printf(cls) + " command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', '%sPage', '--ns', '%s', '@INPUT@'],\n".printf(cls, app.ns) + " depend_files: files('src/pages/main.html')\n" + ")"; edit_meson(app, target, MESON_PAGES_BEGIN, MESON_PAGES_END); edit_meson(app, "'src/entrypoints/%sEntrypoint.vala',".printf(cls), MESON_SOURCES_BEGIN, MESON_SOURCES_END); edit_meson(app, "%s,".printf(var_name), MESON_GENERATED_BEGIN, MESON_GENERATED_END); edit_main(app, "statum.add_page<%sPage, %sEntrypoint>();".printf(cls, cls), VALA_PAGES_BEGIN, VALA_PAGES_END); } private static void edit_meson(AppContext app, string snippet, string begin, string end) throws Error { var path = Path.build_filename(app.dir, "meson.build"); Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet)); } private static void edit_main(AppContext app, string snippet, string begin, string end) throws Error { var path = Path.build_filename(app.dir, "src", "main.vala"); Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet)); } private static void edit_layout(AppContext app, string snippet, string begin, string end) throws Error { var path = Path.build_filename(app.dir, "src", "pages", "main.html"); Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet)); } private static void write_app_file(string app_dir, string relative, string contents, uint32 mode = 0644) throws Error { Generator.write_file(Path.build_filename(app_dir, relative), contents, mode); } /** * Writes a scaffolded file only when it does not exist yet, so * re-running `add` commands never clobbers user edits. */ private static void write_new_app_file(string app_dir, string relative, string contents) throws Error { var path = Path.build_filename(app_dir, relative); if (FileUtils.test(path, FileTest.EXISTS)) { stdout.printf("%s already exists — left untouched\n", path); return; } Generator.write_file(path, contents); } private static string normalise(string name) { return name.down(); } private static string? content_type_for(string filename) { var extension = filename.contains(".") ? filename.substring(filename.last_index_of(".") + 1).down() : ""; switch (extension) { case "css": return "text/css"; case "js": case "mjs": return "application/javascript"; case "png": return "image/png"; case "svg": return "image/svg+xml"; case "jpg": case "jpeg": return "image/jpeg"; case "ico": return "image/x-icon"; case "woff2": return "font/woff2"; case "woff": return "font/woff"; default: return null; } } private static void parse(string[] args, string summary, OptionEntry[]? extra) throws OptionError { var context = new OptionContext(summary); context.add_main_entries(global_options, null); if (extra != null) { context.add_main_entries((!)extra, null); } context.parse(ref args); } /** Drops `skip` positional words after `args[0]`, keeping `args[0]`. */ private static string[] slice(string[] args, int skip) { var rest = new string[int.max(1, args.length - skip)]; rest[0] = args[0]; for (int i = skip + 1; i < args.length; i++) { rest[i - skip] = args[i]; } return rest; } } }