diff --git a/.agents/skills/planning/SKILL.md b/.agents/skills/planning/SKILL.md index 96f8d5468..b5bdf5b20 100644 --- a/.agents/skills/planning/SKILL.md +++ b/.agents/skills/planning/SKILL.md @@ -45,6 +45,7 @@ For executable refinement of an outline, [advance the planning horizon](referenc ## Execute or resume Resume the recorded operation. Resuming planning or revision does not mean starting implementation. +For an authorized interactive browser prototype, read [Browser prototypes with Astro](references/browser-prototypes.md) before choosing its source layout, preview command, and handoff checks. 1. Read the overview, selected plans, dependency records, and applicable repository instructions. Inspect the branch, working tree, commits, and actual source. For a linked PR, [read current plan feedback](references/github-review.md#read-the-feedback) and revise relevant changes within existing authorization. diff --git a/.agents/skills/planning/references/browser-prototypes.md b/.agents/skills/planning/references/browser-prototypes.md new file mode 100644 index 000000000..e5bc188d2 --- /dev/null +++ b/.agents/skills/planning/references/browser-prototypes.md @@ -0,0 +1,37 @@ +# Browser prototypes with Astro + +Use the repository's pinned Astro dependency for interactive browser experiments unless the user selects another format or the experiment requires one. +This procedure supports an authorized prototype; it does not authorize implementation during planning-only work or choose the eventual native app architecture. + +## Set up the experiment + +- Keep the project beside its numbered plan, usually `Plans/YYYY-MM-DD-topic/Prototype/`. Give its README the exact working directory, start/check/build commands, sample-data limits, and reset behavior. +- Reuse the root `package.json`, `package-lock.json`, and installed `node_modules`. If dependencies are missing, run `mise exec -- npm ci` from the repository root. Do not scaffold a second dependency tree or fetch a newer Astro release. +- Put the active page in `src/pages/index.astro` and its client code/styles in Astro's watched import graph. Do not serve the whole editable app as an unprocessed file under `public/`; that bypasses the intended development loop. +- Keep Astro/Vite caches and build output ignored and local to the prototype. Restrict dev-server file access to the prototype's sources and required dependencies, rather than exposing the repository root. Historical artifacts stay clearly labeled; identify one current source. + +## Run and share the preview + +Use the repository command from the repository root (see [`./prototype --help`](../../../../prototype) and [tooling details](../../../../Tools/README.md#browser-prototypes)): + +```sh +./prototype start Plans/YYYY-MM-DD-topic/Prototype --lan +./prototype status Plans/YYYY-MM-DD-topic/Prototype +./prototype logs Plans/YYYY-MM-DD-topic/Prototype +./prototype stop Plans/YYYY-MM-DD-topic/Prototype +``` + +The command owns pinned tooling, absolute project paths, background lifecycle, ownership checks, port conflicts, HTTP readiness, and current reachable IPv4 URLs. Run `status` again at handoff; network addresses can change while the server stays running. Give the user the verified local and network URLs. A phone uses the latter on the same network. Keep this a development preview; do not add public hosting or a tunnel unless requested. + +Leave the review server running for an interactive handoff and provide its stop command; stop temporary verification servers. Record its worktree/project and owner in the execution checkpoint, including the coordinator's reporting duty under [Delegated execution handoff](../SKILL.md#delegated-execution-handoff). + +## Verify the development loop + +```sh +./prototype check Plans/YYYY-MM-DD-topic/Prototype +./prototype build Plans/YYYY-MM-DD-topic/Prototype +``` + +Exercise the served prototype in a browser at the intended viewport sizes. Make and revert a small visible source edit and observe automatic updates without manually reloading. Cover the edited source kinds, such as route markup, client code, and CSS; a successful HTTP response or build alone does not establish live reload. Record whether an update preserves the sample session or performs a full reload that resets it. + +Check both loopback and the actual LAN address, including live reload when reachable. A request from the host to its own LAN address is not physical-phone verification; distinguish that evidence from an actual device test. Confirm the served final content and source revision after reverting the probe, then record checks, URLs, remaining limits, and publication state in the plan before the completion notice. diff --git a/.agents/skills/planning/references/develop-plan.md b/.agents/skills/planning/references/develop-plan.md index d4bc98cc9..764a71ea0 100644 --- a/.agents/skills/planning/references/develop-plan.md +++ b/.agents/skills/planning/references/develop-plan.md @@ -32,6 +32,7 @@ Record sources, covered revisions/versions, implications, and unresolved claims Compare meaningful alternatives against requirements, integration cost, and maintenance burden. Route unresolved product trade-offs through the question loop before selecting an approach. Record the decision source and why alternatives were rejected, without inventing alternatives for routine choices. If feasibility needs a prototype, plan an [experiment deliverable](../../../../Plans/AGENTS.md#experiment-deliverables); execute it only within existing authorization. +For an interactive browser experiment, use [Browser prototypes with Astro](browser-prototypes.md) to specify the development loop and device-preview evidence. Stop when material decisions have enough evidence and remaining uncertainty is explicit. Record when repository evidence suffices without external research. ## Resolve questions diff --git a/AGENTS.md b/AGENTS.md index b4998d05a..4be46ce5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ Read the matching reference before implementation or review. Read only relevant - Regenerate with `./ide --no-open`. Never open Xcode or bypass the setup with `tuist generate`. - Use `./test` for iOS bundles. Never hand-roll `tuist test` or `xcodebuild` for them. - Use root commands for their owned state. Read `--help` instead of reimplementing a command. +- Use `./prototype` for Astro prototype servers and checks; see [tooling details](Tools/README.md#browser-prototypes). - Use `./icons` for icon catalogs and manifests. Never hand-edit them or add icon Swift. - Run `./attribution` after adding or updating a package, external skill, or development tool. Commit the report. - Run `./test --architecture-only` after a Where dependency, composition-root, or documented concurrency-boundary change. diff --git a/README.md b/README.md index d5b82a4bd..86ba88e3a 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ sync-agents Link Claude skills to .agents/skills/; remove legacy instruc simulator Resolve/create this checkout's simulator, boot it, print its UDID worktree Check or update against origin/main; remove owned worktree caches profile Report build/test hot spots (see `./profile --help`) +prototype Run, inspect, check, and build plan-local Astro prototypes flyover Export, preview, and develop Where's native Flyover QA atlas with Astro flaky Detect flaky tests, update FLAKY_TESTS.md (see `./flaky --help`) circleci-artifacts Download every artifact for a CircleCI job diff --git a/Tools/AGENTS.md b/Tools/AGENTS.md index 9735951d6..2a1008a56 100644 --- a/Tools/AGENTS.md +++ b/Tools/AGENTS.md @@ -75,6 +75,14 @@ These rules also apply when changing a root command or installer that calls this Count UTF-8 bytes with directory override precedence. Check Markdown links from both canonical and Claude skill paths. Guards: [agent_guidance_test.rb](Tests/agent_guidance_test.rb), [agent_markdown_test.rb](Tests/agent_markdown_test.rb), and [sync_agents_command_test.rb](Tests/sync_agents_command_test.rb). +## Prototype servers + +Use Astro's own background lifecycle and the shared `astro_site.py` launcher. +Keep status read-only; distinguish missing or dead processes from inspection errors. +Before reuse or stop, require the Astro command, absolute project argument, and working directory to agree. +Discover and HTTP-check current network addresses at handoff; cached startup URLs are not current evidence. +Guards: [test_prototype.py](Tests/test_prototype.py) and [test_prototype_command.py](Tests/test_prototype_command.py). + ## Installer publication Both websites share the root npm workspace and `astro_site.py` launcher. diff --git a/Tools/README.md b/Tools/README.md index 829316789..7bc29b88a 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -11,6 +11,53 @@ The shared lint command checks formatting without changing files. Keep formattin CSS uses the standard Stylelint preset. The configuration preserves three Safari-prefixed properties without disabling the prefix rule. The standard duplicate-property rule permits different CSS syntaxes, but rejects repeated declarations with different values in the same syntax. +## Browser prototypes + +`./prototype` manages an existing Astro project, usually beside its plan in `Plans/`. +It reuses the root lockfile, installed dependencies, pinned Node, and `astro_site.py` launcher. +Install missing dependencies once with `mise exec -- npm ci`; the command does not install or scaffold them. +The project needs `astro.config.mjs` and a working route at `/`. +For another route or Astro `base`, pass `--path /demo/` to both `start` and `status`. +This selects the URL to verify/share; it does not change Astro configuration or persist a preference. +Astro's server metadata omits its configured base, so the command does not infer it. + +```sh +./prototype start Plans/YYYY-MM-DD-topic/Prototype --lan +./prototype status Plans/YYYY-MM-DD-topic/Prototype +./prototype logs Plans/YYYY-MM-DD-topic/Prototype +./prototype check Plans/YYYY-MM-DD-topic/Prototype +./prototype build Plans/YYYY-MM-DD-topic/Prototype +./prototype stop Plans/YYYY-MM-DD-topic/Prototype +``` + +Paths resolve from the caller's directory; absolute paths work across checkouts. +The command always selects tools from its own checkout. `start` defaults to port 4321, +loopback only; `--lan` binds all IPv4 interfaces. Use `--port NUMBER` to choose another port. +An existing owned server is reused. Changing its port or enabling LAN on a loopback server +requires an explicit stop/start. Port conflicts fail without stopping another process. +Use `vite.server.strictPort: true` in the prototype config to also reject a startup port race. + +Astro owns the daemon, `.astro/dev.json`, and `.astro/dev.log`. +The wrapper serializes its start/stop operations with `.astro/prototype-command.lock`; +use the wrapper consistently and keep project paths stable during lifecycle operations. +Before reusing or stopping a live PID, it checks the Astro executable, absolute project argument, +and working directory. It supports macOS and Linux; unusual manually launched commands are refused. +Inspection errors and malformed records remain errors. `status` is read-only, including stale records; +`logs` can read failure output after the server exits. `stop` is idempotent when nothing is running. + +`start` waits for Astro's bounded startup and then checks HTTP readiness. +`status` checks HTTP again and reports currently reachable IPv4 interface URLs, ignoring cached +startup addresses. These checks bypass proxies and do not follow redirects. +Run status again before sharing a URL. Interface names distinguish local-network and VPN addresses; +a successful host request does not establish physical-phone connectivity or browser live reload. +A failed HTTP/LAN check leaves the server available for diagnosis through `logs` and `stop`. +`status` exits 1 when absent or unhealthy; check/build and native lifecycle failures preserve child status. + +Keep `.astro/`, `dist/`, and Vite state ignored and local to the project. +Keep source inside Astro's watched import graph and restrict `vite.server.fs.allow` to the +prototype and required dependencies. The [planning guidance](../.agents/skills/planning/references/browser-prototypes.md) +owns experiment design, browser verification, and handoff evidence. + ## Daily Flyover publication `.github/flyover-apps.json` owns hosted capture registration. `flyover_apps.py` validates it before capture. diff --git a/Tools/Tests/test_prototype.py b/Tools/Tests/test_prototype.py new file mode 100644 index 000000000..c6eebea47 --- /dev/null +++ b/Tools/Tests/test_prototype.py @@ -0,0 +1,350 @@ +"""Policy regressions for prototype ownership, lifecycle, and current URLs.""" + +import contextlib +import io +import json +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from Tools import prototype + + +class PrototypeTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="prototype ü ") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + (self.root / ".git").mkdir() + self.project = self.root / "A prototype" + self.project.mkdir() + (self.project / "astro.config.mjs").touch() + (self.project / ".astro").mkdir() + self.record = { + "pid": 123456, + "port": 4321, + "url": "http://localhost:4321/", + "background": True, + "startedAt": "2026-11-15T12:00:00Z", + "urls": {"network": ["http://192.0.2.1:4321/"]}, + } + self.path = self.project / ".astro/dev.json" + self.write_record() + self.server = prototype.read_server(self.project) + + def write_record(self): + self.path.write_text(json.dumps(self.record)) + + def test_explicit_path_handles_astro_metadata_that_omits_base(self): + self.record["url"] = "http://localhost:4321" + self.record["urls"]["local"] = ["http://localhost:4321/"] + self.write_record() + server = prototype.read_server(self.project) + with ( + patch.object( + prototype, "current_addresses", return_value=[("en0", "192.0.2.2")] + ), + patch.object(prototype, "responds", return_value=True) as responds, + contextlib.redirect_stdout(io.StringIO()) as output, + ): + prototype.report(self.project, server, path="/demo/") + self.assertIn("http://localhost:4321/demo/", output.getvalue()) + self.assertIn("http://192.0.2.2:4321/demo/", output.getvalue()) + self.assertTrue( + all(call.args[1].path == "/demo/" for call in responds.call_args_list) + ) + + def test_missing_and_dead_records_do_not_mutate_status(self): + before = self.path.read_bytes() + with patch.object(os, "kill", side_effect=ProcessLookupError): + self.assertIsNone(prototype.live_server(self.project)) + self.assertEqual(self.path.read_bytes(), before) + self.path.unlink() + self.assertIsNone(prototype.read_server(self.project)) + + def test_uninspectable_process_is_not_absent(self): + before = self.path.read_bytes() + with patch.object(os, "kill", side_effect=PermissionError("denied")): + with self.assertRaisesRegex(PermissionError, "denied"): + prototype.live_server(self.project) + self.assertEqual(self.path.read_bytes(), before) + + def test_exit_during_inspection_is_absence_but_other_errors_propagate(self): + for later, expected in ((ProcessLookupError(), False), (None, None)): + with ( + patch.object(os, "kill", side_effect=[None, later]), + patch.object( + prototype, "process_identity", side_effect=FileNotFoundError("gone") + ), + ): + if expected is False: + self.assertFalse(prototype.owns_process(self.project, self.server)) + else: + with self.assertRaisesRegex(FileNotFoundError, "gone"): + prototype.owns_process(self.project, self.server) + + def test_invalid_records_fail_without_removal(self): + for bad in ( + [], + {**self.record, "pid": True}, + {**self.record, "port": 0}, + {**self.record, "url": {}}, + {**self.record, "url": "http://example.com:4321/"}, + ): + with self.subTest(record=bad): + self.path.write_text(json.dumps(bad)) + with self.assertRaises(ValueError): + prototype.read_server(self.project) + self.assertEqual(json.loads(self.path.read_text()), bad) + self.path.write_text("not json") + with self.assertRaises(ValueError): + prototype.read_server(self.project) + self.assertEqual(self.path.read_text(), "not json") + + def identity(self, platform, project=None): + project = project or self.project + argv = [ + "/tool chain/node", + str(self.root / "node_modules/astro/bin/astro.mjs"), + "dev", + "--port", + "4321", + "--host", + "0.0.0.0", + "--config", + "astro.config.mjs", + "--root", + str(project), + "--json", + ] + identity = argv if platform == "linux" else (argv[0], " ".join(argv[1:])) + return identity, project + + def test_owned_absolute_root_with_spaces_and_unicode_on_both_platforms(self): + for platform in ("darwin", "linux"): + with ( + self.subTest(platform=platform), + patch.object(prototype.sys, "platform", platform), + patch.object(os, "kill"), + patch.object( + prototype, "process_identity", return_value=self.identity(platform) + ), + ): + self.assertTrue(prototype.owns_process(self.project, self.server)) + + def test_another_astro_project_or_mismatched_cwd_is_not_owned(self): + other = self.root / "A prototype other" + for platform in ("darwin", "linux"): + for identity in ( + self.identity(platform, other), + (self.identity(platform)[0], other), + ): + with ( + self.subTest(platform=platform, identity=identity), + patch.object(prototype.sys, "platform", platform), + patch.object(os, "kill"), + patch.object(prototype, "process_identity", return_value=identity), + ): + with self.assertRaisesRegex(ValueError, "not an owned"): + prototype.owns_process(self.project, self.server) + + def test_arbitrary_process_and_foreground_server_are_not_owned(self): + for identity in ( + (["python3", "other.py"], self.project), + self.identity("linux"), + ): + with ( + patch.object(prototype.sys, "platform", "linux"), + patch.object(os, "kill"), + patch.object(prototype, "process_identity", return_value=identity), + ): + server = prototype.Server( + self.server.pid, 4321, "/", False, self.record + ) + with self.assertRaisesRegex(ValueError, "not an owned"): + prototype.owns_process(self.project, server) + + def test_report_uses_current_reachable_addresses_and_ignores_cached_urls(self): + calls = [] + + def responds(host, server): + calls.append(host) + return host != "192.0.2.3" + + with ( + patch.object( + prototype, + "current_addresses", + return_value=[("en0", "192.0.2.2"), ("vpn", "192.0.2.3")], + ), + patch.object(prototype, "responds", side_effect=responds), + contextlib.redirect_stdout(io.StringIO()) as output, + ): + prototype.report(self.project, self.server, lan_required=True) + self.assertIn("http://192.0.2.2:4321/", output.getvalue()) + self.assertNotIn("192.0.2.1", output.getvalue()) + self.assertNotIn("http://192.0.2.3", output.getvalue()) + self.assertEqual(calls, ["127.0.0.1", "192.0.2.2", "192.0.2.3"]) + + def test_lan_request_cannot_succeed_for_loopback_only_server(self): + with ( + patch.object(prototype, "current_addresses", return_value=[]), + patch.object(prototype, "responds", return_value=True), + contextlib.redirect_stdout(io.StringIO()), + ): + with self.assertRaisesRegex(ValueError, "LAN preview is unavailable"): + prototype.report(self.project, self.server, lan_required=True) + + def test_http_failure_is_bounded_and_diagnostic(self): + with patch.object(prototype, "responds", return_value=False): + with self.assertRaisesRegex(ValueError, "no successful HTTP response"): + prototype.report(self.project, self.server) + + def test_probes_do_not_follow_redirects_or_use_proxy_environment(self): + with patch.object(prototype.http.client, "HTTPConnection") as connection: + connection.return_value.getresponse.return_value.status = 302 + self.assertFalse(prototype.responds("192.0.2.2", self.server)) + connection.assert_called_once_with("192.0.2.2", 4321, timeout=2) + connection.return_value.request.assert_called_once_with("GET", "/") + connection.return_value.close.assert_called_once() + + def test_current_addresses_use_pinned_node_and_filter_loopback_ipv6(self): + interfaces = { + "lo": [{"family": "IPv4", "internal": True, "address": "127.0.0.1"}], + "en0": [ + {"family": "IPv4", "internal": False, "address": "192.0.2.2"}, + {"family": "IPv6", "internal": False, "address": "fe80::1"}, + ], + } + with ( + patch.object(prototype, "resolve_mise", return_value="/pinned/mise"), + patch.object( + prototype.subprocess, + "run", + return_value=subprocess.CompletedProcess( + [], 0, json.dumps(interfaces), "" + ), + ) as run, + ): + self.assertEqual(prototype.current_addresses(), [("en0", "192.0.2.2")]) + self.assertEqual( + run.call_args.args[0][:5], + ["/pinned/mise", "exec", "node", "--", "node"], + ) + self.assertEqual(run.call_args.kwargs["cwd"], prototype.SOURCE_ROOT) + + def test_repeated_start_reuses_server_without_launching(self): + with ( + patch.object(prototype, "astro_command"), + patch.object(prototype, "live_server", return_value=self.server), + patch.object(prototype, "run_astro") as run, + patch.object(prototype, "report") as report, + contextlib.redirect_stdout(io.StringIO()), + ): + self.assertEqual( + prototype.start(self.project, None, True, path="/demo/"), 0 + ) + run.assert_not_called() + report.assert_called_once_with( + self.project, + self.server, + path="/demo/", + lan_required=True, + wait_seconds=10, + ) + + def test_existing_port_mismatch_preserves_server(self): + with ( + patch.object(prototype, "astro_command"), + patch.object(prototype, "live_server", return_value=self.server), + patch.object(prototype, "run_astro") as run, + ): + with self.assertRaisesRegex(ValueError, "Already running on port 4321"): + prototype.start(self.project, 4322, False) + run.assert_not_called() + + def test_start_refuses_a_busy_port_before_astro(self): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + with ( + patch.object(prototype, "astro_command"), + patch.object(prototype, "live_server", return_value=None), + patch.object(prototype, "run_astro") as run, + ): + with self.assertRaisesRegex(ValueError, "Port .* is unavailable"): + prototype.start(self.project, listener.getsockname()[1], False) + run.assert_not_called() + + def test_start_passes_host_and_port_to_native_background_lifecycle(self): + with ( + patch.object(prototype, "astro_command"), + patch.object( + prototype, "live_server", side_effect=[None, None, self.server] + ), + patch.object(prototype, "run_astro", return_value=0) as run, + patch.object(prototype, "report"), + patch.object(prototype.socket, "socket"), + ): + self.assertEqual(prototype.start(self.project, 4321, True), 0) + run.assert_called_once_with( + self.project, + "dev", + "--host", + "0.0.0.0", + "--port", + "4321", + "--background", + ) + + def test_competing_start_is_rejected_without_launch(self): + with ( + prototype.lifecycle_lock(self.project), + patch.object(prototype, "astro_command"), + patch.object(prototype, "live_server", return_value=None), + patch.object(prototype, "run_astro") as run, + ): + with self.assertRaisesRegex(ValueError, "Another prototype start/stop"): + prototype.start(self.project, None, False) + run.assert_not_called() + + def test_stop_rechecks_record_before_delegating(self): + changed = prototype.Server(654321, 4321, "/", True, {}) + with ( + patch.object(prototype, "live_server", side_effect=[self.server, changed]), + patch.object(prototype, "astro_command"), + patch.object(prototype, "run_astro") as run, + ): + with self.assertRaisesRegex(ValueError, "Server changed"): + prototype.stop(self.project) + run.assert_not_called() + + def test_stop_checks_native_result_and_process_exit(self): + with ( + patch.object(prototype, "live_server", return_value=self.server), + patch.object(prototype, "astro_command"), + patch.object(prototype, "run_astro", return_value=0) as run, + patch.object(prototype, "owns_process", return_value=False), + ): + self.assertEqual(prototype.stop(self.project), 0) + run.assert_called_once_with(self.project, "dev", "stop") + + def test_native_failure_and_signal_status_are_preserved(self): + for code, expected in ((17, 17), (-15, 143)): + with ( + patch.object(prototype, "astro_command", return_value=["fake"]), + patch.object( + prototype.subprocess, + "run", + return_value=subprocess.CompletedProcess([], code), + ), + ): + self.assertEqual(prototype.run_astro(self.project, "check"), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/Tests/test_prototype_command.py b/Tools/Tests/test_prototype_command.py new file mode 100644 index 000000000..7817e84d4 --- /dev/null +++ b/Tools/Tests/test_prototype_command.py @@ -0,0 +1,251 @@ +"""Exercise the public prototype command without a real Node server or daemon.""" + +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import unittest + +from Tools.repository import repository_root + +REPOSITORY = repository_root(__file__) + + +class PrototypeCommandTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="prototype command ü ") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.checkout = self.root / "Stuff with spaces" + self.outside = self.root / "outside" + self.project = self.outside / "Example ü" + self.bin = self.root / "bin" + for directory in (self.checkout / "Tools", self.bin, self.project): + directory.mkdir(parents=True) + (self.checkout / ".git").touch() + for source in ( + "prototype", + "Tools/prototype.py", + "Tools/astro_site.py", + "Tools/repository.py", + ): + shutil.copy2(REPOSITORY / source, self.checkout / source) + (self.project / "astro.config.mjs").touch() + (self.bin / "python3").symlink_to(sys.executable) + self.trace = self.root / "trace.json" + self.environment = { + **os.environ, + "PATH": str(self.bin), + "PROTOTYPE_TEST_TRACE": str(self.trace), + } + self.environment.pop("PYTHONPATH", None) + + def fake_tools(self): + entry = self.checkout / "node_modules/.bin/astro" + entry.parent.mkdir(parents=True) + entry.touch() + mise = self.bin / "mise" + mise.write_text("""#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import signal +import sys +Path(os.environ['PROTOTYPE_TEST_TRACE']).write_text(json.dumps({ + 'argv': sys.argv[1:], 'cwd': os.getcwd(), + 'telemetry': os.environ.get('ASTRO_TELEMETRY_DISABLED'), +})) +print('child stdout') +print('child stderr', file=sys.stderr) +if os.environ.get('PROTOTYPE_TEST_SIGNAL'): + os.kill(os.getpid(), signal.SIGTERM) +sys.exit(int(os.environ.get('PROTOTYPE_TEST_EXIT', '0'))) +""") + mise.chmod(0o755) + + def run_command(self, *arguments): + return subprocess.run( + [str(self.checkout / "prototype"), *arguments], + cwd=self.outside, + env=self.environment, + text=True, + capture_output=True, + timeout=15, + ) + + def record(self, pid): + (self.project / ".astro").mkdir(exist_ok=True) + path = self.project / ".astro/dev.json" + path.write_text( + json.dumps( + { + "pid": pid, + "port": 4321, + "url": "http://localhost:4321/", + "background": True, + "startedAt": "2026-11-15T12:00:00Z", + } + ) + ) + return path + + def test_help_and_usage_errors_need_no_tooling_from_unrelated_cwd(self): + result = self.run_command("--help") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("start,status,stop,logs,check,build", result.stdout) + self.assertEqual(result.stderr, "") + for arguments in ( + ("start", "Example ü", "--port", "0"), + ("unknown",), + ("status", "Example ü", "--lan"), + ("start", "Example ü", "--path", "https://example.com/"), + ("start", "Example ü", "--path", "//example.com/"), + ("start", "Example ü", "--path", "/demo/?q=x"), + ): + with self.subTest(arguments=arguments): + result = self.run_command(*arguments) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("error:", result.stderr) + self.assertNotIn("mise", result.stderr) + self.assertFalse((self.project / ".astro").exists()) + + def test_check_and_build_preserve_streams_status_and_absolute_project(self): + self.fake_tools() + self.environment["PROTOTYPE_TEST_EXIT"] = "17" + for command in ("check", "build"): + with self.subTest(command=command): + result = self.run_command(command, "Example ü") + self.assertEqual(result.returncode, 17) + self.assertEqual(result.stdout, "child stdout\n") + self.assertEqual(result.stderr, "child stderr\n") + trace = json.loads(self.trace.read_text()) + self.assertEqual(trace["cwd"], str(self.checkout)) + self.assertEqual(trace["telemetry"], "1") + self.assertEqual( + trace["argv"], + [ + "exec", + "node", + "--", + "node", + str(self.checkout / "node_modules/.bin/astro"), + command, + "--root", + str(self.project), + "--config", + "astro.config.mjs", + ], + ) + + def test_signal_exit_propagates(self): + self.fake_tools() + self.environment["PROTOTYPE_TEST_SIGNAL"] = "1" + result = self.run_command("check", str(self.project)) + self.assertEqual(result.returncode, 143, result.stderr) + + def test_invalid_project_is_rejected_before_tooling_or_state(self): + result = self.run_command("start", str(self.outside)) + self.assertEqual(result.returncode, 1) + self.assertIn("Expected astro.config.mjs", result.stderr) + self.assertFalse((self.outside / ".astro").exists()) + self.assertFalse(self.trace.exists()) + + def test_missing_dependencies_are_actionable_before_state(self): + result = self.run_command("start", "Example ü") + self.assertEqual(result.returncode, 1) + self.assertIn("mise exec -- npm ci", result.stderr) + self.assertFalse((self.project / ".astro").exists()) + + def test_status_of_stale_record_is_read_only_and_needs_no_tooling(self): + child = subprocess.Popen([sys.executable, "-c", "pass"]) + child.wait(timeout=5) + path = self.record(child.pid) + before = path.read_bytes() + result = self.run_command("status", "Example ü") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("No prototype server is running", result.stdout) + self.assertEqual(result.stderr, "") + self.assertEqual(path.read_bytes(), before) + + def test_zombie_is_stale_without_erasing_its_record(self): + child = subprocess.Popen([sys.executable, "-c", "pass"]) + self.addCleanup(child.wait, timeout=5) + deadline = time.monotonic() + 5 + while True: + state = subprocess.run( + ["/bin/ps", "-p", str(child.pid), "-o", "stat="], + text=True, + capture_output=True, + check=True, + ).stdout.strip() + if state.startswith("Z"): + break + if time.monotonic() >= deadline: + self.fail("Child did not enter zombie state before deadline") + time.sleep(0.01) + path = self.record(child.pid) + before = path.read_bytes() + for command, code in (("status", 1), ("stop", 0)): + result = self.run_command(command, "Example ü") + self.assertEqual(result.returncode, code, result.stderr) + self.assertIn("No prototype server is running", result.stdout) + self.assertEqual(result.stderr, "") + self.assertEqual(path.read_bytes(), before) + + def test_stop_refuses_an_unrelated_live_process(self): + path = self.record(os.getpid()) + before = path.read_bytes() + result = self.run_command("stop", "Example ü") + self.assertEqual(result.returncode, 1) + self.assertIn("not an owned background Astro server", result.stderr) + self.assertEqual(path.read_bytes(), before) + self.assertFalse(self.trace.exists()) + os.kill(os.getpid(), 0) + + def test_logs_are_available_after_failed_start_without_tooling(self): + path = self.record(123456) + (path.parent / "dev.log").write_text("Astro could not start\n") + result = self.run_command("logs", "Example ü") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "Astro could not start\n") + self.assertEqual(result.stderr, "") + self.assertTrue(path.exists()) + + def test_malformed_status_is_an_error_and_keeps_evidence(self): + path = self.record(123456) + path.write_text("corrupt") + result = self.run_command("status", "Example ü") + self.assertEqual(result.returncode, 1) + self.assertIn("prototype:", result.stderr) + self.assertEqual(result.stdout, "") + self.assertEqual(path.read_text(), "corrupt") + + def test_stopping_absent_server_is_idempotent(self): + result = self.run_command("stop", "Example ü") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "No prototype server is running.\n") + self.assertFalse((self.project / ".astro").exists()) + + def test_start_preserves_native_failure(self): + self.fake_tools() + self.environment["PROTOTYPE_TEST_EXIT"] = "23" + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + result = self.run_command("start", "Example ü", "--port", str(port), "--lan") + self.assertEqual(result.returncode, 23, result.stderr) + self.assertEqual(result.stderr, "child stderr\n") + self.assertEqual(result.stdout, "child stdout\n") + argv = json.loads(self.trace.read_text())["argv"] + self.assertIn("--background", argv) + self.assertEqual(argv[argv.index("--root") + 1], str(self.project)) + self.assertEqual(argv[argv.index("--host") + 1], "0.0.0.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/prototype.py b/Tools/prototype.py new file mode 100644 index 000000000..ab9982360 --- /dev/null +++ b/Tools/prototype.py @@ -0,0 +1,410 @@ +"""Project-scoped preview policy around Astro's own background server lifecycle.""" + +import argparse +from contextlib import contextmanager +from dataclasses import dataclass, replace +import fcntl +import http.client +import ipaddress +import json +import os +from pathlib import Path +import re +import socket +import subprocess +import sys +import time +from urllib.parse import urlsplit + +from Tools.astro_site import SOURCE_ROOT, astro_command, resolve_mise +from Tools.repository import repository_root + + +@dataclass(frozen=True) +class Server: + pid: int + port: int + path: str + background: bool + record: dict + + +def project_directory(value): + project = Path(value).expanduser().resolve(strict=True) + if not (project / "astro.config.mjs").is_file(): + raise ValueError(f"Expected astro.config.mjs in {project}") + if (project / ".astro").is_symlink(): + raise ValueError("Keep the project's .astro directory local, not symlinked.") + return project + + +def read_server(project): + """Only absence means no record; inspection/parse errors must stay visible.""" + path = project / ".astro/dev.json" + try: + if path.is_symlink(): + raise ValueError(f"Refusing a symlinked server record: {path}") + data = json.loads(path.read_text()) + except FileNotFoundError: + return None + if not isinstance(data, dict): + raise ValueError(f"Invalid Astro server record: {path}") + pid, port = data.get("pid"), data.get("port") + if ( + type(pid) is not int + or pid <= 1 + or type(port) is not int + or not 1 <= port <= 65535 + or type(data.get("background")) is not bool + or not isinstance(data.get("startedAt"), str) + ): + raise ValueError(f"Invalid Astro server record: {path}") + url = local_url(data.get("url"), port) + return Server(pid, port, url.path or "/", data["background"], data) + + +def local_url(value, port): + if not isinstance(value, str): + raise ValueError("Invalid local URL in Astro server record.") + url = urlsplit(value) + if ( + url.scheme != "http" + or url.hostname not in ("localhost", "127.0.0.1", "::1", "0.0.0.0") + or url.username is not None + or url.password is not None + or (url.port if url.port is not None else 80) != port + or url.query + or url.fragment + ): + raise ValueError("Invalid local URL in Astro server record.") + return url + + +def captured(command): + result = subprocess.run(command, text=True, capture_output=True, timeout=5) + if result.returncode: + raise ValueError( + f"Cannot inspect server: {result.stderr.strip() or command[0]}" + ) + return result.stdout.strip() + + +def process_identity(pid): + """Read exact argv on Linux, command text plus cwd on macOS; fail closed.""" + state = captured(["/bin/ps", "-ww", "-p", str(pid), "-o", "stat="]) + if state.startswith(("Z", "X")): + return None + if sys.platform == "linux": + process = Path("/proc") / str(pid) + return ( + (process / "cmdline").read_bytes().decode().rstrip("\0").split("\0"), + (process / "cwd").resolve(strict=True), + ) + if sys.platform == "darwin": + command = captured(["/bin/ps", "-ww", "-p", str(pid), "-o", "command="]) + executable = captured(["/bin/ps", "-ww", "-p", str(pid), "-o", "comm="]) + cwd = captured(["/usr/sbin/lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"]) + paths = [line[1:] for line in cwd.splitlines() if line.startswith("n")] + if len(paths) != 1 or not command.startswith(executable + " "): + raise ValueError(f"Cannot inspect server process {pid}") + return (executable, command[len(executable) + 1 :]), Path(paths[0]).resolve() + raise ValueError("Prototype server management supports macOS and Linux.") + + +def owns_process(project, server): + """A live PID alone is insufficient: require Astro dev and this project root.""" + try: + os.kill(server.pid, 0) + except ProcessLookupError: + return False + # PermissionError deliberately propagates: an inaccessible process is not stale. + try: + process = process_identity(server.pid) + except (OSError, ValueError): + # A process can exit between kill(0) and inspection; only ESRCH proves + # absence. Permission and command-inspection failures remain errors. + try: + os.kill(server.pid, 0) + except ProcessLookupError: + return False + raise + if process is None: + return False + identity, cwd = process + roots = {SOURCE_ROOT} + try: + roots.add(repository_root(project / "astro.config.mjs")) + except FileNotFoundError: + # A standalone project can still use this checkout's Astro executable. + pass + entries = { + str((root / "node_modules" / entry).resolve()) + for root in roots + for entry in (".bin/astro", "astro/bin/astro.mjs") + } + entries.update(str(root / "node_modules/.bin/astro") for root in roots) + if sys.platform == "linux": + argv = identity + matches = ( + len(argv) >= 5 + and Path(argv[0]).name == "node" + and argv[1] in entries + and argv[2] == "dev" + and argv.count("--root") == 1 + and argv.index("--root") + 1 < len(argv) + and argv[argv.index("--root") + 1] == str(project) + ) + else: + executable, arguments = identity + # ps flattens argv on macOS. Native background Astro puts --root after + # port/host/config and only --json after it in commands supported here. + matches = Path(executable).name == "node" and any( + arguments.startswith(entry + " dev ") + and re.search( + r" --root " + re.escape(str(project)) + r"(?: --json)?$", arguments + ) + for entry in entries + ) + if cwd != project or not matches or not server.background: + raise ValueError( + f"PID {server.pid} is not an owned background Astro server for {project}; " + "leaving it and its record untouched." + ) + return True + + +def live_server(project): + server = read_server(project) + return server if server and owns_process(project, server) else None + + +def current_addresses(): + """Discover current IPv4 interfaces rather than Astro's cached startup URLs.""" + command = [ + resolve_mise(), + "exec", + "node", + "--", + "node", + "-e", + "process.stdout.write(JSON.stringify(require('node:os').networkInterfaces()))", + ] + result = subprocess.run( + command, cwd=SOURCE_ROOT, text=True, capture_output=True, timeout=10 + ) + if result.returncode: + raise ValueError(f"Cannot discover network interfaces: {result.stderr.strip()}") + addresses = [] + for name, interfaces in json.loads(result.stdout).items(): + for interface in interfaces or []: + if interface["family"] != "IPv4" or interface["internal"]: + continue + address = str(ipaddress.IPv4Address(interface["address"])) + if (name, address) not in addresses: + addresses.append((name, address)) + return addresses + + +def responds(host, server): + # Direct connections bypass proxy variables and never follow redirects. + connection = http.client.HTTPConnection(host, server.port, timeout=2) + try: + connection.request("GET", server.path) + response = connection.getresponse() + return 200 <= response.status < 300 + except (OSError, http.client.HTTPException): + return False + finally: + connection.close() + + +def report(project, server, *, path="/", lan_required=False, wait_seconds=0): + server = replace(server, path=path) + deadline = time.monotonic() + wait_seconds + while not responds("127.0.0.1", server): + if time.monotonic() >= deadline: + raise ValueError( + f"PID {server.pid} has no successful HTTP response at {server.path} on port {server.port}. " + f"For a non-root route, pass --path /your-route/. Inspect ./prototype logs {project}" + ) + time.sleep(0.2) + print( + f"Project: {project}\nPID: {server.pid}\nLocal: http://localhost:{server.port}{server.path}" + ) + reachable = [] + for name, address in current_addresses(): + if responds(address, server): + reachable.append(address) + print(f"Network ({name}): http://{address}:{server.port}{server.path}") + if not reachable: + print("Network: no current IPv4 interface returned a successful HTTP response.") + if lan_required: + raise ValueError( + "LAN preview is unavailable. If this server was started without --lan, " + "stop it and start again with --lan." + ) + print(f"Log: {project / '.astro/dev.log'}") + + +@contextmanager +def lifecycle_lock(project): + """Serialize wrapper operations; Astro continues to own the PID and daemon.""" + (project / ".astro").mkdir(exist_ok=True) + with (project / ".astro/prototype-command.lock").open("a") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise ValueError( + "Another prototype start/stop is in progress for this project." + ) from error + yield + + +def run_astro(project, *arguments): + command = astro_command(project, project, *arguments) + result = subprocess.run( + command, cwd=SOURCE_ROOT, env={**os.environ, "ASTRO_TELEMETRY_DISABLED": "1"} + ) + return result.returncode if result.returncode >= 0 else 128 - result.returncode + + +def start(project, port, lan, *, path="/"): + # Resolve dependencies before writing any state; native Astro owns readiness + # of its daemon (30s), then we verify the served route with a bounded probe. + astro_command(project, project, "dev") + live_server(project) + with lifecycle_lock(project): + server = live_server(project) + if server: + if port is not None and port != server.port: + raise ValueError( + f"Already running on port {server.port}; stop it before changing ports." + ) + print("Reusing the existing server.") + else: + port = port or 4321 + host = "0.0.0.0" if lan else "127.0.0.1" + with socket.socket() as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + probe.bind((host, port)) + except OSError as error: + raise ValueError( + f"Port {port} is unavailable; choose another --port. {error}" + ) from error + result = run_astro( + project, "dev", "--host", host, "--port", str(port), "--background" + ) + if result: + return result + server = live_server(project) + if server is None: + raise ValueError( + "Astro exited without a live server record. Inspect prototype logs." + ) + if server.port != port: + raise ValueError( + f"Astro selected port {server.port} instead of {port}; inspect status or stop it." + ) + report(project, server, path=path, lan_required=lan, wait_seconds=10) + return 0 + + +def stop(project): + server = live_server(project) + if server is None: + print("No prototype server is running.") + return 0 + astro_command(project, project, "dev", "stop") + with lifecycle_lock(project): + # Re-read after acquiring the lock; never act on a superseded record. + current = live_server(project) + if current is None: + print("No prototype server is running.") + return 0 + if current != server: + raise ValueError("Server changed during inspection; retry stop.") + result = run_astro(project, "dev", "stop") + if result == 0 and owns_process(project, current): + raise ValueError(f"Astro did not stop PID {current.pid}.") + return result + + +def port_number(value): + port = int(value) + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError("port must be between 1 and 65535") + return port + + +def preview_path(value): + url = urlsplit(value) + if ( + not value.startswith("/") + or value.startswith("//") + or url.query + or url.fragment + or not value.isascii() + or re.search(r"[\x00-\x20\x7f]", value) + ): + raise argparse.ArgumentTypeError( + "path must be an absolute URL path, percent-encoded, without query or fragment" + ) + return value + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Manage a project-scoped Astro prototype with the repository's pinned tools.", + epilog="PROJECT is relative to your current directory (or absolute). Requires astro.config.mjs. " + "Start leaves Astro running in the background. --lan allows same-network device review.", + ) + commands = parser.add_subparsers(dest="command", required=True) + for name in ("start", "status", "stop", "logs", "check", "build"): + command = commands.add_parser(name) + command.add_argument("project", metavar="PROJECT") + if name in ("start", "status"): + command.add_argument( + "--path", + type=preview_path, + default="/", + help="HTTP route to check and share; use for Astro base paths (default: /)", + ) + if name == "start": + command.add_argument( + "--lan", action="store_true", help="listen on all IPv4 interfaces" + ) + command.add_argument( + "--port", + type=port_number, + help="default: reuse existing server, otherwise 4321", + ) + args = parser.parse_args(argv) + try: + project = project_directory(args.project) + if args.command == "start": + return start(project, args.port, args.lan, path=args.path) + if args.command == "stop": + return stop(project) + if args.command == "status": + server = live_server(project) + if server is None: + print("No prototype server is running (any stale record is unchanged).") + return 1 + report(project, server, path=args.path) + return 0 + if args.command == "logs": + # Read Astro's own log even after startup fails; status must not erase + # its stale record, and log inspection needs neither Node nor mise. + print((project / ".astro/dev.log").read_text(), end="") + return 0 + return run_astro(project, args.command) + except KeyboardInterrupt: + return 130 + except (OSError, ValueError, subprocess.TimeoutExpired) as error: + print(f"prototype: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/prototype b/prototype new file mode 100755 index 000000000..9e827e53b --- /dev/null +++ b/prototype @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +"""Manage an Astro prototype using this checkout's pinned web tools.""" + +import sys + +from Tools.prototype import main + +if __name__ == "__main__": + sys.exit(main())