فهرست منبع

feat(cli): add spry dev build/run/watch loop; fix child env list separators and add XDG_DATA_DIRS to spawned processes

clanker 1 هفته پیش
والد
کامیت
44d2d5a366
6فایلهای تغییر یافته به همراه380 افزوده شده و 8 حذف شده
  1. 11 0
      README.md
  2. 315 0
      tools/spry/Dev.vala
  3. 7 4
      tools/spry/Generator.vala
  4. 3 2
      tools/spry/meson.build
  5. 24 1
      tools/spry/spry.vala
  6. 20 1
      tools/spry/templates/README.md

+ 11 - 0
README.md

@@ -177,6 +177,17 @@ and `throws Error`:
 Guards (`SpryAuth.require_login`/`require_permission`) build on
 `get_user` for their per-request re-validation.
 
+## The `spry` CLI
+
+`spry new <name>` scaffolds an application; `spry add page|action|resource`
+grows it inside marker blocks; `spry add login|register|user-management`
+adds auth UI bound to `Spry.Actions`; `spry keys` maintains the static key
+pairs; `spry docker` generates (and optionally builds) the image. `spry dev`
+is the development loop: build, run, watch — saving a file rebuilds and
+restarts the app (a failed build keeps the previous process running), and
+Statum's client-carried state makes restarts transparent to the browser.
+Run `spry --help` for the full command surface.
+
 ## Migrating from old Spry
 
 | Old (≤ 0.1) | New (0.2) |

+ 315 - 0
tools/spry/Dev.vala

@@ -0,0 +1,315 @@
+using GLib;
+
+namespace Spry.Cli {
+
+    /**
+     * `spry dev` — build, run and watch the application.
+     *
+     * Sets up the builddir when absent, builds with ninja, spawns the app
+     * binary with the stack's library environment, then watches `src/`,
+     * `meson.build` and `web-config.json`. On change it rebuilds
+     * incrementally — meson's dependency graph re-runs the statum-mkpstm /
+     * statum-mkres code generation automatically — and restarts the binary
+     * when the build succeeds. On failure the compiler errors are shown and
+     * the previous process keeps running, so the loop is never left dead.
+     *
+     * Restarts are cheap by design: Statum state is client-carried, the
+     * browser transparently recovers via the transmit(retry) protocol, and
+     * session identity survives via the static keys in web-config.json —
+     * just refresh the page.
+     */
+    public class Dev : Object {
+
+        /** Delay bundling rapid consecutive saves into one rebuild. */
+        private const uint DEBOUNCE_MS = 200;
+
+        /** Grace period after SIGTERM before escalating to SIGKILL. */
+        private const uint KILL_GRACE_SECONDS = 3;
+
+        private string app_dir;
+        private string app_name;
+        private string build_dir;
+        private string binary_path;
+        private string? libdir;
+        private int port;
+
+        private MainLoop loop;
+        private SubprocessLauncher launcher;
+        private Subprocess? app_process = null;
+        private FileMonitor[] monitors = {};
+        private uint debounce_source = 0;
+        private bool expect_exit = false;
+        private bool shutting_down = false;
+
+        /**
+         * Runs the dev loop in `app_dir`. Does not return until the user
+         * interrupts it (Ctrl-C) or the initial build fails.
+         */
+        public static int run(string app_dir, string app_name, int port, bool fresh,
+                bool no_run, string? libdir) throws Error {
+            var dev = new Dev();
+            dev.app_dir = app_dir;
+            dev.app_name = app_name;
+            dev.build_dir = Path.build_filename(app_dir, "builddir");
+            dev.binary_path = Path.build_filename(dev.build_dir, app_name);
+            dev.libdir = libdir;
+            dev.port = port;
+
+            if (fresh) {
+                dev.reset_database();
+            }
+
+            dev.launcher = new SubprocessLauncher(SubprocessFlags.NONE);
+            Tools.apply_env(dev.launcher, libdir);
+
+            if (!FileUtils.test(Path.build_filename(dev.build_dir, "build.ninja"), FileTest.EXISTS)) {
+                dev.step("Configuring builddir");
+                if (!dev.spawn_sync({ "meson", "setup", ".", "builddir" })) {
+                    stderr.printf("[spry] meson setup failed — fix the errors above and re-run spry dev\n");
+                    return 1;
+                }
+            }
+
+            dev.step("Building");
+            if (!dev.build()) {
+                return 1;
+            }
+            if (no_run) {
+                return 0;
+            }
+
+            dev.loop = new MainLoop();
+            dev.watch_sources();
+            dev.install_signal_handlers();
+            dev.spawn_child();
+
+            dev.loop.run();
+            return 0;
+        }
+
+        // ------------------------------------------------------------------
+        // Build / run / restart
+        // ------------------------------------------------------------------
+
+        /** Deletes the application's SQLite store (honouring SPRY_DB_PATH). */
+        private void reset_database() {
+            var configured = Environment.get_variable("SPRY_DB_PATH");
+            var path = configured != null && configured.length > 0
+                ? configured
+                : app_name + ".sqlite";
+            if (FileUtils.test(path, FileTest.EXISTS)) {
+                FileUtils.remove(path);
+                stdout.printf("[spry] Deleted %s\n", path);
+            }
+        }
+
+        /** Incrementally builds; returns true on success. */
+        private bool build() throws Error {
+            return spawn_sync({ "ninja", "-C", "builddir" });
+        }
+
+        /**
+         * Runs a short-lived child that inherits this process's stdio,
+         * with the stack's library environment applied.
+         */
+        private bool spawn_sync(string[] argv) throws Error {
+            var process = launcher.spawnv(argv);
+            try {
+                process.wait_check();
+                return true;
+            } catch (Error e) {
+                return false;
+            }
+        }
+
+        private void spawn_child() {
+            try {
+                expect_exit = false;
+                app_process = launcher.spawnv({ binary_path, port.to_string() });
+                stdout.printf("[spry] Running %s on port %d (Ctrl-C to stop)\n", app_name, port);
+                watch_child.begin();
+            } catch (Error e) {
+                stderr.printf("[spry] Could not run %s: %s\n", binary_path, e.message);
+            }
+        }
+
+        /**
+         * The single lifecycle waiter for the running child: on an expected
+         * exit (restart) it spawns the replacement; on an unexpected exit it
+         * reports and leaves the loop watching for the next successful
+         * rebuild.
+         */
+        private async void watch_child() {
+            var process = app_process;
+            if (process == null) {
+                return;
+            }
+            try {
+                yield ((!)process).wait_async(null);
+            } catch (Error e) {
+            }
+            if (shutting_down) {
+                return;
+            }
+            if (expect_exit) {
+                spawn_child();
+            } else {
+                app_process = null;
+                stdout.printf("[spry] %s exited — save a file to rebuild, or Ctrl-C to quit\n", app_name);
+            }
+        }
+
+        /** Rebuilds and, on success, replaces the running child. */
+        private void rebuild() {
+            if (shutting_down) {
+                return;
+            }
+            step("Rebuilding");
+            try {
+                if (!build()) {
+                    stdout.printf("[spry] Build failed — %s is still running\n", app_name);
+                    return;
+                }
+            } catch (Error e) {
+                stderr.printf("[spry] Build could not run: %s\n", e.message);
+                return;
+            }
+            restart_child();
+        }
+
+        /**
+         * Stops the running child (SIGTERM, escalating to SIGKILL after the
+         * grace period); the lifecycle waiter spawns the replacement once it
+         * observes the exit. When no child is running the replacement starts
+         * immediately.
+         */
+        private void restart_child() {
+            var process = app_process;
+            if (process == null) {
+                spawn_child();
+                return;
+            }
+            expect_exit = true;
+            ((!)process).send_signal(ProcessSignal.TERM);
+            Timeout.add_seconds(KILL_GRACE_SECONDS, () => {
+                if (!shutting_down && app_process == process && !((!)process).get_if_exited()) {
+                    stdout.printf("[spry] %s did not exit — killing it\n", app_name);
+                    ((!)process).send_signal(ProcessSignal.KILL);
+                }
+                return false;
+            });
+        }
+
+        // ------------------------------------------------------------------
+        // Watching
+        // ------------------------------------------------------------------
+
+        /** Watches the application's sources for changes. */
+        private void watch_sources() throws Error {
+            watch_tree(File.new_for_path(Path.build_filename(app_dir, "src")));
+            watch_file(Path.build_filename(app_dir, "meson.build"));
+            watch_file(Path.build_filename(app_dir, "web-config.json"));
+        }
+
+        private void watch_tree(File directory) {
+            watch_directory(directory);
+            try {
+                var enumerator = directory.enumerate_children("standard::name,standard::type",
+                    FileQueryInfoFlags.NONE);
+                FileInfo info;
+                while ((info = enumerator.next_file()) != null) {
+                    if (info.get_file_type() == FileType.DIRECTORY) {
+                        watch_tree(File.new_for_path(
+                            Path.build_filename((!) directory.get_path(), info.get_name())));
+                    }
+                }
+            } catch (Error e) {
+                stderr.printf("[spry] Could not enumerate %s: %s\n", directory.get_path(), e.message);
+            }
+        }
+
+        /**
+         * Attaches a directory monitor; newly created subdirectories are
+         * picked up as well, so `spry add` output is watched without a
+         * restart.
+         */
+        private void watch_directory(File directory) {
+            try {
+                var monitor = directory.monitor_directory(FileMonitorFlags.NONE);
+                monitor.changed.connect((file, other, event) => {
+                    if (event == FileMonitorEvent.CREATED && file.query_file_type(FileQueryInfoFlags.NONE) == FileType.DIRECTORY) {
+                        watch_tree(file);
+                    }
+                    if (event != FileMonitorEvent.CHANGES_DONE_HINT) {
+                        schedule_rebuild();
+                    }
+                });
+                monitors += monitor;
+            } catch (Error e) {
+                stderr.printf("[spry] Could not watch %s: %s\n", directory.get_path(), e.message);
+            }
+        }
+
+        private void watch_file(string path) {
+            try {
+                var monitor = File.new_for_path(path).monitor_file(FileMonitorFlags.NONE);
+                monitor.changed.connect(() => schedule_rebuild());
+                monitors += monitor;
+            } catch (Error e) {
+                stderr.printf("[spry] Could not watch %s: %s\n", path, e.message);
+            }
+        }
+
+        /** Bundles rapid saves into a single rebuild. */
+        private void schedule_rebuild() {
+            if (debounce_source != 0) {
+                Source.remove(debounce_source);
+            }
+            debounce_source = Timeout.add(DEBOUNCE_MS, () => {
+                debounce_source = 0;
+                rebuild();
+                return false;
+            });
+        }
+
+        // ------------------------------------------------------------------
+        // Lifecycle
+        // ------------------------------------------------------------------
+
+        private void install_signal_handlers() {
+            Unix.signal_add(ProcessSignal.INT, () => {
+                shutdown();
+                return false;
+            });
+            Unix.signal_add(ProcessSignal.TERM, () => {
+                shutdown();
+                return false;
+            });
+        }
+
+        private void shutdown() {
+            if (shutting_down) {
+                return;
+            }
+            shutting_down = true;
+            stdout.printf("\n[spry] Stopping %s\n", app_name);
+            if (app_process != null) {
+                ((!)app_process).send_signal(ProcessSignal.TERM);
+                Timeout.add_seconds(KILL_GRACE_SECONDS, () => {
+                    if (app_process != null && !((!)app_process).get_if_exited()) {
+                        ((!)app_process).send_signal(ProcessSignal.KILL);
+                    }
+                    return false;
+                });
+            }
+            Timeout.add(KILL_GRACE_SECONDS * 1000, () => {
+                loop.quit();
+                return false;
+            });
+        }
+
+        private void step(string label) {
+            stdout.printf("[spry] %s…\n", label);
+        }
+    }
+}

+ 7 - 4
tools/spry/Generator.vala

@@ -310,20 +310,23 @@ namespace Spry.Cli {
         }
 
         /**
-         * Points `launcher`'s LD_LIBRARY_PATH/PKG_CONFIG_PATH at the Spry
-         * stack's library directory so spawned statum tools load the freshly
-         * built libraries rather than stale system ones.
+         * Points `launcher`'s LD_LIBRARY_PATH/PKG_CONFIG_PATH/XDG_DATA_DIRS
+         * at the Spry stack's library and share directories so spawned
+         * processes load the freshly built libraries, resolve their
+         * pkg-config packages and find the installed vapis — regardless of
+         * the invoking shell's environment.
          */
         public static void apply_env(SubprocessLauncher launcher, string? libdir) {
             var dir = default_libdir(libdir);
             prepend(launcher, "LD_LIBRARY_PATH", dir);
             prepend(launcher, "PKG_CONFIG_PATH", Path.build_filename(dir, "pkgconfig"));
+            prepend(launcher, "XDG_DATA_DIRS", Path.build_filename(Path.get_dirname(dir), "share"));
         }
 
         private static void prepend(SubprocessLauncher launcher, string name, string dir) {
             var existing = Environment.get_variable(name);
             var value = existing != null && existing.length > 0
-                ? dir + Path.DIR_SEPARATOR_S + existing
+                ? dir + Path.SEARCHPATH_SEPARATOR_S + existing
                 : dir;
             launcher.setenv(name, value, true);
         }

+ 3 - 2
tools/spry/meson.build

@@ -1,5 +1,6 @@
 # `spry` — the Spry application CLI: scaffolding (`spry new`), add commands,
-# static key maintenance (`spry keys`) and Docker image generation.
+# static key maintenance (`spry keys`), Docker image generation, and the
+# build/run/watch dev loop (`spry dev`).
 
 python3 = find_program('python3')
 
@@ -31,7 +32,7 @@ spry_templates = custom_target('spry-templates',
 )
 
 executable('spry',
-    ['spry.vala', 'Generator.vala', 'Keys.vala', 'Docker.vala', spry_templates],
+    ['spry.vala', 'Generator.vala', 'Keys.vala', 'Docker.vala', 'Dev.vala', spry_templates],
     dependencies: [glib_dep, gobject_dep, gio_dep, json_glib_dep],
     install: true
 )

+ 24 - 1
tools/spry/spry.vala

@@ -23,6 +23,9 @@ namespace Spry.Cli {
         private static string? tag = null;
         private static bool no_build = false;
         private static string? stack_dir = null;
+        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" },
@@ -52,6 +55,13 @@ namespace Spry.Cli {
             { 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";
@@ -94,6 +104,8 @@ namespace Spry.Cli {
                         return cmd_keys(slice(args, 1));
                     case "docker":
                         return cmd_docker(slice(args, 1));
+                    case "dev":
+                        return cmd_dev(slice(args, 1));
                     default:
                         stderr.printf("Error: Unknown command '%s'.\n", command);
                         print_usage();
@@ -123,6 +135,7 @@ namespace Spry.Cli {
                 "  add user-management              Add the admin user-management page\n" +
                 "  keys [--out FILE]                Generate/merge static keys into web-config.json\n" +
                 "  docker [--tag T] [--no-build]    Generate (and optionally build) a Dockerfile\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" +
@@ -183,7 +196,7 @@ namespace Spry.Cli {
             }
 
             stdout.printf("Created %s\n", app_dir);
-            stdout.printf("Next:\n  cd %s\n  meson setup builddir && ninja -C builddir\n  ./builddir/%s\n", name, name);
+            stdout.printf("Next:\n  cd %s\n  spry dev\n", name);
             return 0;
         }
 
@@ -354,6 +367,16 @@ namespace Spry.Cli {
             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 docker
         // ----------------------------------------------------------------------

+ 20 - 1
tools/spry/templates/README.md

@@ -2,7 +2,25 @@
 
 A Statum + Spry application, scaffolded by `spry new`.
 
-## Build & run
+## Development
+
+```bash
+spry dev
+```
+
+Builds, runs the app on port 8080 (`--port N` to change) and watches
+`src/`, `meson.build` and `web-config.json`: saving a file rebuilds
+incrementally — page HTML and resources recompile via the meson codegen
+targets — and restarts the app when the build succeeds. A failed build
+keeps the previous process running while the compiler errors are shown.
+Restarts are cheap: Statum state is client-carried (the browser recovers
+transparently) and the static keys in `web-config.json` keep sessions
+alive — just refresh. `--fresh` starts from an empty database;
+`--no-run` builds once and exits.
+
+The environment below is required for the underlying meson builds (the
+`spry dev` child processes set it for you; export it in shells where you
+build or run manually):
 
 ```bash
 export WS_PREFIX="$HOME/.local"
@@ -50,6 +68,7 @@ those blocks and are idempotent.
 ## Growing the app
 
 ```
+spry dev
 spry add page <name> [--route /path]
 spry add action <name>
 spry add resource <file>