spry.vala 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. using GLib;
  2. namespace Spry.Cli {
  3. /**
  4. * `spry` — the Spry application CLI.
  5. *
  6. * Scaffolds Statum + Spry applications (`spry new`, including their USM
  7. * packaging), grows them with app-owned page/action/resource/auth
  8. * templates (`spry add …`), maintains the static key material in
  9. * `web-config.json` (`spry keys`), and ships them as container images
  10. * through USM (`spry deploy`). Edits to generated projects happen only
  11. * within `// spry:*-begin/end` markers in `src/main.vala` and
  12. * `# spry:*-begin/end` markers in `meson.build`, so `add` commands are
  13. * idempotent and never reformat surrounding user code.
  14. */
  15. public class SpryCli : Object {
  16. private static string? tools_dir = null;
  17. private static string? libdir = null;
  18. private static bool force = false;
  19. private static string? route = null;
  20. private static string? out_path = null;
  21. private static string? usm = null;
  22. private static string? deploy_exec = null;
  23. private static string? deploy_base = null;
  24. private static string? deploy_spm = null;
  25. private static string? deploy_system = null;
  26. [CCode (array_length = false, null_terminated = true)]
  27. private static string[] deploy_repositories = null;
  28. private static string? deploy_installer_url = null;
  29. private static bool deploy_no_build = false;
  30. private static bool deploy_verbose = false;
  31. private static int dev_port = 8080;
  32. private static bool dev_fresh = false;
  33. private static bool dev_no_run = false;
  34. private const OptionEntry[] global_options = {
  35. { "tools-dir", '\0', 0, OptionArg.STRING, ref tools_dir, "Directory containing the statum-* tools (default: search PATH, then ~/.local/bin)", "DIR" },
  36. { "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" },
  37. { null }
  38. };
  39. private const OptionEntry[] new_options = {
  40. { "force", 'f', 0, OptionArg.NONE, ref force, "Overwrite generated files when the target directory already exists", null },
  41. { null }
  42. };
  43. private const OptionEntry[] page_options = {
  44. { "route", 'r', 0, OptionArg.STRING, ref route, "Page route (default: /<name>)", "PATH" },
  45. { null }
  46. };
  47. private const OptionEntry[] keys_options = {
  48. { "out", 'o', 0, OptionArg.FILENAME, ref out_path, "Configuration file to merge into (default: web-config.json)", "FILE" },
  49. { null }
  50. };
  51. private const OptionEntry[] deploy_options = {
  52. { "usm", '\0', 0, OptionArg.FILENAME, ref usm, "usm binary to delegate to (default: search PATH)", "FILE" },
  53. { "exec", '\0', 0, OptionArg.STRING, ref deploy_exec, "Container command (default: \"<app> 8080\")", "CMD" },
  54. { "system", '\0', 0, OptionArg.STRING, ref deploy_system, "Target system: fedora, debian, ubuntu, alpine or gentoo — expands to --base + --spm, so it cannot be combined with either (default: the fedora equivalent, i.e. --spm dnf on usm's fedora base)", "SYSTEM" },
  55. { "spm", '\0', 0, OptionArg.STRING, ref deploy_spm, "System package manager wired into the image: dnf, apt, apk, emerge or none (default: dnf via the fedora-equivalent default)", "SPM" },
  56. { "base", '\0', 0, OptionArg.STRING, ref deploy_base, "Base image (default: usm's fedora:43)", "IMAGE" },
  57. { "repository", '\0', 0, OptionArg.STRING_ARRAY, ref deploy_repositories, "USM repository (.usmr) to resolve from; repeatable (default: the machine's configured repositories)", "FILE" },
  58. { "installer-url", '\0', 0, OptionArg.STRING, ref deploy_installer_url, "USM installer source override (file://… for local testing)", "URL" },
  59. { "no-build", '\0', 0, OptionArg.NONE, ref deploy_no_build, "Only generate the deploy context; do not build the image", null },
  60. { "verbose", 'v', 0, OptionArg.NONE, ref deploy_verbose, "Stream package script output to the terminal during the image build", null },
  61. { null }
  62. };
  63. private const OptionEntry[] dev_options = {
  64. { "port", 'p', 0, OptionArg.INT, ref dev_port, "Port to run the application on (default: 8080)", "N" },
  65. { "fresh", '\0', 0, OptionArg.NONE, ref dev_fresh, "Delete the SQLite database before starting", null },
  66. { "no-run", '\0', 0, OptionArg.NONE, ref dev_no_run, "Build once and exit; do not run or watch", null },
  67. { null }
  68. };
  69. private const string MESON_PAGES_BEGIN = "# spry:pages-begin";
  70. private const string MESON_PAGES_END = "# spry:pages-end";
  71. private const string MESON_RESOURCES_BEGIN = "# spry:resources-begin";
  72. private const string MESON_RESOURCES_END = "# spry:resources-end";
  73. private const string MESON_SOURCES_BEGIN = "# spry:sources-begin";
  74. private const string MESON_SOURCES_END = "# spry:sources-end";
  75. private const string MESON_GENERATED_BEGIN = "# spry:generated-begin";
  76. private const string MESON_GENERATED_END = "# spry:generated-end";
  77. private const string VALA_PAGES_BEGIN = "// spry:pages-begin";
  78. private const string VALA_PAGES_END = "// spry:pages-end";
  79. private const string VALA_ACTIONS_BEGIN = "// spry:actions-begin";
  80. private const string VALA_ACTIONS_END = "// spry:actions-end";
  81. private const string VALA_RESOURCES_BEGIN = "// spry:resources-begin";
  82. private const string VALA_RESOURCES_END = "// spry:resources-end";
  83. private const string VALA_AUTH_BEGIN = "// spry:auth-begin";
  84. private const string VALA_AUTH_END = "// spry:auth-end";
  85. private const string NAV_BEGIN = "<!-- spry:nav-begin -->";
  86. private const string NAV_END = "<!-- spry:nav-end -->";
  87. private const string NAV_AUTH_BEGIN = "<!-- spry:nav-auth-begin -->";
  88. private const string NAV_AUTH_END = "<!-- spry:nav-auth-end -->";
  89. public static int main(string[] args) {
  90. if (args.length < 2) {
  91. print_usage();
  92. return 1;
  93. }
  94. var command = args[1];
  95. if (command == "--help" || command == "-h" || command == "help") {
  96. print_usage();
  97. return 0;
  98. }
  99. try {
  100. switch (command) {
  101. case "new":
  102. return cmd_new(slice(args, 1));
  103. case "add":
  104. return cmd_add(slice(args, 1));
  105. case "keys":
  106. return cmd_keys(slice(args, 1));
  107. case "deploy":
  108. return cmd_deploy(slice(args, 1));
  109. case "dev":
  110. return cmd_dev(slice(args, 1));
  111. default:
  112. stderr.printf("Error: Unknown command '%s'.\n", command);
  113. print_usage();
  114. return 1;
  115. }
  116. } catch (OptionError e) {
  117. stderr.printf("Error: %s\n", e.message);
  118. return 1;
  119. } catch (Error e) {
  120. stderr.printf("Error: %s\n", e.message);
  121. return 1;
  122. }
  123. }
  124. private static void print_usage() {
  125. stdout.printf(
  126. "Usage:\n" +
  127. " spry <command> [options]\n" +
  128. "\n" +
  129. "Commands:\n" +
  130. " new <name> Scaffold a new Statum + Spry application\n" +
  131. " add page <name> [--route PATH] Add a page (HTML + entrypoint + wiring)\n" +
  132. " add action <name> Add a StatumAction skeleton\n" +
  133. " add resource <file> Embed a static resource (statum-mkres)\n" +
  134. " add login Add the login page (Spry.Actions.LoginAction)\n" +
  135. " add register Add the registration page\n" +
  136. " add user-management Add the admin user-management page\n" +
  137. " keys [--out FILE] Generate/merge static keys into web-config.json\n" +
  138. " deploy [--exec CMD] Build the USM container image (usm manifest\n" +
  139. " [--system SYSTEM]| deploy; --repository/--installer-url/\n" +
  140. " [--spm SPM] [--base I] --no-build pass through; default --spm dnf\n" +
  141. " [--repository FILE]... on usm's fedora base; --system expands to\n" +
  142. " --base + --spm and excludes both)\n" +
  143. " dev [--port N] [--fresh] Build, run and watch: rebuild + restart on save\n" +
  144. "\n" +
  145. "Options:\n" +
  146. " --tools-dir DIR Directory containing the statum-* tools (default: PATH)\n" +
  147. " --libdir DIR Library dir for spawned tools' LD_LIBRARY_PATH\n" +
  148. " (default: pkg-config spry-0.2 libdir)\n" +
  149. " --help Show this help\n");
  150. }
  151. private static void print_add_usage() {
  152. stdout.printf(
  153. "Usage:\n" +
  154. " spry add page <name> [--route /path]\n" +
  155. " spry add action <name>\n" +
  156. " spry add resource <file>\n" +
  157. " spry add login\n" +
  158. " spry add register\n" +
  159. " spry add user-management\n");
  160. }
  161. // ----------------------------------------------------------------------
  162. // spry new
  163. // ----------------------------------------------------------------------
  164. private static int cmd_new(string[] args) throws Error, OptionError {
  165. parse(args, "<name> - scaffold a new Statum + Spry application", new_options);
  166. if (args.length < 2) {
  167. stderr.printf("Error: No application name specified.\n");
  168. return 1;
  169. }
  170. var name = args[1].down();
  171. if (!Generator.valid_name(name)) {
  172. stderr.printf("Error: '%s' is not a valid application name (letters, digits, '-' and '_', starting with a letter).\n", name);
  173. return 1;
  174. }
  175. var app_dir = Path.build_filename(Environment.get_current_dir(), name);
  176. if (FileUtils.test(app_dir, FileTest.EXISTS) && !force) {
  177. stderr.printf("Error: %s already exists (use --force to overwrite its generated files).\n", app_dir);
  178. return 1;
  179. }
  180. var vars = base_vars(name);
  181. write_app_file(app_dir, "meson.build", Generator.fill(Templates.PROJECT_MESON, vars));
  182. write_app_file(app_dir, "src/main.vala", Generator.fill(Templates.APP_MAIN, vars));
  183. write_app_file(app_dir, "src/pages/main.html", Generator.fill(Templates.LAYOUT, vars));
  184. write_app_file(app_dir, "src/pages/home.html", Generator.fill(Templates.HOME, vars));
  185. write_app_file(app_dir, "src/entrypoints/HomeEntrypoint.vala", Generator.fill(Templates.HOME_ENTRYPOINT, vars));
  186. write_app_file(app_dir, ".gitignore", Generator.fill(Templates.GITIGNORE, vars));
  187. write_app_file(app_dir, "README.md", Generator.fill(Templates.README, vars));
  188. // USM packaging: the manifest + scripts that let `spry deploy`
  189. // (usm manifest deploy) build and install the app in-container
  190. write_app_file(app_dir, "MANIFEST.usm", Generator.fill(Templates.MANIFEST_USM, vars));
  191. write_app_file(app_dir, "usm-scripts/build.sh", Templates.USM_BUILD_SH, 0755);
  192. write_app_file(app_dir, "usm-scripts/install.sh", Templates.USM_INSTALL_SH, 0755);
  193. write_app_file(app_dir, ".usmignore", Templates.USMIGNORE);
  194. var config_path = Path.build_filename(app_dir, "web-config.json");
  195. try {
  196. Keys.run(config_path, tools_dir, libdir);
  197. } catch (Error e) {
  198. stderr.printf("Warning: %s — wrote an empty config; run `spry keys` later.\n", e.message);
  199. Generator.write_file(config_path, "{\n \"statum\": {\n }\n}\n");
  200. }
  201. CompileCommands.build_initial(app_dir, libdir);
  202. stdout.printf("Created %s\n", app_dir);
  203. stdout.printf("Next:\n cd %s\n spry dev\n", name);
  204. return 0;
  205. }
  206. // ----------------------------------------------------------------------
  207. // spry add …
  208. // ----------------------------------------------------------------------
  209. private static int cmd_add(string[] args) throws Error, OptionError {
  210. if (args.length < 2) {
  211. print_add_usage();
  212. return 1;
  213. }
  214. var what = args[1];
  215. var rest = slice(args, 1);
  216. switch (what) {
  217. case "page":
  218. return cmd_add_page(rest);
  219. case "action":
  220. return cmd_add_action(rest);
  221. case "resource":
  222. return cmd_add_resource(rest);
  223. case "login":
  224. return cmd_add_login(rest);
  225. case "register":
  226. return cmd_add_register(rest);
  227. case "user-management":
  228. return cmd_add_user_management(rest);
  229. default:
  230. stderr.printf("Error: Unknown add command '%s'.\n", what);
  231. print_add_usage();
  232. return 1;
  233. }
  234. }
  235. private static int cmd_add_page(string[] args) throws Error, OptionError {
  236. parse(args, "<name> - add a page (HTML + entrypoint + wiring)", page_options);
  237. if (args.length < 2) {
  238. stderr.printf("Error: No page name specified.\n");
  239. return 1;
  240. }
  241. var name = normalise(args[1]);
  242. if (!Generator.valid_name(name)) {
  243. stderr.printf("Error: '%s' is not a valid page name.\n", name);
  244. return 1;
  245. }
  246. var route_path = route != null ? (!)route : "/" + name;
  247. if (!route_path.has_prefix("/")) {
  248. route_path = "/" + route_path;
  249. }
  250. var app = require_app();
  251. add_page(app, name, Generator.pascal_case(name), route_path,
  252. Generator.fill(Templates.PAGE, page_vars(app, name, route_path)),
  253. Generator.fill(Templates.PAGE_ENTRYPOINT, page_vars(app, name, route_path)));
  254. stdout.printf("Added page %s at %s\n", name, route_path);
  255. return 0;
  256. }
  257. private static int cmd_add_action(string[] args) throws Error, OptionError {
  258. parse(args, "<name> - add a StatumAction skeleton", null);
  259. if (args.length < 2) {
  260. stderr.printf("Error: No action name specified.\n");
  261. return 1;
  262. }
  263. var name = normalise(args[1]);
  264. if (!Generator.valid_name(name)) {
  265. stderr.printf("Error: '%s' is not a valid action name.\n", name);
  266. return 1;
  267. }
  268. var cls = Generator.pascal_case(name);
  269. var app = require_app();
  270. var vars = app.vars;
  271. vars.set("CLASS", cls);
  272. write_new_app_file(app.dir, "src/actions/%sAction.vala".printf(cls),
  273. Generator.fill(Templates.ACTION, vars));
  274. edit_meson(app, "'src/actions/%sAction.vala',".printf(cls), MESON_SOURCES_BEGIN, MESON_SOURCES_END);
  275. edit_main(app, "statum.action<%sAction>();".printf(cls), VALA_ACTIONS_BEGIN, VALA_ACTIONS_END);
  276. stdout.printf("Added action %s\n", cls);
  277. return 0;
  278. }
  279. private static int cmd_add_resource(string[] args) throws Error, OptionError {
  280. parse(args, "<file> - embed a static resource", null);
  281. if (args.length < 2) {
  282. stderr.printf("Error: No resource file specified.\n");
  283. return 1;
  284. }
  285. var source = args[1];
  286. if (!FileUtils.test(source, FileTest.EXISTS)) {
  287. stderr.printf("Error: %s does not exist.\n", source);
  288. return 1;
  289. }
  290. var file_name = Path.get_basename(source);
  291. var stem = file_name.contains(".") ? file_name.substring(0, file_name.last_index_of(".")) : file_name;
  292. var cls = Generator.pascal_case(stem) + "Resource";
  293. var var_name = Generator.snake_case(stem) + "_resource";
  294. var content_type = content_type_for(file_name);
  295. var app = require_app();
  296. var resource_path = Path.build_filename(app.dir, "src", "resources", file_name);
  297. if (!FileUtils.test(resource_path, FileTest.EXISTS)) {
  298. Generator.write_file(resource_path, Generator.read_file(source));
  299. }
  300. var command = "[statum_mkres, '-o', '@OUTPUT@', '-n', '%s', '--class-name', '%s'".printf(file_name, cls);
  301. if (content_type != null) {
  302. command += ", '-c', '%s'".printf((!)content_type);
  303. }
  304. command += ", '@INPUT@']";
  305. var target = "%s = custom_target('%s-resource',\n".printf(var_name, Generator.snake_case(stem))
  306. + " input: 'src/resources/%s',\n".printf(file_name)
  307. + " output: '%s.vala',\n".printf(cls)
  308. + " command: %s\n".printf(command)
  309. + ")";
  310. edit_meson(app, target, MESON_RESOURCES_BEGIN, MESON_RESOURCES_END);
  311. edit_meson(app, "%s,".printf(var_name), MESON_GENERATED_BEGIN, MESON_GENERATED_END);
  312. edit_main(app, "statum.add_resource<%s>();".printf(cls), VALA_RESOURCES_BEGIN, VALA_RESOURCES_END);
  313. stdout.printf("Added resource %s as %s\n", source, cls);
  314. return 0;
  315. }
  316. private static int cmd_add_login(string[] args) throws Error, OptionError {
  317. parse(args, "- add the login page", null);
  318. var app = require_app();
  319. add_page(app, "login", "Login", "/login",
  320. Generator.fill(Templates.LOGIN, app.vars),
  321. Generator.fill(Templates.LOGIN_ENTRYPOINT, app.vars));
  322. // Keep Spry.Actions registrations in sync with
  323. // SpryAuth.register_actions (Spry/src/Auth.vala), the canonical
  324. // list of shipped actions.
  325. edit_main(app, "statum.action<Spry.Actions.LoginAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  326. edit_main(app, "statum.action<Spry.Actions.LogoutAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  327. edit_layout(app, "<a href=\"/login\" stm-if=\"!auth\">Login</a>", NAV_BEGIN, NAV_END);
  328. edit_layout(app, "<button type=\"button\" stm-action=\"auth.logout\">Log out</button>", NAV_AUTH_BEGIN, NAV_AUTH_END);
  329. Generator.append_section(Path.build_filename(app.dir, "README.md"),
  330. "## Guarding pages", Generator.fill(Templates.LOGIN_README, app.vars));
  331. stdout.printf("Added login page at /login\n");
  332. return 0;
  333. }
  334. private static int cmd_add_register(string[] args) throws Error, OptionError {
  335. parse(args, "- add the registration page", null);
  336. var app = require_app();
  337. add_page(app, "register", "Register", "/register",
  338. Generator.fill(Templates.REGISTER, app.vars),
  339. Generator.fill(Templates.REGISTER_ENTRYPOINT, app.vars));
  340. edit_main(app, "statum.action<Spry.Actions.RegisterAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  341. edit_layout(app, "<a href=\"/register\" stm-if=\"!auth\">Register</a>", NAV_BEGIN, NAV_END);
  342. stdout.printf("Added register page at /register\n");
  343. return 0;
  344. }
  345. private static int cmd_add_user_management(string[] args) throws Error, OptionError {
  346. parse(args, "- add the admin user-management page", null);
  347. var app = require_app();
  348. add_page(app, "user-management", "UserManagement", "/users",
  349. Generator.fill(Templates.USER_MANAGEMENT, app.vars),
  350. Generator.fill(Templates.USER_MANAGEMENT_ENTRYPOINT, app.vars));
  351. edit_main(app, "statum.action<Spry.Actions.SetUserEnabledAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  352. edit_main(app, "statum.action<Spry.Actions.GrantPermissionAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  353. edit_main(app, "statum.action<Spry.Actions.RevokePermissionAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  354. edit_main(app, "statum.action<Spry.Actions.AlterUserAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  355. edit_main(app, "statum.action<Spry.Actions.DeleteUserAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  356. edit_layout(app, "<a href=\"/users\" stm-if=\"auth && (auth.permissions.indexOf('admin') !== -1 || auth.permissions.indexOf('*') !== -1)\">Users</a>", NAV_BEGIN, NAV_END);
  357. stdout.printf("Added user-management page at /users\n");
  358. return 0;
  359. }
  360. // ----------------------------------------------------------------------
  361. // spry dev
  362. // ----------------------------------------------------------------------
  363. private static int cmd_dev(string[] args) throws Error, OptionError {
  364. parse(args, "- build, run and watch the application", dev_options);
  365. var app = require_app();
  366. return Dev.run(app.dir, app.name, dev_port, dev_fresh, dev_no_run, libdir);
  367. }
  368. // ----------------------------------------------------------------------
  369. // spry keys / spry deploy
  370. // ----------------------------------------------------------------------
  371. private static int cmd_keys(string[] args) throws Error, OptionError {
  372. parse(args, "- generate and merge static keys into web-config.json", keys_options);
  373. Keys.run(out_path ?? "web-config.json", tools_dir, libdir);
  374. return 0;
  375. }
  376. private static int cmd_deploy(string[] args) throws Error, OptionError {
  377. string[] repositories;
  378. string[] remaining;
  379. extract_repositories(args, out repositories, out remaining);
  380. parse(remaining, "- build the USM container image via usm manifest deploy", deploy_options);
  381. var app = require_app();
  382. string? base_image = deploy_base;
  383. string? spm = deploy_spm;
  384. if (!Deploy.resolve_system(deploy_system, ref base_image, ref spm)) {
  385. return 1;
  386. }
  387. return Deploy.run(app.dir, app.name, usm, deploy_exec, base_image, spm,
  388. repositories, deploy_installer_url, deploy_no_build, deploy_verbose);
  389. }
  390. /**
  391. * Collects repeatable `--repository FILE` / `--repository=FILE`
  392. * arguments into {@link repositories}, leaving the rest in
  393. * {@link remaining} so the GLib option context (whose string-array
  394. * capture is unreliable here) never sees them.
  395. */
  396. private static void extract_repositories(string[] args, out string[] repositories, out string[] remaining) {
  397. var found = new string[0];
  398. var rest = new string[args.length];
  399. rest[0] = args[0];
  400. int out_index = 1;
  401. for (int i = 1; i < args.length; i++) {
  402. if (args[i] == "--repository" && i + 1 < args.length) {
  403. found += args[++i];
  404. }
  405. else if (args[i].has_prefix("--repository=")) {
  406. found += args[i].substring("--repository=".length);
  407. }
  408. else {
  409. rest[out_index++] = args[i];
  410. }
  411. }
  412. rest.length = out_index;
  413. repositories = found;
  414. remaining = rest;
  415. }
  416. // ----------------------------------------------------------------------
  417. // Shared helpers
  418. // ----------------------------------------------------------------------
  419. /**
  420. * The surrounding spry application: the current directory, the app
  421. * name (directory basename) and the namespace its generated pages
  422. * use (read back from meson.build, falling back to the directory
  423. * name).
  424. */
  425. private class AppContext {
  426. public string dir;
  427. public string name;
  428. public string ns;
  429. public HashTable<string, string> vars;
  430. public AppContext(string dir, string name, string ns) {
  431. this.dir = dir;
  432. this.name = name;
  433. this.ns = ns;
  434. this.vars = new HashTable<string, string>(str_hash, str_equal);
  435. this.vars.insert("APP_NAME", name);
  436. this.vars.insert("NS", ns);
  437. }
  438. }
  439. private static AppContext require_app() throws Error {
  440. var cwd = Environment.get_current_dir();
  441. if (!FileUtils.test(Path.build_filename(cwd, "meson.build"), FileTest.EXISTS)
  442. || !FileUtils.test(Path.build_filename(cwd, "src", "main.vala"), FileTest.EXISTS)) {
  443. throw Generator.cli_error("not a spry application directory (run from the project root)");
  444. }
  445. var name = Path.get_basename(cwd);
  446. var ns = detect_ns(Generator.read_file(Path.build_filename(cwd, "meson.build")));
  447. if (ns.length == 0) {
  448. ns = Generator.pascal_case(name);
  449. }
  450. return new AppContext(cwd, name, ns);
  451. }
  452. private static string detect_ns(string meson) {
  453. var marker = "'--ns', '";
  454. var index = meson.index_of(marker);
  455. if (index < 0) {
  456. return "";
  457. }
  458. var start = index + marker.length;
  459. var stop = meson.index_of("'", start);
  460. return stop > start ? meson.substring(start, stop - start) : "";
  461. }
  462. private static HashTable<string, string> base_vars(string name) {
  463. var vars = new HashTable<string, string>(str_hash, str_equal);
  464. vars.insert("APP_NAME", name);
  465. vars.insert("NS", Generator.pascal_case(name));
  466. return vars;
  467. }
  468. private static HashTable<string, string> page_vars(AppContext app, string name, string route_path) {
  469. app.vars.set("CLASS", Generator.pascal_case(name));
  470. app.vars.set("ROUTE", route_path);
  471. return app.vars;
  472. }
  473. private static void add_page(AppContext app, string name, string cls, string route_path,
  474. string html, string entrypoint) throws Error {
  475. write_new_app_file(app.dir, "src/pages/%s.html".printf(name), html);
  476. write_new_app_file(app.dir, "src/entrypoints/%sEntrypoint.vala".printf(cls), entrypoint);
  477. var var_name = Generator.snake_case(name) + "_page";
  478. var target = "%s = custom_target('%s-page',\n".printf(var_name, Generator.snake_case(name))
  479. + " input: 'src/pages/%s.html',\n".printf(name)
  480. + " output: '%sPage.vala',\n".printf(cls)
  481. + " command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', '%sPage', '--ns', '%s', '@INPUT@'],\n".printf(cls, app.ns)
  482. + " depend_files: files('src/pages/main.html')\n"
  483. + ")";
  484. edit_meson(app, target, MESON_PAGES_BEGIN, MESON_PAGES_END);
  485. edit_meson(app, "'src/entrypoints/%sEntrypoint.vala',".printf(cls), MESON_SOURCES_BEGIN, MESON_SOURCES_END);
  486. edit_meson(app, "%s,".printf(var_name), MESON_GENERATED_BEGIN, MESON_GENERATED_END);
  487. edit_main(app, "statum.add_page<%sPage, %sEntrypoint>();".printf(cls, cls), VALA_PAGES_BEGIN, VALA_PAGES_END);
  488. }
  489. private static void edit_meson(AppContext app, string snippet, string begin, string end) throws Error {
  490. var path = Path.build_filename(app.dir, "meson.build");
  491. Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
  492. }
  493. private static void edit_main(AppContext app, string snippet, string begin, string end) throws Error {
  494. var path = Path.build_filename(app.dir, "src", "main.vala");
  495. Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
  496. }
  497. private static void edit_layout(AppContext app, string snippet, string begin, string end) throws Error {
  498. var path = Path.build_filename(app.dir, "src", "pages", "main.html");
  499. Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
  500. }
  501. private static void write_app_file(string app_dir, string relative, string contents, uint32 mode = 0644) throws Error {
  502. Generator.write_file(Path.build_filename(app_dir, relative), contents, mode);
  503. }
  504. /**
  505. * Writes a scaffolded file only when it does not exist yet, so
  506. * re-running `add` commands never clobbers user edits.
  507. */
  508. private static void write_new_app_file(string app_dir, string relative, string contents) throws Error {
  509. var path = Path.build_filename(app_dir, relative);
  510. if (FileUtils.test(path, FileTest.EXISTS)) {
  511. stdout.printf("%s already exists — left untouched\n", path);
  512. return;
  513. }
  514. Generator.write_file(path, contents);
  515. }
  516. private static string normalise(string name) {
  517. return name.down();
  518. }
  519. private static string? content_type_for(string filename) {
  520. var extension = filename.contains(".") ? filename.substring(filename.last_index_of(".") + 1).down() : "";
  521. switch (extension) {
  522. case "css":
  523. return "text/css";
  524. case "js":
  525. case "mjs":
  526. return "application/javascript";
  527. case "png":
  528. return "image/png";
  529. case "svg":
  530. return "image/svg+xml";
  531. case "jpg":
  532. case "jpeg":
  533. return "image/jpeg";
  534. case "ico":
  535. return "image/x-icon";
  536. case "woff2":
  537. return "font/woff2";
  538. case "woff":
  539. return "font/woff";
  540. default:
  541. return null;
  542. }
  543. }
  544. private static void parse(string[] args, string summary, OptionEntry[]? extra) throws OptionError {
  545. var context = new OptionContext(summary);
  546. context.add_main_entries(global_options, null);
  547. if (extra != null) {
  548. context.add_main_entries((!)extra, null);
  549. }
  550. context.parse(ref args);
  551. }
  552. /** Drops `skip` positional words after `args[0]`, keeping `args[0]`. */
  553. private static string[] slice(string[] args, int skip) {
  554. var rest = new string[int.max(1, args.length - skip)];
  555. rest[0] = args[0];
  556. for (int i = skip + 1; i < args.length; i++) {
  557. rest[i - skip] = args[i];
  558. }
  559. return rest;
  560. }
  561. }
  562. }