spry.vala 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  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. ensure_spry_css(app);
  320. add_page(app, "login", "Login", "/login",
  321. Generator.fill(Templates.LOGIN, app.vars),
  322. Generator.fill(Templates.LOGIN_ENTRYPOINT, app.vars));
  323. // Keep Spry.Actions registrations in sync with
  324. // SpryAuth.register_actions (Spry/src/Auth.vala), the canonical
  325. // list of shipped actions.
  326. edit_main(app, "statum.action<Spry.Actions.LoginAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  327. edit_main(app, "statum.action<Spry.Actions.LogoutAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  328. edit_layout(app, "<a href=\"/login\" stm-if=\"!auth\">Login</a>", NAV_BEGIN, NAV_END);
  329. edit_layout(app, "<button class=\"btn btn-sm btn-outline\" type=\"button\" stm-action=\"auth.logout\">Log out</button>", NAV_AUTH_BEGIN, NAV_AUTH_END);
  330. Generator.append_section(Path.build_filename(app.dir, "README.md"),
  331. "## Guarding pages", Generator.fill(Templates.LOGIN_README, app.vars));
  332. stdout.printf("Added login page at /login\n");
  333. return 0;
  334. }
  335. private static int cmd_add_register(string[] args) throws Error, OptionError {
  336. parse(args, "- add the registration page", null);
  337. var app = require_app();
  338. ensure_spry_css(app);
  339. add_page(app, "register", "Register", "/register",
  340. Generator.fill(Templates.REGISTER, app.vars),
  341. Generator.fill(Templates.REGISTER_ENTRYPOINT, app.vars));
  342. edit_main(app, "statum.action<Spry.Actions.RegisterAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  343. edit_layout(app, "<a href=\"/register\" stm-if=\"!auth\">Register</a>", NAV_BEGIN, NAV_END);
  344. stdout.printf("Added register page at /register\n");
  345. return 0;
  346. }
  347. private static int cmd_add_user_management(string[] args) throws Error, OptionError {
  348. parse(args, "- add the admin user-management page", null);
  349. var app = require_app();
  350. ensure_spry_css(app);
  351. add_page(app, "user-management", "UserManagement", "/users",
  352. Generator.fill(Templates.USER_MANAGEMENT, app.vars),
  353. Generator.fill(Templates.USER_MANAGEMENT_ENTRYPOINT, app.vars));
  354. edit_main(app, "statum.action<Spry.Actions.SetUserEnabledAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  355. edit_main(app, "statum.action<Spry.Actions.GrantPermissionAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  356. edit_main(app, "statum.action<Spry.Actions.RevokePermissionAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  357. edit_main(app, "statum.action<Spry.Actions.AlterUserAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  358. edit_main(app, "statum.action<Spry.Actions.DeleteUserAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  359. edit_layout(app, "<a href=\"/users\" stm-if=\"auth && (auth.permissions.indexOf('admin') !== -1 || auth.permissions.indexOf('*') !== -1)\">Users</a>", NAV_BEGIN, NAV_END);
  360. stdout.printf("Added user-management page at /users\n");
  361. return 0;
  362. }
  363. // ----------------------------------------------------------------------
  364. // spry dev
  365. // ----------------------------------------------------------------------
  366. private static int cmd_dev(string[] args) throws Error, OptionError {
  367. parse(args, "- build, run and watch the application", dev_options);
  368. var app = require_app();
  369. return Dev.run(app.dir, app.name, dev_port, dev_fresh, dev_no_run, libdir);
  370. }
  371. // ----------------------------------------------------------------------
  372. // spry keys / spry deploy
  373. // ----------------------------------------------------------------------
  374. private static int cmd_keys(string[] args) throws Error, OptionError {
  375. parse(args, "- generate and merge static keys into web-config.json", keys_options);
  376. Keys.run(out_path ?? "web-config.json", tools_dir, libdir);
  377. return 0;
  378. }
  379. private static int cmd_deploy(string[] args) throws Error, OptionError {
  380. string[] repositories;
  381. string[] remaining;
  382. extract_repositories(args, out repositories, out remaining);
  383. parse(remaining, "- build the USM container image via usm manifest deploy", deploy_options);
  384. var app = require_app();
  385. string? base_image = deploy_base;
  386. string? spm = deploy_spm;
  387. if (!Deploy.resolve_system(deploy_system, ref base_image, ref spm)) {
  388. return 1;
  389. }
  390. return Deploy.run(app.dir, app.name, usm, deploy_exec, base_image, spm,
  391. repositories, deploy_installer_url, deploy_no_build, deploy_verbose);
  392. }
  393. /**
  394. * Collects repeatable `--repository FILE` / `--repository=FILE`
  395. * arguments into {@link repositories}, leaving the rest in
  396. * {@link remaining} so the GLib option context (whose string-array
  397. * capture is unreliable here) never sees them.
  398. */
  399. private static void extract_repositories(string[] args, out string[] repositories, out string[] remaining) {
  400. var found = new string[0];
  401. var rest = new string[args.length];
  402. rest[0] = args[0];
  403. int out_index = 1;
  404. for (int i = 1; i < args.length; i++) {
  405. if (args[i] == "--repository" && i + 1 < args.length) {
  406. found += args[++i];
  407. }
  408. else if (args[i].has_prefix("--repository=")) {
  409. found += args[i].substring("--repository=".length);
  410. }
  411. else {
  412. rest[out_index++] = args[i];
  413. }
  414. }
  415. rest.length = out_index;
  416. repositories = found;
  417. remaining = rest;
  418. }
  419. // ----------------------------------------------------------------------
  420. // Shared helpers
  421. // ----------------------------------------------------------------------
  422. /**
  423. * The surrounding spry application: the current directory, the app
  424. * name (directory basename) and the namespace its generated pages
  425. * use (read back from meson.build, falling back to the directory
  426. * name).
  427. */
  428. private class AppContext {
  429. public string dir;
  430. public string name;
  431. public string ns;
  432. public HashTable<string, string> vars;
  433. public AppContext(string dir, string name, string ns) {
  434. this.dir = dir;
  435. this.name = name;
  436. this.ns = ns;
  437. this.vars = new HashTable<string, string>(str_hash, str_equal);
  438. this.vars.insert("APP_NAME", name);
  439. this.vars.insert("NS", ns);
  440. }
  441. }
  442. private static AppContext require_app() throws Error {
  443. var cwd = Environment.get_current_dir();
  444. if (!FileUtils.test(Path.build_filename(cwd, "meson.build"), FileTest.EXISTS)
  445. || !FileUtils.test(Path.build_filename(cwd, "src", "main.vala"), FileTest.EXISTS)) {
  446. throw Generator.cli_error("not a spry application directory (run from the project root)");
  447. }
  448. var name = Path.get_basename(cwd);
  449. var ns = detect_ns(Generator.read_file(Path.build_filename(cwd, "meson.build")));
  450. if (ns.length == 0) {
  451. ns = Generator.pascal_case(name);
  452. }
  453. return new AppContext(cwd, name, ns);
  454. }
  455. private static string detect_ns(string meson) {
  456. var marker = "'--ns', '";
  457. var index = meson.index_of(marker);
  458. if (index < 0) {
  459. return "";
  460. }
  461. var start = index + marker.length;
  462. var stop = meson.index_of("'", start);
  463. return stop > start ? meson.substring(start, stop - start) : "";
  464. }
  465. private static HashTable<string, string> base_vars(string name) {
  466. var vars = new HashTable<string, string>(str_hash, str_equal);
  467. vars.insert("APP_NAME", name);
  468. vars.insert("NS", Generator.pascal_case(name));
  469. return vars;
  470. }
  471. private static HashTable<string, string> page_vars(AppContext app, string name, string route_path) {
  472. app.vars.set("CLASS", Generator.pascal_case(name));
  473. app.vars.set("ROUTE", route_path);
  474. return app.vars;
  475. }
  476. private static void add_page(AppContext app, string name, string cls, string route_path,
  477. string html, string entrypoint) throws Error {
  478. write_new_app_file(app.dir, "src/pages/%s.html".printf(name), html);
  479. write_new_app_file(app.dir, "src/entrypoints/%sEntrypoint.vala".printf(cls), entrypoint);
  480. var var_name = Generator.snake_case(name) + "_page";
  481. var target = "%s = custom_target('%s-page',\n".printf(var_name, Generator.snake_case(name))
  482. + " input: 'src/pages/%s.html',\n".printf(name)
  483. + " output: '%sPage.vala',\n".printf(cls)
  484. + " command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', '%sPage', '--ns', '%s', '@INPUT@'],\n".printf(cls, app.ns)
  485. + " depend_files: files('src/pages/main.html')\n"
  486. + ")";
  487. edit_meson(app, target, MESON_PAGES_BEGIN, MESON_PAGES_END);
  488. edit_meson(app, "'src/entrypoints/%sEntrypoint.vala',".printf(cls), MESON_SOURCES_BEGIN, MESON_SOURCES_END);
  489. edit_meson(app, "%s,".printf(var_name), MESON_GENERATED_BEGIN, MESON_GENERATED_END);
  490. edit_main(app, "statum.add_page<%sPage, %sEntrypoint>();".printf(cls, cls), VALA_PAGES_BEGIN, VALA_PAGES_END);
  491. }
  492. private static void edit_meson(AppContext app, string snippet, string begin, string end) throws Error {
  493. var path = Path.build_filename(app.dir, "meson.build");
  494. Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
  495. }
  496. private static void edit_main(AppContext app, string snippet, string begin, string end) throws Error {
  497. var path = Path.build_filename(app.dir, "src", "main.vala");
  498. Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
  499. }
  500. private static void edit_layout(AppContext app, string snippet, string begin, string end) throws Error {
  501. var path = Path.build_filename(app.dir, "src", "pages", "main.html");
  502. Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
  503. }
  504. /**
  505. * Splices the Spry CSS stylesheet link into a layout scaffolded
  506. * before SpryModule served `spry.css` as a built-in resource (the
  507. * auth page markup assumes the framework's classes). Layouts
  508. * already carrying the link — and heavily customised ones without
  509. * the statum.js script line to anchor on — are left untouched.
  510. */
  511. private static void ensure_spry_css(AppContext app) throws Error {
  512. var path = Path.build_filename(app.dir, "src", "pages", "main.html");
  513. if (!FileUtils.test(path, FileTest.EXISTS)) {
  514. return;
  515. }
  516. var contents = Generator.read_file(path);
  517. var link = "<link rel=\"stylesheet\" pstm-res-href=\"spry.css\">";
  518. if (contents.contains(link)) {
  519. return;
  520. }
  521. var anchor = "<script pstm-res-src=\"statum.js\"></script>";
  522. if (!contents.contains(anchor)) {
  523. return;
  524. }
  525. Generator.write_file(path, contents.replace(anchor, anchor + "\n " + link));
  526. }
  527. private static void write_app_file(string app_dir, string relative, string contents, uint32 mode = 0644) throws Error {
  528. Generator.write_file(Path.build_filename(app_dir, relative), contents, mode);
  529. }
  530. /**
  531. * Writes a scaffolded file only when it does not exist yet, so
  532. * re-running `add` commands never clobbers user edits.
  533. */
  534. private static void write_new_app_file(string app_dir, string relative, string contents) throws Error {
  535. var path = Path.build_filename(app_dir, relative);
  536. if (FileUtils.test(path, FileTest.EXISTS)) {
  537. stdout.printf("%s already exists — left untouched\n", path);
  538. return;
  539. }
  540. Generator.write_file(path, contents);
  541. }
  542. private static string normalise(string name) {
  543. return name.down();
  544. }
  545. private static string? content_type_for(string filename) {
  546. var extension = filename.contains(".") ? filename.substring(filename.last_index_of(".") + 1).down() : "";
  547. switch (extension) {
  548. case "css":
  549. return "text/css";
  550. case "js":
  551. case "mjs":
  552. return "application/javascript";
  553. case "png":
  554. return "image/png";
  555. case "svg":
  556. return "image/svg+xml";
  557. case "jpg":
  558. case "jpeg":
  559. return "image/jpeg";
  560. case "ico":
  561. return "image/x-icon";
  562. case "woff2":
  563. return "font/woff2";
  564. case "woff":
  565. return "font/woff";
  566. default:
  567. return null;
  568. }
  569. }
  570. private static void parse(string[] args, string summary, OptionEntry[]? extra) throws OptionError {
  571. var context = new OptionContext(summary);
  572. context.add_main_entries(global_options, null);
  573. if (extra != null) {
  574. context.add_main_entries((!)extra, null);
  575. }
  576. context.parse(ref args);
  577. }
  578. /** Drops `skip` positional words after `args[0]`, keeping `args[0]`. */
  579. private static string[] slice(string[] args, int skip) {
  580. var rest = new string[int.max(1, args.length - skip)];
  581. rest[0] = args[0];
  582. for (int i = skip + 1; i < args.length; i++) {
  583. rest[i - skip] = args[i];
  584. }
  585. return rest;
  586. }
  587. }
  588. }