diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..492153f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,75 @@ +# Copilot PR Review Instructions — FastEdge-sdk-js + +## Constitution + +This repository is `@gcoredev/fastedge-sdk-js` — the JavaScript/TypeScript SDK for Gcore FastEdge. It provides three CLI tools (`fastedge-build`, `fastedge-init`, `fastedge-assets`), a static site server API (`createStaticServer`), and TypeScript type definitions for the FastEdge WASM runtime. + +### Principles (enforce during review) + +1. **Type accuracy** — TypeScript declarations in `types/` are the authoritative public API surface. Code and docs must match them exactly. +2. **No over-engineering** — Simple solutions over complex abstractions. Three similar lines > premature abstraction. +3. **CLI flag consistency** — All CLI flags must match `arg()` definitions in `src/cli/*/`. No undocumented flags, no documented-but-removed flags. +4. **Build pipeline integrity** — The componentize pipeline (esbuild → Wizer → JCO) is sensitive to ordering. Changes must preserve stage dependencies. +5. **Runtime constraints** — Code targeting the WASM runtime has strict limitations (no Node APIs, init-time-only Wizer calls). Reviewers must flag violations. + +### Public API contract + +The public API surface is defined by: +- `types/` — TypeScript declarations (runtime APIs, static server, KV store) +- `src/cli/fastedge-build/` — Build CLI flags and config interface +- `src/cli/fastedge-init/` — Scaffold CLI wizard +- `src/cli/fastedge-assets/` — Asset manifest CLI +- `src/server/static-assets/static-server/` — `createStaticServer` API + +Changes to these surfaces require updated `docs/`, updated tests, and a semver-appropriate version bump. + +## Documentation Freshness + +`docs/` is the single source of truth for public API documentation. When code changes affect the public API or user-facing behavior, **request changes** if the corresponding doc file was not updated in the same PR. + +### Public API changes (must update docs/) +- New, modified, or removed CLI flags in `src/cli/fastedge-build/build.ts` +- Changes to `BuildConfig` or `AssetCacheConfig` interfaces in `src/cli/fastedge-build/types.ts` +- Changes to scaffold wizard behavior in `src/cli/fastedge-init/` +- Changes to asset manifest CLI in `src/cli/fastedge-assets/` +- Changes to `createStaticServer` API or `ServerConfig` in `src/server/static-assets/` +- Changes to TypeScript declarations in `types/` +- Changes to `package.json` exports or bin entries + +### Mapping: code location → doc file + +| Code path | Doc file | +|-----------|----------| +| `src/cli/fastedge-build/` (flags, config) | `docs/BUILD_CLI.md` | +| `src/cli/fastedge-build/types.ts` (BuildConfig) | `docs/BUILD_CLI.md` | +| `src/cli/fastedge-build/config-build.ts` (build types) | `docs/BUILD_CLI.md` | +| `src/cli/fastedge-init/` (wizard, templates) | `docs/INIT_CLI.md` | +| `src/cli/fastedge-assets/` (manifest CLI) | `docs/ASSETS_CLI.md` | +| `src/server/static-assets/static-server/` | `docs/STATIC_SITES.md` | +| `src/server/static-assets/asset-manifest/` | `docs/ASSETS_CLI.md` | +| `types/fastedge-env.d.ts` | `docs/SDK_API.md` | +| `types/fastedge-secret.d.ts` | `docs/SDK_API.md` | +| `types/fastedge-kv.d.ts` | `docs/SDK_API.md` | +| `types/globals.d.ts` | `docs/SDK_API.md` | +| `package.json` (exports, bin) | `docs/INDEX.md` | +| `types/`, `src/cli/`, `README.md` (quickstart examples) | `docs/quickstart.md` | +| `fastedge-plugin-source/manifest.json` | `.github/copilot-instructions.md` | + +### Violation example + +> PR changes `BuildConfig` interface in `types.ts` but `docs/BUILD_CLI.md` still shows the old field names → **request changes**. The config interface must be documented before merge. + +### Quickstart protection + +If any public API signature or CLI behavior changes, check whether `docs/quickstart.md` examples are still accurate. Request changes if examples would no longer work against the updated code. + +### Pipeline source contract + +If `fastedge-plugin-source/manifest.json` lists source files that overlap with files changed in this PR, request that `docs/` is updated to keep the plugin pipeline's source material current. + +## Quality Rules + +- All TypeScript signatures in docs must match `types/` declarations exactly +- All CLI flags in docs must match `arg()` definitions in source +- KvStore `get()` returns `ArrayBuffer | null` (not string) — flag incorrect types +- No marketing language in documentation — precise, technical prose only diff --git a/.github/workflows/collect.py b/.github/workflows/collect.py new file mode 100644 index 0000000..ce1fb65 --- /dev/null +++ b/.github/workflows/collect.py @@ -0,0 +1,959 @@ +from __future__ import annotations + +import json +import os +import re +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +SOURCE_BRANCH = "doctor-v1" +TOOLKIT_SOURCE_DIR = "toolkit" +RUNTIME_SOURCE_DIR = "workflows/runtime" +TOOLKIT_TARGET_ROOT = ".toolkit" +RUNTIME_TARGET_ROOT = ".github/workflows" +TOOLKIT_SOURCE_SHA_PATH = ".toolkit/SOURCE_SHA" +RUNTIME_SOURCE_SHA_PATH = ".toolkit/DOCTOR_RUNTIME_SHA" +TOOLKIT_REQUIRED_DIRECTORIES = { + "claude": ".toolkit/claude", + "scripts": ".toolkit/scripts", + "workflows": ".toolkit/workflows", + "examples": ".toolkit/examples", +} +RUNTIME_SOURCE_FILES = [ + # Closed-set allowlist: adding a new runtime file requires updating this list + # so doctor never silently ignores source bundle changes. + "doctor.yml", + "collect.py", + "doctor-analyze.md", + "doctor-analyze.prompt.md", + "doctor-analyze.lock.yml", +] +RUNTIME_BOOTSTRAP_FILES = [ + "doctor.yml", +] +RUNTIME_SYNC_FILES: list[str] = [] +STALE_RUNTIME_TARGET_FILES = sorted(set(RUNTIME_SOURCE_FILES) - set(RUNTIME_BOOTSTRAP_FILES)) +HYGIENE_EXCLUDED_FILE_NAMES = {".DS_Store"} +HYGIENE_EXCLUDED_DIR_NAMES = {"__pycache__"} +HYGIENE_EXCLUDED_SUFFIXES = {".pyc", ".pyo"} +_TRAILER_PATTERNS = { + "method": re.compile(r"^Method:\s*(.+)$", re.IGNORECASE), + "agent": re.compile(r"^Agent:\s*(.+)$", re.IGNORECASE), + "co_authored_by": re.compile(r"^Co-authored-by:\s*(.+)$", re.IGNORECASE), + "refs": re.compile(r"^Refs:\s*(.+)$", re.IGNORECASE), + "closes": re.compile(r"^Closes:\s*(.+)$", re.IGNORECASE), +} +PRESENCE_ONLY_PATHS = [ + "AGENTS.md", + "CLAUDE.md", + "DOCS.md", + ".specify/memory/constitution.md", + "docs/quickstart.md", +] + + +def _path_to_id(path: str) -> str: + return path.strip("./").replace("/", "_").replace(".", "_") + + +def _path_exists(repo_root: Path, relative_path: str) -> bool: + return (repo_root / relative_path).exists() + + +def _path_state(repo_root: Path, relative_path: str) -> str: + path = repo_root / relative_path + if path.exists(): + return "present" + if os.path.lexists(path): + return "broken_link" + return "missing" + + +def _path_present(repo_root: Path, relative_path: str) -> bool: + return _path_state(repo_root, relative_path) == "present" + + +def _is_hygiene_ignored(candidate: Path) -> bool: + return ( + candidate.name in HYGIENE_EXCLUDED_FILE_NAMES + or candidate.suffix in HYGIENE_EXCLUDED_SUFFIXES + or any(part in HYGIENE_EXCLUDED_DIR_NAMES for part in candidate.parts) + ) + + +def _is_managed_source_file(candidate: Path) -> bool: + return candidate.is_file() and not _is_hygiene_ignored(candidate) + + +def _new_finding( + finding_id: str, + *, + category: str, + severity: str, + path: str | None, + state: str, + auto_action: str, + message: str, + expected: str | None = None, + actual: str | None = None, +) -> dict[str, Any]: + return { + "id": finding_id, + "category": category, + "severity": severity, + "path": path, + "state": state, + "expected": expected, + "actual": actual, + "auto_action": auto_action, + "message": message, + } + + +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def load_required_runtime_files_from_source( + *, + source_root: Path, + selected_relative_paths: list[str] | tuple[str, ...] | None = None, +) -> dict[str, str]: + source_root = Path(source_root) + runtime_source_root = source_root / RUNTIME_SOURCE_DIR + if not runtime_source_root.exists(): + raise FileNotFoundError(f"Runtime source root is missing: {runtime_source_root}") + + actual_relative_paths = sorted( + candidate.relative_to(runtime_source_root).as_posix() + for candidate in runtime_source_root.rglob("*") + if _is_managed_source_file(candidate) + ) + actual_path_set = set(actual_relative_paths) + + missing_sources = [ + relative_path + for relative_path in RUNTIME_SOURCE_FILES + if relative_path not in actual_path_set + ] + if missing_sources: + missing_list = ", ".join(missing_sources) + raise FileNotFoundError(f"Required doctor runtime source files are missing: {missing_list}") + + unexpected_sources = [ + relative_path + for relative_path in actual_relative_paths + if relative_path not in RUNTIME_SOURCE_FILES + ] + if unexpected_sources: + unexpected_list = ", ".join(unexpected_sources) + raise RuntimeError( + "Unexpected doctor runtime source files found: " + f"{unexpected_list}. Update RUNTIME_SOURCE_FILES to manage them explicitly." + ) + + selected_relative_paths = list(RUNTIME_SYNC_FILES if selected_relative_paths is None else selected_relative_paths) + return { + f"{RUNTIME_TARGET_ROOT}/{relative_path}": _read_text(runtime_source_root / relative_path) + for relative_path in selected_relative_paths + } + + +def _read_sha_marker(repo_root: Path, relative_path: str) -> str | None: + marker_path = repo_root / relative_path + if not marker_path.exists(): + return None + + raw = marker_path.read_text(encoding="utf-8").strip() + return raw or None + + +def _resolve_source_sha(source_root: Path, explicit_source_sha: str | None) -> str: + if explicit_source_sha: + return explicit_source_sha.strip() + + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=source_root, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"Unable to resolve source SHA from {source_root}") + + return result.stdout.strip() + + +def build_expected_manifest_stub() -> dict[str, Any]: + return { + "source_branch": SOURCE_BRANCH, + "source_sha": "", + "managed_root": TOOLKIT_TARGET_ROOT, + "managed_directories": list(TOOLKIT_REQUIRED_DIRECTORIES.values()), + "managed_files": {}, + "runtime_root": RUNTIME_TARGET_ROOT, + "runtime_files": {}, + "runtime_bootstrap_files": {}, + "presence_paths": list(PRESENCE_ONLY_PATHS), + } + + +def build_expected_manifest_from_source( + *, + source_root: Path, + source_sha: str | None = None, + source_branch: str = SOURCE_BRANCH, +) -> dict[str, Any]: + source_root = Path(source_root) + if not source_root.exists(): + raise FileNotFoundError(f"Source root is missing: {source_root}") + + resolved_source_sha = _resolve_source_sha(source_root, source_sha) + toolkit_source_root = source_root / TOOLKIT_SOURCE_DIR + runtime_source_root = source_root / RUNTIME_SOURCE_DIR + if not toolkit_source_root.exists(): + raise FileNotFoundError(f"Toolkit source root is missing: {toolkit_source_root}") + if not runtime_source_root.exists(): + raise FileNotFoundError(f"Runtime source root is missing: {runtime_source_root}") + + managed_files: dict[str, str] = {} + managed_directories = sorted( + f"{TOOLKIT_TARGET_ROOT}/{candidate.name}" + for candidate in toolkit_source_root.iterdir() + if candidate.is_dir() and candidate.name not in HYGIENE_EXCLUDED_FILE_NAMES + ) + + for source_file in sorted(candidate for candidate in toolkit_source_root.rglob("*") if _is_managed_source_file(candidate)): + relative_path = source_file.relative_to(toolkit_source_root).as_posix() + managed_files[f"{TOOLKIT_TARGET_ROOT}/{relative_path}"] = _read_text(source_file) + + managed_files[TOOLKIT_SOURCE_SHA_PATH] = f"{resolved_source_sha}\n" + managed_files[RUNTIME_SOURCE_SHA_PATH] = f"{resolved_source_sha}\n" + runtime_files = load_required_runtime_files_from_source( + source_root=source_root, + selected_relative_paths=RUNTIME_SYNC_FILES, + ) + runtime_bootstrap_files = load_required_runtime_files_from_source( + source_root=source_root, + selected_relative_paths=RUNTIME_BOOTSTRAP_FILES, + ) + + return { + "source_branch": source_branch, + "source_sha": resolved_source_sha, + "managed_root": TOOLKIT_TARGET_ROOT, + "managed_directories": managed_directories, + "managed_files": managed_files, + "runtime_root": RUNTIME_TARGET_ROOT, + "runtime_files": runtime_files, + "runtime_bootstrap_files": runtime_bootstrap_files, + "presence_paths": list(PRESENCE_ONLY_PATHS), + } + + +def build_compliance_checks(*, repo_root: Path) -> dict[str, dict[str, bool]]: + repo_root = Path(repo_root) + + return { + "required_paths": { + "agents_present": _path_present(repo_root, "AGENTS.md"), + "claude_present": _path_present(repo_root, "CLAUDE.md"), + "docs_md_present": _path_present(repo_root, "DOCS.md"), + "constitution_present": _path_present(repo_root, ".specify/memory/constitution.md"), + "quickstart_present": _path_present(repo_root, "docs/quickstart.md"), + }, + "toolkit": { + "root_present": (repo_root / TOOLKIT_TARGET_ROOT).is_dir(), + "source_sha_present": _path_exists(repo_root, TOOLKIT_SOURCE_SHA_PATH), + "runtime_sha_present": _path_exists(repo_root, RUNTIME_SOURCE_SHA_PATH), + "claude_present": (repo_root / TOOLKIT_REQUIRED_DIRECTORIES["claude"]).is_dir(), + "scripts_present": (repo_root / TOOLKIT_REQUIRED_DIRECTORIES["scripts"]).is_dir(), + "workflows_present": (repo_root / TOOLKIT_REQUIRED_DIRECTORIES["workflows"]).is_dir(), + "examples_present": (repo_root / TOOLKIT_REQUIRED_DIRECTORIES["examples"]).is_dir(), + }, + "doctor_runtime": { + "doctor_yml_present": _path_exists(repo_root, f"{RUNTIME_TARGET_ROOT}/doctor.yml"), + }, + } + + +def _iter_relative_entries(repo_root: Path, subtree_root: str) -> dict[str, str]: + root = repo_root / subtree_root + if not root.exists(): + return {} + + entries: dict[str, str] = {} + for candidate in root.rglob("*"): + if _is_hygiene_ignored(candidate): + continue + if candidate.is_file(): + entries[candidate.relative_to(repo_root).as_posix()] = "present" + continue + if candidate.is_symlink() and not candidate.exists(): + entries[candidate.relative_to(repo_root).as_posix()] = "broken_link" + return entries + + +def _compare_expected_files( + *, + repo_root: Path, + expected_files: dict[str, str], + category: str, + change_message_prefix: str, + auto_action: str = "act", + missing_severity: str = "error", + changed_severity: str = "warning", + broken_link_severity: str = "error", +) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + + for relative_path, expected_content in expected_files.items(): + file_state = _path_state(repo_root, relative_path) + if file_state == "missing": + findings.append( + _new_finding( + f"{category}.{_path_to_id(relative_path)}.missing", + category=category, + severity=missing_severity, + path=relative_path, + state="missing", + auto_action=auto_action, + message=f"{change_message_prefix} is missing: {relative_path}", + expected=expected_content, + ) + ) + continue + if file_state == "broken_link": + findings.append( + _new_finding( + f"{category}.{_path_to_id(relative_path)}.broken_link", + category=category, + severity=broken_link_severity, + path=relative_path, + state="broken_link", + auto_action=auto_action, + message=f"{change_message_prefix} is a broken symlink: {relative_path}", + expected=expected_content, + ) + ) + continue + + file_path = repo_root / relative_path + actual_content = file_path.read_text(encoding="utf-8").rstrip("\n") + if actual_content != str(expected_content).rstrip("\n"): + findings.append( + _new_finding( + f"{category}.{_path_to_id(relative_path)}.changed", + category=category, + severity=changed_severity, + path=relative_path, + state="changed", + auto_action=auto_action, + message=f"{change_message_prefix} content differs: {relative_path}", + expected=str(expected_content), + actual=actual_content, + ) + ) + + return findings + + +def scan_repository_files(*, repo_root: Path, expected_manifest: dict[str, Any]) -> dict[str, Any]: + repo_root = Path(repo_root) + managed_files: dict[str, str] = dict(expected_manifest.get("managed_files", {})) + runtime_files: dict[str, str] = dict(expected_manifest.get("runtime_files", {})) + runtime_bootstrap_files: dict[str, str] = dict(expected_manifest.get("runtime_bootstrap_files", {})) + managed_root = str(expected_manifest.get("managed_root") or TOOLKIT_TARGET_ROOT) + presence_paths: list[str] = list(expected_manifest.get("presence_paths", PRESENCE_ONLY_PATHS)) + checks = build_compliance_checks(repo_root=repo_root) + + findings = [ + *_compare_expected_files( + repo_root=repo_root, + expected_files=managed_files, + category="managed", + change_message_prefix="Managed path", + ), + *_compare_expected_files( + repo_root=repo_root, + expected_files=runtime_files, + category="runtime", + change_message_prefix="Doctor runtime path", + ), + *_compare_expected_files( + repo_root=repo_root, + expected_files=runtime_bootstrap_files, + category="observed", + change_message_prefix="Doctor bootstrap", + auto_action="none", + missing_severity="warning", + changed_severity="warning", + broken_link_severity="warning", + ), + ] + + managed_expected_paths = set(managed_files) + managed_actual_entries = _iter_relative_entries(repo_root, managed_root) + for actual_path in sorted(set(managed_actual_entries) - managed_expected_paths): + actual_state = managed_actual_entries[actual_path] + if actual_state == "broken_link": + findings.append( + _new_finding( + f"managed.{_path_to_id(actual_path)}.broken_link", + category="managed", + severity="error", + path=actual_path, + state="broken_link", + auto_action="act", + message=f"Unexpected extra managed path is a broken symlink: {actual_path}", + ) + ) + continue + findings.append( + _new_finding( + f"managed.{_path_to_id(actual_path)}.unexpected_extra", + category="managed", + severity="warning", + path=actual_path, + state="unexpected_extra", + auto_action="act", + message=f"Unexpected extra managed path present: {actual_path}", + ) + ) + + for presence_path in presence_paths: + presence_state = _path_state(repo_root, presence_path) + if presence_state == "present": + continue + if presence_state == "broken_link": + findings.append( + _new_finding( + f"observed.{_path_to_id(presence_path)}.broken_link", + category="observed", + severity="warning", + path=presence_path, + state="broken_link", + auto_action="analyze", + message=f"Required path is a broken symlink: {presence_path}", + ) + ) + continue + findings.append( + _new_finding( + f"observed.{_path_to_id(presence_path)}.missing", + category="observed", + severity="warning", + path=presence_path, + state="missing", + auto_action="analyze", + message=f"Required path is missing: {presence_path}", + ) + ) + + for relative_name in STALE_RUNTIME_TARGET_FILES: + stale_path = f"{RUNTIME_TARGET_ROOT}/{relative_name}" + stale_state = _path_state(repo_root, stale_path) + if stale_state == "missing": + continue + if stale_state == "broken_link": + findings.append( + _new_finding( + f"observed.{_path_to_id(stale_path)}.broken_link", + category="observed", + severity="warning", + path=stale_path, + state="broken_link", + auto_action="none", + message=f"Stale doctor runtime path must be removed manually: {stale_path}", + ) + ) + continue + findings.append( + _new_finding( + f"observed.{_path_to_id(stale_path)}.stale", + category="observed", + severity="warning", + path=stale_path, + state="stale", + auto_action="none", + message=f"Stale doctor runtime path must be removed manually: {stale_path}", + ) + ) + + return { + "checks": checks, + "findings": findings, + "managed_drift_present": any(item["category"] == "managed" for item in findings), + "runtime_drift_present": any(item["category"] == "runtime" for item in findings), + "observed_findings_present": any(item["category"] == "observed" for item in findings), + } + + +def select_act_reason(flags: dict[str, bool]) -> str: + priority = [ + "toolkit_missing", + "source_sha_missing", + "runtime_sha_missing", + "managed_drift", + ] + + for reason in priority: + if flags.get(reason, False): + return reason + if flags.get("operational_error", False): + return "operational_error" + return "healthy" + + +def enrich_commit_record_with_trailers(commit_payload: dict[str, Any]) -> dict[str, Any]: + message = str(commit_payload.get("message", "")) + parse_message = message.replace("\\n", "\n") + + method: str | None = None + agent: str | None = None + co_authored_by: list[str] = [] + refs: list[str] = [] + closes: list[str] = [] + + for line in parse_message.splitlines(): + line = line.strip() + if not line: + continue + + method_match = _TRAILER_PATTERNS["method"].match(line) + if method_match: + method = method_match.group(1).strip() + continue + + agent_match = _TRAILER_PATTERNS["agent"].match(line) + if agent_match: + agent = agent_match.group(1).strip() + continue + + co_author_match = _TRAILER_PATTERNS["co_authored_by"].match(line) + if co_author_match: + co_authored_by.append(co_author_match.group(1).strip()) + continue + + refs_match = _TRAILER_PATTERNS["refs"].match(line) + if refs_match: + refs.append(refs_match.group(1).strip()) + continue + + closes_match = _TRAILER_PATTERNS["closes"].match(line) + if closes_match: + closes.append(closes_match.group(1).strip()) + + return { + "sha": commit_payload.get("sha"), + "author": commit_payload.get("author"), + "authored_at": commit_payload.get("authored_at"), + "message": message, + "method": method, + "agent": agent, + "co_authored_by": co_authored_by, + "refs": refs, + "closes": closes, + } + + +def build_activity_sections( + *, + daily_commits: list[dict[str, Any]], + daily_prs: list[dict[str, Any]], + daily_issues: list[dict[str, Any]], + snapshot_prs: list[dict[str, Any]], + snapshot_issues: list[dict[str, Any]], +) -> dict[str, Any]: + return { + "activity": { + "commits": list(daily_commits), + "prs": list(daily_prs), + "issues": list(daily_issues), + }, + "snapshot": { + "prs": list(snapshot_prs), + "issues": list(snapshot_issues), + }, + } + + +def _build_environment_findings(*, issues_enabled: bool) -> list[dict[str, Any]]: + if issues_enabled: + return [] + + return [ + _new_finding( + "operational.issues_disabled", + category="operational", + severity="warning", + path=None, + state="issues_disabled", + auto_action="none", + message="Issues are disabled in the target repository", + ) + ] + + +def _stringify_bool(value: bool) -> str: + return "true" if value else "false" + + +def _parse_bool(value: str | bool | None, *, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _rfc3339_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _default_window(report_date: str) -> tuple[str, str]: + report_start = datetime.strptime(report_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + window_from = report_start - timedelta(days=1) + return ( + window_from.isoformat().replace("+00:00", "Z"), + report_start.isoformat().replace("+00:00", "Z"), + ) + + +def _load_json_source( + *, + env_var: str, + path_env_var: str, + default: Any, +) -> Any: + path_value = os.getenv(path_env_var) + if path_value: + return json.loads(Path(path_value).read_text(encoding="utf-8")) + + raw_value = os.getenv(env_var) + if raw_value: + return json.loads(raw_value) + + return default + + +def resolve_source_state(*, repo_root: Path, expected_manifest: dict[str, Any]) -> dict[str, Any]: + repo_root = Path(repo_root) + source_branch = str(expected_manifest.get("source_branch") or SOURCE_BRANCH) + source_sha = str(expected_manifest.get("source_sha") or "").strip() + findings: list[dict[str, Any]] = [] + + if not source_sha: + findings.append( + _new_finding( + "operational.source_manifest_unavailable", + category="operational", + severity="error", + path=None, + state="source_manifest_unavailable", + auto_action="none", + message="Collect did not receive a valid source manifest from the configured source ref", + ) + ) + + return { + "source_branch": source_branch, + "source_sha": source_sha, + "installed_source_sha": _read_sha_marker(repo_root, TOOLKIT_SOURCE_SHA_PATH), + "installed_runtime_sha": _read_sha_marker(repo_root, RUNTIME_SOURCE_SHA_PATH), + "fatal_error": not bool(source_sha), + "findings": findings, + } + + +def build_job_outputs( + *, + report_date: str, + source_sha: str | None, + installed_source_sha: str | None, + installed_runtime_sha: str | None, + should_act: bool, + act_reason: str, + issues_enabled: bool, + observed_findings_present: bool, + analyze_day: str | None = None, + report_tz: str | None = None, +) -> dict[str, str]: + outputs = { + "should_act": _stringify_bool(should_act), + "source_sha": source_sha or "", + "installed_source_sha": installed_source_sha or "", + "installed_runtime_sha": installed_runtime_sha or "", + "act_reason": act_reason, + "issues_enabled": _stringify_bool(issues_enabled), + "observed_findings_present": _stringify_bool(observed_findings_present), + "stats_artifact_name": f"doctor-stats-{report_date}", + "findings_artifact_name": f"doctor-findings-{report_date}", + } + + if analyze_day is not None: + outputs["analyze_day"] = analyze_day + if report_tz is not None: + outputs["report_tz"] = report_tz + + return outputs + + +def assemble_collect_result( + *, + repo_root: Path, + expected_manifest: dict[str, Any], + issues_enabled: bool, + repo_full_name: str, + repo_default_branch: str, + workflow_name: str, + workflow_run_id: int, + workflow_run_attempt: int, + workflow_event_name: str, + workflow_actor: str, + source_repo_full_name: str, + analyze_day: str | None, + report_tz: str | None, + collected_at: str, + report_date: str, + window_from: str, + window_to: str, + daily_commits: list[dict[str, Any]] | None = None, + daily_prs: list[dict[str, Any]] | None = None, + daily_issues: list[dict[str, Any]] | None = None, + snapshot_prs: list[dict[str, Any]] | None = None, + snapshot_issues: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + repo_root = Path(repo_root) + source_state = resolve_source_state(repo_root=repo_root, expected_manifest=expected_manifest) + scan_result = scan_repository_files(repo_root=repo_root, expected_manifest=expected_manifest) + environment_findings = _build_environment_findings(issues_enabled=issues_enabled) + + flags = { + "toolkit_missing": not (repo_root / TOOLKIT_TARGET_ROOT).is_dir(), + "source_sha_missing": not _path_exists(repo_root, TOOLKIT_SOURCE_SHA_PATH), + "runtime_sha_missing": not _path_exists(repo_root, RUNTIME_SOURCE_SHA_PATH), + "managed_drift": scan_result["managed_drift_present"], + "operational_error": source_state["fatal_error"], + } + + should_act = any( + flags[name] + for name in ( + "toolkit_missing", + "source_sha_missing", + "runtime_sha_missing", + "managed_drift", + ) + ) + if flags["operational_error"]: + should_act = False + + if should_act: + act_reason = select_act_reason(flags) + elif flags["operational_error"]: + act_reason = "operational_error" + else: + act_reason = "healthy" + + findings = [ + *source_state["findings"], + *scan_result["findings"], + *environment_findings, + ] + + normalized_commits = [ + enrich_commit_record_with_trailers(commit) + if any(field not in commit for field in ("method", "agent", "co_authored_by", "refs", "closes")) + else dict(commit) + for commit in (daily_commits or []) + ] + activity_sections = build_activity_sections( + daily_commits=normalized_commits, + daily_prs=list(daily_prs or []), + daily_issues=list(daily_issues or []), + snapshot_prs=list(snapshot_prs or []), + snapshot_issues=list(snapshot_issues or []), + ) + + job_outputs = build_job_outputs( + report_date=report_date, + source_sha=source_state["source_sha"], + installed_source_sha=source_state["installed_source_sha"], + installed_runtime_sha=source_state["installed_runtime_sha"], + should_act=should_act, + act_reason=act_reason, + issues_enabled=issues_enabled, + observed_findings_present=scan_result["observed_findings_present"], + analyze_day=analyze_day, + report_tz=report_tz, + ) + + contract_payload = { + "managed_root": str(expected_manifest.get("managed_root") or TOOLKIT_TARGET_ROOT), + "managed_directories": list( + expected_manifest.get("managed_directories", TOOLKIT_REQUIRED_DIRECTORIES.values()) + ), + "runtime_root": str(expected_manifest.get("runtime_root") or RUNTIME_TARGET_ROOT), + "runtime_files": sorted(expected_manifest.get("runtime_files", {}).keys()), + "presence_paths": list(expected_manifest.get("presence_paths", PRESENCE_ONLY_PATHS)), + } + + stats_payload: dict[str, Any] | None = None + if not source_state["fatal_error"]: + stats_payload = { + "schema_version": "doctor-stats/v3", + "collected_at": collected_at, + "report_date": report_date, + "window": { + "from": window_from, + "to": window_to, + }, + "repo": { + "full_name": repo_full_name, + "default_branch": repo_default_branch, + }, + "workflow": { + "name": workflow_name, + "run_id": workflow_run_id, + "run_attempt": workflow_run_attempt, + "event_name": workflow_event_name, + "actor": workflow_actor, + }, + "source_repo": { + "full_name": source_repo_full_name, + "source_branch": source_state["source_branch"], + "source_sha": source_state["source_sha"], + }, + "toolkit": { + "installed_source_sha": source_state["installed_source_sha"], + }, + "doctor_runtime": { + "installed_source_sha": source_state["installed_runtime_sha"], + }, + "contract": contract_payload, + **activity_sections, + } + + findings_payload = { + "schema_version": "doctor-findings/v4", + "collected_at": collected_at, + "report_date": report_date, + "repo": { + "full_name": repo_full_name, + "default_branch": repo_default_branch, + }, + "source_repo": { + "full_name": source_repo_full_name, + "source_branch": source_state["source_branch"], + "source_sha": source_state["source_sha"], + }, + "toolkit": { + "installed_source_sha": source_state["installed_source_sha"], + }, + "doctor_runtime": { + "installed_source_sha": source_state["installed_runtime_sha"], + }, + "decision": { + "should_act": should_act, + "act_reason": act_reason, + "managed_drift_present": scan_result["managed_drift_present"], + "runtime_drift_present": scan_result["runtime_drift_present"], + "observed_findings_present": scan_result["observed_findings_present"], + }, + "contract": contract_payload, + "checks": scan_result["checks"], + "findings": findings, + } + + return { + "fatal_error": source_state["fatal_error"], + "job_outputs": job_outputs, + "stats_payload": stats_payload, + "findings_payload": findings_payload, + } + + +def _write_json_file(path: Path, payload: dict[str, Any]) -> None: + path.write_text(f"{json.dumps(payload, indent=2)}\n", encoding="utf-8") + + +def _write_github_output(outputs: dict[str, str]) -> None: + github_output = os.getenv("GITHUB_OUTPUT") + if not github_output: + return + + with Path(github_output).open("a", encoding="utf-8") as handle: + for key, value in outputs.items(): + handle.write(f"{key}={value}\n") + + +def main() -> int: + repo_root = Path(os.getenv("DOCTOR_REPO_ROOT", ".")).resolve() + output_dir = Path(os.getenv("DOCTOR_OUTPUT_DIR", ".")).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + collected_at = os.getenv("DOCTOR_COLLECTED_AT", _rfc3339_now()) + report_date = os.getenv("DOCTOR_REPORT_DATE", collected_at[:10]) + default_window_from, default_window_to = _default_window(report_date) + + source_root = os.getenv("DOCTOR_SOURCE_ROOT") + if source_root: + expected_manifest = build_expected_manifest_from_source( + source_root=Path(source_root).resolve(), + source_sha=os.getenv("DOCTOR_SOURCE_SHA") or None, + source_branch=os.getenv("DOCTOR_SOURCE_BRANCH", SOURCE_BRANCH), + ) + else: + expected_manifest = _load_json_source( + env_var="DOCTOR_EXPECTED_MANIFEST", + path_env_var="DOCTOR_EXPECTED_MANIFEST_PATH", + default={}, + ) + + activity_payload = _load_json_source( + env_var="DOCTOR_ACTIVITY", + path_env_var="DOCTOR_ACTIVITY_PATH", + default={}, + ) + + result = assemble_collect_result( + repo_root=repo_root, + expected_manifest=expected_manifest, + issues_enabled=_parse_bool(os.getenv("DOCTOR_ISSUES_ENABLED"), default=True), + repo_full_name=os.getenv("DOCTOR_REPO_FULL_NAME", os.getenv("GITHUB_REPOSITORY", "")), + repo_default_branch=os.getenv("DOCTOR_REPO_DEFAULT_BRANCH", "main"), + workflow_name=os.getenv("GITHUB_WORKFLOW", "doctor"), + workflow_run_id=int(os.getenv("GITHUB_RUN_ID", "0")), + workflow_run_attempt=int(os.getenv("GITHUB_RUN_ATTEMPT", "0")), + workflow_event_name=os.getenv("GITHUB_EVENT_NAME", "workflow_dispatch"), + workflow_actor=os.getenv("GITHUB_ACTOR", ""), + source_repo_full_name=os.getenv("DOCTOR_SOURCE_REPO", "G-Core/agent-toolkit"), + analyze_day=os.getenv("DOCTOR_ANALYZE_DAY"), + report_tz=os.getenv("DOCTOR_REPORT_TZ"), + collected_at=collected_at, + report_date=report_date, + window_from=os.getenv("DOCTOR_WINDOW_FROM", default_window_from), + window_to=os.getenv("DOCTOR_WINDOW_TO", default_window_to), + daily_commits=activity_payload.get("commits", []), + daily_prs=activity_payload.get("prs", []), + daily_issues=activity_payload.get("issues", []), + snapshot_prs=activity_payload.get("snapshot_prs", []), + snapshot_issues=activity_payload.get("snapshot_issues", []), + ) + + findings_path = output_dir / "doctor-findings.json" + _write_json_file(findings_path, result["findings_payload"]) + + if result["stats_payload"] is not None: + _write_json_file(output_dir / "doctor-stats.json", result["stats_payload"]) + + _write_github_output(result["job_outputs"]) + return 1 if result["fatal_error"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/copilot-sync.yml b/.github/workflows/copilot-sync.yml new file mode 100644 index 0000000..c4ca5e4 --- /dev/null +++ b/.github/workflows/copilot-sync.yml @@ -0,0 +1,20 @@ +name: Copilot Instructions Sync + +on: + pull_request: + paths: + - fastedge-plugin-source/manifest.json + - fastedge-plugin-source/check-copilot-sync.sh + - .github/copilot-instructions.md + - .github/workflows/copilot-sync.yml + +jobs: + check-sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Check manifest ↔ copilot-instructions sync + run: bash fastedge-plugin-source/check-copilot-sync.sh diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 013f0b0..b733ef3 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -4,7 +4,7 @@ on: push: branches: ['main'] paths: - - 'docs/**' + - 'github-pages/**' workflow_dispatch: # trigger manually @@ -18,7 +18,7 @@ concurrency: cancel-in-progress: false env: - BUILD_PATH: './docs' + BUILD_PATH: './github-pages' jobs: build: diff --git a/.github/workflows/doctor-analyze.lock.yml b/.github/workflows/doctor-analyze.lock.yml new file mode 100644 index 0000000..2ce1161 --- /dev/null +++ b/.github/workflows/doctor-analyze.lock.yml @@ -0,0 +1,1807 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.44.0). DO NOT EDIT. +# +# To update this file, run: +# python3 workflows/build_doctor_analyze_lock.py +# This tracked lock is generated by gh aw compile and then post-processed. +# Do not edit this file manually. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# +# frontmatter-hash: 26ed66316fc76b55ddfd8e9b55e3ebebba4ee98bbbea3e8d33c3f45a23ed4a52 + +name: "doctor-analyze" +"on": + workflow_call: + inputs: + analyze_day: + required: true + type: string + findings_artifact_name: + required: true + type: string + force_analyze: + required: false + type: boolean + installed_runtime_sha: + required: false + type: string + installed_source_sha: + required: false + type: string + issues_enabled: + required: true + type: string + observed_findings_present: + required: true + type: string + report_tz: + required: true + type: string + source_sha: + required: false + type: string + stats_artifact_name: + required: true + type: string + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}" + +run-name: "doctor-analyze" + +jobs: + activation: + needs: pre_activation + if: needs.pre_activation.outputs.activated == 'true' + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0 + with: + destination: /opt/gh-aw/actions + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + issues: read + pull-requests: read + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_WORKFLOW_ID_SANITIZED: doctoranalyze + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + model: ${{ steps.generate_aw_info.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0 + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Download doctor-stats artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: ${{ inputs.stats_artifact_name }} + path: /tmp/gh-aw/collect-artifacts/stats + - name: Download doctor-findings artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: ${{ inputs.findings_artifact_name }} + path: /tmp/gh-aw/collect-artifacts/findings + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "claude", + engine_name: "Claude Code", + model: process.env.GH_AW_MODEL_AGENT_CLAUDE || "", + version: "", + agent_version: "2.1.42", + cli_version: "v0.44.0", + workflow_name: "doctor-analyze", + experimental: false, + supports_tools_allowlist: true, + supports_http_transport: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults"], + firewall_enabled: true, + awf_version: "v0.18.0", + awmg_version: "v0.1.4", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.18.0 + - name: Install Claude Code CLI + run: npm install -g --silent @anthropic-ai/claude-code@2.1.42 + - name: Determine automatic lockdown mode for GitHub MCP server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.18.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.18.0 ghcr.io/github/gh-aw-firewall/squid:0.18.0 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine + - name: Write Safe Outputs Config + run: | + mkdir -p /opt/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' + {"add_comment":{"max":1},"close_issue":{"max":1},"create_issue":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1},"update_issue":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' + [ + { + "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", + "type": "string" + }, + "labels": { + "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "parent": { + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", + "type": [ + "number", + "string" + ] + }, + "temporary_id": { + "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", + "pattern": "^aw_[A-Za-z0-9]{4,8}$", + "type": "string" + }, + "title": { + "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "create_issue" + }, + { + "description": "Close a GitHub issue with a closing comment. You can and should always add a comment when closing an issue to explain the action or provide context. This tool is ONLY for closing issues - use update_issue if you need to change the title, body, labels, or other metadata without closing. Use close_issue when work is complete, the issue is no longer relevant, or it's a duplicate. The closing comment should explain the resolution or reason for closing. If the issue is already closed, a comment will still be posted. CONSTRAINTS: Maximum 1 issue(s) can be closed.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Closing comment explaining why the issue is being closed and summarizing any resolution, workaround, or conclusion.", + "type": "string" + }, + "issue_number": { + "description": "Issue number to close. This is the numeric ID from the GitHub URL (e.g., 901 in github.com/owner/repo/issues/901). If omitted, closes the issue that triggered this workflow (requires an issue event trigger).", + "type": [ + "number", + "string" + ] + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "name": "close_issue" + }, + { + "description": "Add a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. CONSTRAINTS: Maximum 1 comment(s) can be added.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "The comment text in Markdown format. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation.", + "type": "string" + }, + "item_number": { + "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). If omitted, the tool will attempt to resolve the target from the current workflow context (triggering issue, PR, or discussion).", + "type": "number" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "name": "add_comment" + }, + { + "description": "Update an existing GitHub issue's title, body, labels, assignees, or milestone WITHOUT closing it. This tool is primarily for editing issue metadata and content. While it supports changing status between 'open' and 'closed', use close_issue instead when you want to close an issue with a closing comment. Body updates support replacing, appending to, prepending content, or updating a per-run \"island\" section. CONSTRAINTS: Maximum 1 issue(s) can be updated.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "assignees": { + "description": "Replace the issue assignees with this list of GitHub usernames (e.g., ['octocat', 'mona']).", + "items": { + "type": "string" + }, + "type": "array" + }, + "body": { + "description": "Issue body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this content is added with a separator and an attribution footer. For 'replace-island', only the run-specific section is updated.", + "type": "string" + }, + "issue_number": { + "description": "Issue number to update. This is the numeric ID from the GitHub URL (e.g., 789 in github.com/owner/repo/issues/789). Required when the workflow target is '*' (any issue).", + "type": [ + "number", + "string" + ] + }, + "labels": { + "description": "Replace the issue labels with this list (e.g., ['bug', 'tracking:foo']). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "milestone": { + "description": "Milestone number to assign (e.g., 1). Use null to clear.", + "type": [ + "number", + "string" + ] + }, + "operation": { + "description": "How to update the issue body: 'append' (default - add to end with separator), 'prepend' (add to start with separator), 'replace' (overwrite entire body), or 'replace-island' (update a run-specific section).", + "enum": [ + "replace", + "append", + "prepend", + "replace-island" + ], + "type": "string" + }, + "status": { + "description": "New issue status: 'open' to reopen a closed issue, 'closed' to close an open issue.", + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "title": { + "description": "New issue title to replace the existing title.", + "type": "string" + } + }, + "type": "object" + }, + "name": "update_issue" + }, + { + "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "reason": { + "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", + "type": "string" + }, + "tool": { + "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "name": "missing_tool" + }, + { + "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "name": "noop" + }, + { + "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "context": { + "description": "Additional context about the missing data or where it should come from (max 256 characters).", + "type": "string" + }, + "data_type": { + "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", + "type": "string" + }, + "reason": { + "description": "Explanation of why this data is needed to complete the task (max 256 characters).", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "missing_data" + } + ] + GH_AW_SAFE_OUTPUTS_TOOLS_EOF + cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + } + } + }, + "close_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "issue_number": { + "optionalPositiveInteger": true + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "update_issue": { + "defaultMax": 1, + "fields": { + "body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "issue_number": { + "issueOrPRNumber": true + }, + "status": { + "type": "string", + "enum": [ + "open", + "closed" + ] + }, + "title": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + }, + "customValidation": "requiresOneOf:status,title,body" + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash /opt/gh-aw/actions/start_safe_outputs_server.sh + + - name: Start MCP gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="claude" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4' + + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "container": "ghcr.io/github/github-mcp-server:v0.30.3", + "env": { + "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", + "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "$GH_AW_SAFE_OUTPUTS_API_KEY" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT" + cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT" + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GitHub API Access Instructions + + The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations. + + + To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. + + Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body). + + **IMPORTANT - temporary_id format rules:** + - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed) + - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i + - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive) + - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 + - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore) + - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678 + - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate + + Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i. + + Discover available tools from the safeoutputs MCP server. + + **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. + + **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed. + + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + The following workflow call inputs are available for this workflow: + - **issues_enabled**: __GH_AW_INPUTS_ISSUES_ENABLED__ + - **observed_findings_present**: __GH_AW_INPUTS_OBSERVED_FINDINGS_PRESENT__ + - **stats_artifact_name**: __GH_AW_INPUTS_STATS_ARTIFACT_NAME__ + - **findings_artifact_name**: __GH_AW_INPUTS_FINDINGS_ARTIFACT_NAME__ + - **source_sha**: __GH_AW_INPUTS_SOURCE_SHA__ + - **installed_source_sha**: __GH_AW_INPUTS_INSTALLED_SOURCE_SHA__ + - **installed_runtime_sha**: __GH_AW_INPUTS_INSTALLED_RUNTIME_SHA__ + - **analyze_day**: __GH_AW_INPUTS_ANALYZE_DAY__ + - **report_tz**: __GH_AW_INPUTS_REPORT_TZ__ + - **force_analyze**: __GH_AW_INPUTS_FORCE_ANALYZE__ + - **run_id**: __GH_AW_GITHUB_RUN_ID__ + - **actor**: __GH_AW_GITHUB_ACTOR__ + - **workflow-run-url**: __GH_AW_GITHUB_SERVER_URL__/__GH_AW_GITHUB_REPOSITORY__/actions/runs/__GH_AW_GITHUB_RUN_ID__ + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT" + # Doctor Analyze + + ## TL;DR + + Read-only analyze job for the `doctor` workflow. Downloads `collect` artifacts, + validates findings, and manages a deterministic GitHub issue for observed drift. + Writes `doctor-report.json` as the only persistent output. + + You are the `analyze` job for the repository described in the `` block above. + This workflow is called only from `doctor.yml` and must stay read-only for repository files. + Never create PRs. Never edit tracked repository files. The only persistent outputs you may write are: + + - `doctor-report.json` in the workspace root + - mirrored copies of the same report under `/tmp/gh-aw/agent/` so the + current compiled workflow uploads them via generic agent artifacts + + Temporary scratch files are allowed only if they are needed to build or validate the report. + + ## Runtime Context + + Treat the `` block from the system prompt as the runtime contract from `collect`. + It must provide these keys: + + - `issues_enabled` + - `observed_findings_present` + - `stats_artifact_name` + - `findings_artifact_name` + - `source_sha` + - `installed_source_sha` + - `installed_runtime_sha` + - `analyze_day` + - `report_tz` + - `force_analyze` + - `run_id` + - `actor` + - `workflow_run_url` + + Read `run_attempt` from env var `GITHUB_RUN_ATTEMPT` via Bash. + Read `event_name` from env var `GITHUB_EVENT_NAME` via Bash. + If the `` block is missing any required key, record + that in `runtime_notes[]`, write `doctor-report.json` with + `issue.reason = "model_error"`, and exit with failure. + + Use `report_tz` to derive: + + - `report_date` in `YYYY-MM-DD` + - `today_name` as a lowercase English weekday name + - `analyze_day_match = (today_name == analyze_day.lower())` + - `manual_override = (force_analyze == true)` + + ## Operating Rules + + - Use GitHub MCP tools only for read operations: artifacts, issues, labels, workflow metadata, repository contents. + - Use safe outputs only for issue mutations: `create-issue`, `update-issue`, `close-issue`, `add-comment`. + - Never perform direct write mutations through bash, git, or raw GitHub API calls. + - Prefer GitHub MCP tools for workflow runs, artifacts, issues, and labels. + - If workflow artifact MCP tools are unavailable in the exposed tool list, + you may use `Bash` with authenticated GitHub API calls for artifact lookup + and download only. + - Never use unauthenticated `gh` or unauthenticated `curl` for GitHub API access. + - For authenticated fallback in Bash, export + `GH_TOKEN="$GITHUB_TOKEN"` before `gh api`, or use `curl` with header + `Authorization: Bearer $GITHUB_TOKEN`. + - `collect` is the source of truth for findings and severities. Do not invent new finding IDs or new finding categories. + - Limit issue-generating analysis to findings where: + - `category == observed` + - `auto_action == analyze` + - `path` is one of: + - `AGENTS.md` + - `CLAUDE.md` + - `DOCS.md` + - `.specify/memory/constitution.md` + - `docs/quickstart.md` + - You may read repository files to improve recommendations and evidence, + but not to expand the scope beyond the allowed observed-only paths. + + ## Required Procedure + + 1. Start by creating a scratch work directory for downloaded artifacts and intermediate notes. + 2. Compute `report_date`, `today_name`, `analyze_day_match`, and `manual_override`. + 3. Initialize `runtime_notes[]` and a draft `doctor-report.json` object. + + ### Runtime Gate + + Apply gates in this order: + + 1. If `observed_findings_present != "true"`: + - write `doctor-report.json` + - set: + - `decision.observed_findings_present = false` + - `decision.qualifying_findings_present = false` + - `issue.action = "none"` + - `issue.reason = "no_observed_findings"` + - do not download artifacts + - do not call safe outputs + - exit successfully + 2. If `manual_override` is false and `analyze_day_match` is false: + - write `doctor-report.json` + - set: + - `decision.observed_findings_present = true` + - `decision.qualifying_findings_present = false` + - `issue.action = "skipped"` + - `issue.reason = "not_analyze_day"` + - do not call safe outputs + - exit successfully + 3. Otherwise continue to artifact download and analysis. + + ## Artifact Download And Validation + + Download artifacts from the current workflow run identified in + `` using the exact artifact names from `collect`: + + - the `stats_artifact_name` value from `` + - the `findings_artifact_name` value from `` + - the predownloaded local files, when present: + - `/tmp/gh-aw/collect-artifacts/stats/doctor-stats.json` + - `/tmp/gh-aw/collect-artifacts/findings/doctor-findings.json` + + Required procedure for artifact access: + + 1. First check the predownloaded local files under + `/tmp/gh-aw/collect-artifacts/`. If both required files are present, use + them as the primary source and do not attempt network artifact lookup. + 2. If either local file is missing, prefer GitHub MCP workflow run and artifact tools when they are available. + 3. If the matching collect artifacts are not listed yet, wait 2 seconds and retry once using the same access path. + 4. If workflow artifact MCP tools are unavailable, use authenticated Bash fallback: + - `GH_TOKEN="$GITHUB_TOKEN" gh api ...` + - or `curl -H "Authorization: Bearer $GITHUB_TOKEN" ...` + 5. Only use workspace or runner filesystem search as a secondary diagnostic + note after both the predownloaded local files and the authenticated + access paths fail. + 6. Do not use unauthenticated `gh api`, unauthenticated `curl`, or raw REST calls without `GITHUB_TOKEN`. + + Expect these files: + + - `doctor-stats.json` + - `doctor-findings.json` + + Validate both artifacts before analysis. Treat any missing file, invalid + JSON, missing required field, wrong schema version, or type mismatch as + `artifact_invalid`. + + Minimum required validation for `doctor-stats.json`: + + - `schema_version == "doctor-stats/v3"` + - `collected_at` + - `report_date` + - `window.from` + - `window.to` + - `repo.full_name` + - `repo.default_branch` + - `workflow.run_id` + - `workflow.run_attempt` + - `workflow.event_name` + - `workflow.actor` + - `source_repo.full_name` + - `source_repo.source_branch` + - `source_repo.source_sha` + - `activity.commits` + - `activity.prs` + - `activity.issues` + - `snapshot.prs` + - `snapshot.issues` + + Minimum required validation for `doctor-findings.json`: + + - `schema_version == "doctor-findings/v4"` + - `collected_at` + - `report_date` + - `repo.full_name` + - `repo.default_branch` + - `source_repo.source_branch` + - `source_repo.source_sha` + - `decision.should_act` + - `decision.act_reason` + - `decision.managed_drift_present` + - `decision.runtime_drift_present` + - `decision.observed_findings_present` + - `findings[]` + + For every analyzed finding, require: + + - `id` + - `category` + - `severity` + - `path` + - `state` + - `auto_action` + - `message` + + If artifact validation fails: + + - write `doctor-report.json` + - set: + - `issue.action = "failed"` + - `issue.reason = "artifact_invalid"` + - add a precise explanation to `runtime_notes[]` + - do not call safe outputs + - exit with failure + + ## Analysis Scope + + Analyze only findings from `doctor-findings.json` where: + + - `category == "observed"` + - `auto_action == "analyze"` + + Exclude from issue generation: + + - `managed` + - `runtime` + - `operational` + - observed checks outside the allowed path scope + + Keep `info` findings in `analysis[]`, but they do not qualify for an issue. + Qualifying findings are only severities: + + - `warning` + - `error` + + ## Analysis Rules + + For each in-scope observed finding: + + 1. Preserve the original `finding_id`, `path`, `severity`, `state`, and `message`. + 2. Read the current repository path when it exists and use that only as supporting evidence. + 3. Produce one `analysis[]` item with: + - `finding_id` + - `category = "observed"` + - `path` + - `severity` + - `state` + - `qualifies_for_issue` + - `summary` + - `recommendation` + - `evidence[]` + + Use these guidance rules: + + - `AGENTS.md` missing: + - explain that the repo is missing the agent entry point + - recommend restoring the file or a valid symlink in the root + - `CLAUDE.md` missing: + - explain that the repo is missing the Claude-facing entry point + - recommend restoring the file or a valid symlink in the root + - `DOCS.md` missing: + - explain that the repo is missing the main docs entry point in the root + - recommend restoring the file or a valid symlink in the root + - `docs/quickstart.md` missing or placeholder: + - recommend a real first-run or onboarding guide + - `.specify/memory/constitution.md` missing or placeholder: + - recommend restoring and tracking a real working agreement + + When files exist but appear placeholder-like or incomplete, use concrete evidence such as: + + - obvious placeholder markers: `TODO`, `TBD`, `placeholder`, `coming soon`, `lorem ipsum`, `template` + - extremely short content with no actionable project-specific instructions + + Do not create duplicate recommendations for the same path family. Group repeated ideas. + + ## Issue Policy + + Deterministic issue title: + + - `Toolkit doctor: observed drift` + + Preferred labels when available: + + - `toolkit` + - `doctor` + + Issue body must contain these sections: + + - `## Summary` + - `## Findings` + - `## Recommendations` + - `## Context` + + Issue body requirements: + + - concise summary of current qualifying warnings and errors + - bullet list or table of qualifying findings with: + - path + - severity + - message + - short grouped recommendations + - link to the `workflow_run_url` value from `` + - short activity context from `doctor-stats.json` only if it clearly helps prioritization + + Use read-only GitHub tools to find an existing open issue with the exact title `Toolkit doctor: observed drift`. + Do not create duplicates. + + Before issue mutation: + + 1. Check whether labels `toolkit` and `doctor` exist. + 2. If one or both labels are missing: + - continue without the missing labels + - record the problem in `runtime_notes[]` + 3. If an existing deterministic issue is locked: + - write `doctor-report.json` + - set: + - `issue.action = "failed"` + - `issue.reason = "issue_locked"` + - do not create a duplicate + - exit with failure + + ## Issue Lifecycle + + After analysis, compute: + + - `findings_analyzed` + - `qualifying_findings` + - `warning_count` + - `error_count` + - `qualifying_findings_present` + + Then apply this lifecycle: + + 1. If `qualifying_findings_present == false`: + - if an open deterministic issue exists: + - add a resolution comment that observed drift is no longer present in the latest analyze run + - close that issue + - set: + - `issue.action = "closed"` + - `issue.reason = "closed_resolved"` + - otherwise: + - set: + - `issue.action = "none"` + - `issue.reason = "no_qualifying_findings"` + 2. If `qualifying_findings_present == true` and `issues_enabled != "true"`: + - perform the full analysis + - do not create, update, close, or comment on issues + - set: + - `issue.action = "skipped"` + - `issue.reason = "issues_disabled"` + - if an existing deterministic issue exists, keep its metadata in the report but do not mutate it + 3. If `qualifying_findings_present == true` and no open deterministic issue exists: + - create one issue through `create-issue` + - set: + - `issue.action = "created"` + - `issue.reason = "created_new"` + 4. If `qualifying_findings_present == true` and an open deterministic issue exists: + - update that exact issue through `update-issue` + - set: + - `issue.action = "updated"` + - `issue.reason = "updated_existing"` + + Never use direct GitHub write APIs for issues. Safe outputs only. + + ## doctor-report.json + + Always write `doctor-report.json` before exiting, including skip and failure paths. + + Write the same final JSON payload to all of these paths: + + - `./doctor-report.json` + - `/tmp/gh-aw/agent/doctor-report.json` + - `/tmp/gh-aw/agent/doctor-report-${report_date}.json` + + This is a temporary workaround until the compiled reusable workflow gets a + dedicated `upload-artifact` step for a `doctor-report-YYYY-MM-DD` artifact. + + Required top-level schema: + + ```json + { + "schema_version": "doctor-report/v1", + "analyzed_at": "RFC3339", + "report_date": "YYYY-MM-DD", + "repo": { + "full_name": "owner/repo" + }, + "workflow": { + "run_id": 0, + "run_attempt": 0, + "event_name": "workflow_dispatch|schedule|...", + "actor": "github-user" + }, + "inputs": { + "stats_artifact_name": "doctor-stats-YYYY-MM-DD", + "findings_artifact_name": "doctor-findings-YYYY-MM-DD" + }, + "decision": { + "observed_findings_present": true, + "issues_enabled": true, + "analyze_day_match": true, + "manual_override": false, + "qualifying_findings_present": true + }, + "issue": { + "action": "created|updated|closed|skipped|none|failed", + "reason": "created_new|updated_existing|closed_resolved|issues_disabled|not_analyze_day|no_observed_findings|no_qualifying_findings|artifact_invalid|model_error|issue_locked", + "number": 0, + "url": "https://github.com/owner/repo/issues/123", + "title": "Toolkit doctor: observed drift" + }, + "summary": { + "findings_analyzed": 0, + "qualifying_findings": 0, + "warning_count": 0, + "error_count": 0 + }, + "runtime_notes": [], + "analysis": [ + { + "finding_id": "observed.docs_quickstart_md.missing", + "category": "observed", + "path": "docs/quickstart.md", + "severity": "warning", + "state": "missing", + "qualifies_for_issue": true, + "summary": "Repository is missing the onboarding quickstart.", + "recommendation": "Add a real quickstart with first-run instructions for contributors.", + "evidence": [ + "doctor-findings.json: observed.docs_quickstart_md.missing", + "docs/quickstart.md is absent in the checked out repository" + ] + } + ] + } + ``` + + Populate `issue.number`, `issue.url`, and `issue.title` with `null` when absent. + `analysis[]` must include every analyzed observed finding, including + `info`, with `qualifies_for_issue` set appropriately. + + ## Failure Handling + + If you encounter a temporary tool or execution error while downloading + artifacts, reading repository context, or building issue text: + + 1. retry once + 2. if the retry succeeds, continue + 3. if the retry fails, write `doctor-report.json` with: + - `issue.action = "failed"` + - `issue.reason = "model_error"` + 4. add a precise note to `runtime_notes[]` + 5. exit with failure + + For all failure paths: + + - prefer a valid `doctor-report.json` over incomplete changes + - do not create duplicate issues + - do not mutate repository files + + ## Final Checklist + + Before finishing, verify all of the following: + + - `doctor-report.json` exists and is valid JSON + - the mirrored report copies exist in `/tmp/gh-aw/agent/` + - `schema_version == "doctor-report/v1"` + - issue decisions use only the allowed enums + - safe outputs, if any, were used only after artifact validation and only when allowed by runtime gates + - no repository files were modified other than `doctor-report.json` and temporary scratch files + GH_AW_PROMPT_EOF + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_GITHUB_SERVER_URL: ${{ github.server_url }} + GH_AW_INPUTS_ANALYZE_DAY: ${{ inputs.analyze_day }} + GH_AW_INPUTS_FINDINGS_ARTIFACT_NAME: ${{ inputs.findings_artifact_name }} + GH_AW_INPUTS_FORCE_ANALYZE: ${{ inputs.force_analyze }} + GH_AW_INPUTS_INSTALLED_RUNTIME_SHA: ${{ inputs.installed_runtime_sha }} + GH_AW_INPUTS_INSTALLED_SOURCE_SHA: ${{ inputs.installed_source_sha }} + GH_AW_INPUTS_ISSUES_ENABLED: ${{ inputs.issues_enabled }} + GH_AW_INPUTS_OBSERVED_FINDINGS_PRESENT: ${{ inputs.observed_findings_present }} + GH_AW_INPUTS_REPORT_TZ: ${{ inputs.report_tz }} + GH_AW_INPUTS_SOURCE_SHA: ${{ inputs.source_sha }} + GH_AW_INPUTS_STATS_ARTIFACT_NAME: ${{ inputs.stats_artifact_name }} + with: + script: | + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_GITHUB_SERVER_URL: process.env.GH_AW_GITHUB_SERVER_URL, + GH_AW_INPUTS_ANALYZE_DAY: process.env.GH_AW_INPUTS_ANALYZE_DAY, + GH_AW_INPUTS_FINDINGS_ARTIFACT_NAME: process.env.GH_AW_INPUTS_FINDINGS_ARTIFACT_NAME, + GH_AW_INPUTS_FORCE_ANALYZE: process.env.GH_AW_INPUTS_FORCE_ANALYZE, + GH_AW_INPUTS_INSTALLED_RUNTIME_SHA: process.env.GH_AW_INPUTS_INSTALLED_RUNTIME_SHA, + GH_AW_INPUTS_INSTALLED_SOURCE_SHA: process.env.GH_AW_INPUTS_INSTALLED_SOURCE_SHA, + GH_AW_INPUTS_ISSUES_ENABLED: process.env.GH_AW_INPUTS_ISSUES_ENABLED, + GH_AW_INPUTS_OBSERVED_FINDINGS_PRESENT: process.env.GH_AW_INPUTS_OBSERVED_FINDINGS_PRESENT, + GH_AW_INPUTS_REPORT_TZ: process.env.GH_AW_INPUTS_REPORT_TZ, + GH_AW_INPUTS_SOURCE_SHA: process.env.GH_AW_INPUTS_SOURCE_SHA, + GH_AW_INPUTS_STATS_ARTIFACT_NAME: process.env.GH_AW_INPUTS_STATS_ARTIFACT_NAME, + } + }); + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute Claude Code CLI + id: agentic_execution + # Allowed tools (sorted): + # - Bash + # - BashOutput + # - Edit + # - ExitPlanMode + # - Glob + # - Grep + # - KillBash + # - LS + # - MultiEdit + # - NotebookEdit + # - NotebookRead + # - Read + # - Task + # - TodoWrite + # - Write + # - mcp__github__download_workflow_run_artifact + # - mcp__github__get_code_scanning_alert + # - mcp__github__get_commit + # - mcp__github__get_dependabot_alert + # - mcp__github__get_discussion + # - mcp__github__get_discussion_comments + # - mcp__github__get_file_contents + # - mcp__github__get_job_logs + # - mcp__github__get_label + # - mcp__github__get_latest_release + # - mcp__github__get_me + # - mcp__github__get_notification_details + # - mcp__github__get_pull_request + # - mcp__github__get_pull_request_comments + # - mcp__github__get_pull_request_diff + # - mcp__github__get_pull_request_files + # - mcp__github__get_pull_request_review_comments + # - mcp__github__get_pull_request_reviews + # - mcp__github__get_pull_request_status + # - mcp__github__get_release_by_tag + # - mcp__github__get_secret_scanning_alert + # - mcp__github__get_tag + # - mcp__github__get_workflow_run + # - mcp__github__get_workflow_run_logs + # - mcp__github__get_workflow_run_usage + # - mcp__github__issue_read + # - mcp__github__list_branches + # - mcp__github__list_code_scanning_alerts + # - mcp__github__list_commits + # - mcp__github__list_dependabot_alerts + # - mcp__github__list_discussion_categories + # - mcp__github__list_discussions + # - mcp__github__list_issue_types + # - mcp__github__list_issues + # - mcp__github__list_label + # - mcp__github__list_notifications + # - mcp__github__list_pull_requests + # - mcp__github__list_releases + # - mcp__github__list_secret_scanning_alerts + # - mcp__github__list_starred_repositories + # - mcp__github__list_tags + # - mcp__github__list_workflow_jobs + # - mcp__github__list_workflow_run_artifacts + # - mcp__github__list_workflow_runs + # - mcp__github__list_workflows + # - mcp__github__pull_request_read + # - mcp__github__search_code + # - mcp__github__search_issues + # - mcp__github__search_orgs + # - mcp__github__search_pull_requests + # - mcp__github__search_repositories + # - mcp__github__search_users + timeout-minutes: 20 + run: | + set -o pipefail + sudo -E awf --tty --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.18.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && claude --print --disable-slash-commands --no-chrome --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_job_logs,mcp__github__get_label,mcp__github__get_latest_release,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__issue_read,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__pull_request_read,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users --debug-file /tmp/gh-aw/agent-stdio.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_CLAUDE:+ --model "$GH_AW_MODEL_AGENT_CLAUDE"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + BASH_DEFAULT_TIMEOUT_MS: 60000 + BASH_MAX_TIMEOUT_MS: 60000 + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + DISABLE_BUG_COMMAND: 1 + DISABLE_ERROR_REPORTING: 1 + DISABLE_TELEMETRY: 1 + GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json + GH_AW_MODEL_AGENT_CLAUDE: ${{ vars.GH_AW_MODEL_AGENT_CLAUDE || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GITHUB_WORKSPACE: ${{ github.workspace }} + MCP_TIMEOUT: 120000 + MCP_TOOL_TIMEOUT: 60000 + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Stop MCP gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Safe Outputs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: safe-output + path: ${{ env.GH_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,anthropic.com,api.anthropic.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,cdn.playwright.dev,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,files.pythonhosted.org,ghcr.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,playwright.download.prss.microsoft.com,ppa.launchpad.net,pypi.org,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,sentry.io,statsig.anthropic.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Upload sanitized agent output + if: always() && env.GH_AW_AGENT_OUTPUT + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: agent-output + path: ${{ env.GH_AW_AGENT_OUTPUT }} + if-no-files-found: warn + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_claude_log.cjs'); + await main(); + - name: Parse MCP gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: (always()) && (needs.agent.result != 'skipped') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: 1 + GH_AW_WORKFLOW_NAME: "doctor-analyze" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "doctor-analyze" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "doctor-analyze" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "doctor-analyze" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "doctor-analyze" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Update reaction comment with completion status + id: conclusion + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_NAME: "doctor-analyze" + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); + await main(); + + detection: + needs: agent + if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true' + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 10 + outputs: + success: ${{ steps.parse_results.outputs.success }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0 + with: + destination: /opt/gh-aw/actions + - name: Download agent artifacts + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-artifacts + path: /tmp/gh-aw/threat-detection/ + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-output + path: /tmp/gh-aw/threat-detection/ + - name: Echo agent output types + env: + AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + run: | + echo "Agent output-types: $AGENT_OUTPUT_TYPES" + - name: Setup threat detection + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "doctor-analyze" + WORKFLOW_DESCRIPTION: "No description provided" + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY 'Claude Code' https://github.github.com/gh-aw/reference/engines/#anthropic-claude-code + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install Claude Code CLI + run: npm install -g --silent @anthropic-ai/claude-code@2.1.42 + - name: Execute Claude Code CLI + id: agentic_execution + # Allowed tools (sorted): + # - Bash(cat) + # - Bash(grep) + # - Bash(head) + # - Bash(jq) + # - Bash(ls) + # - Bash(tail) + # - Bash(wc) + # - BashOutput + # - ExitPlanMode + # - Glob + # - Grep + # - KillBash + # - LS + # - NotebookRead + # - Read + # - Task + # - TodoWrite + timeout-minutes: 20 + run: | + set -o pipefail + # Execute Claude Code CLI with prompt from file + claude --print --disable-slash-commands --no-chrome --allowed-tools 'Bash(cat),Bash(grep),Bash(head),Bash(jq),Bash(ls),Bash(tail),Bash(wc),BashOutput,ExitPlanMode,Glob,Grep,KillBash,LS,NotebookRead,Read,Task,TodoWrite' --debug-file /tmp/gh-aw/threat-detection/detection.log --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_CLAUDE:+ --model "$GH_AW_MODEL_DETECTION_CLAUDE"} 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + BASH_DEFAULT_TIMEOUT_MS: 60000 + BASH_MAX_TIMEOUT_MS: 60000 + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + DISABLE_BUG_COMMAND: 1 + DISABLE_ERROR_REPORTING: 1 + DISABLE_TELEMETRY: 1 + GH_AW_MODEL_DETECTION_CLAUDE: ${{ vars.GH_AW_MODEL_DETECTION_CLAUDE || '' }} + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_WORKSPACE: ${{ github.workspace }} + MCP_TIMEOUT: 120000 + MCP_TOOL_TIMEOUT: 60000 + - name: Parse threat detection results + id: parse_results + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: threat-detection.log + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + + pre_activation: + runs-on: ubuntu-slim + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0 + with: + destination: /opt/gh-aw/actions + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); + await main(); + + safe_outputs: + needs: + - agent + - detection + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_ENGINE_ID: "claude" + GH_AW_WORKFLOW_ID: "doctor-analyze" + GH_AW_WORKFLOW_NAME: "doctor-analyze" + outputs: + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@cec1ecf3b97e9a1bbffaedf490a49ce03c1071ba # v0.44.0 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":1},\"close_issue\":{\"max\":1},\"create_issue\":{\"max\":1},\"missing_data\":{},\"missing_tool\":{},\"update_issue\":{\"allow_body\":true,\"max\":1}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + diff --git a/.github/workflows/doctor-analyze.md b/.github/workflows/doctor-analyze.md new file mode 100644 index 0000000..73250d9 --- /dev/null +++ b/.github/workflows/doctor-analyze.md @@ -0,0 +1,503 @@ +--- +name: doctor-analyze +on: + workflow_call: + inputs: + force_analyze: + required: false + type: boolean + analyze_day: + required: true + type: string + report_tz: + required: true + type: string + issues_enabled: + required: true + type: string + observed_findings_present: + required: true + type: string + stats_artifact_name: + required: true + type: string + findings_artifact_name: + required: true + type: string + source_sha: + required: false + type: string + installed_source_sha: + required: false + type: string + installed_runtime_sha: + required: false + type: string +permissions: + contents: read + issues: read + pull-requests: read + actions: read +safe-outputs: + create-issue: + update-issue: + close-issue: + add-comment: +engine: claude +--- + +# Doctor Analyze + +## TL;DR + +Read-only analyze job for the `doctor` workflow. Downloads `collect` artifacts, +validates findings, and manages a deterministic GitHub issue for observed drift. +Writes `doctor-report.json` as the only persistent output. + +You are the `analyze` job for the repository described in the `` block above. +This workflow is called only from `doctor.yml` and must stay read-only for repository files. +Never create PRs. Never edit tracked repository files. The only persistent outputs you may write are: + +- `doctor-report.json` in the workspace root +- mirrored copies of the same report under `/tmp/gh-aw/agent/` so the + current compiled workflow uploads them via generic agent artifacts + +Temporary scratch files are allowed only if they are needed to build or validate the report. + +## Runtime Context + +Treat the `` block from the system prompt as the runtime contract from `collect`. +It must provide these keys: + +- `issues_enabled` +- `observed_findings_present` +- `stats_artifact_name` +- `findings_artifact_name` +- `source_sha` +- `installed_source_sha` +- `installed_runtime_sha` +- `analyze_day` +- `report_tz` +- `force_analyze` +- `run_id` +- `actor` +- `workflow_run_url` + +Read `run_attempt` from env var `GITHUB_RUN_ATTEMPT` via Bash. +Read `event_name` from env var `GITHUB_EVENT_NAME` via Bash. +If the `` block is missing any required key, record +that in `runtime_notes[]`, write `doctor-report.json` with +`issue.reason = "model_error"`, and exit with failure. + +Use `report_tz` to derive: + +- `report_date` in `YYYY-MM-DD` +- `today_name` as a lowercase English weekday name +- `analyze_day_match = (today_name == analyze_day.lower())` +- `manual_override = (force_analyze == true)` + +## Operating Rules + +- Use GitHub MCP tools only for read operations: artifacts, issues, labels, workflow metadata, repository contents. +- Use safe outputs only for issue mutations: `create-issue`, `update-issue`, `close-issue`, `add-comment`. +- Never perform direct write mutations through bash, git, or raw GitHub API calls. +- Prefer GitHub MCP tools for workflow runs, artifacts, issues, and labels. +- If workflow artifact MCP tools are unavailable in the exposed tool list, + you may use `Bash` with authenticated GitHub API calls for artifact lookup + and download only. +- Never use unauthenticated `gh` or unauthenticated `curl` for GitHub API access. +- For authenticated fallback in Bash, export + `GH_TOKEN="$GITHUB_TOKEN"` before `gh api`, or use `curl` with header + `Authorization: Bearer $GITHUB_TOKEN`. +- `collect` is the source of truth for findings and severities. Do not invent new finding IDs or new finding categories. +- Limit issue-generating analysis to findings where: + - `category == observed` + - `auto_action == analyze` + - `path` is one of: + - `AGENTS.md` + - `CLAUDE.md` + - `DOCS.md` + - `.specify/memory/constitution.md` + - `docs/quickstart.md` +- You may read repository files to improve recommendations and evidence, + but not to expand the scope beyond the allowed observed-only paths. + +## Required Procedure + +1. Start by creating a scratch work directory for downloaded artifacts and intermediate notes. +2. Compute `report_date`, `today_name`, `analyze_day_match`, and `manual_override`. +3. Initialize `runtime_notes[]` and a draft `doctor-report.json` object. + +### Runtime Gate + +Apply gates in this order: + +1. If `observed_findings_present != "true"`: + - write `doctor-report.json` + - set: + - `decision.observed_findings_present = false` + - `decision.qualifying_findings_present = false` + - `issue.action = "none"` + - `issue.reason = "no_observed_findings"` + - do not download artifacts + - do not call safe outputs + - exit successfully +2. If `manual_override` is false and `analyze_day_match` is false: + - write `doctor-report.json` + - set: + - `decision.observed_findings_present = true` + - `decision.qualifying_findings_present = false` + - `issue.action = "skipped"` + - `issue.reason = "not_analyze_day"` + - do not call safe outputs + - exit successfully +3. Otherwise continue to artifact download and analysis. + +## Artifact Download And Validation + +Download artifacts from the current workflow run identified in +`` using the exact artifact names from `collect`: + +- the `stats_artifact_name` value from `` +- the `findings_artifact_name` value from `` +- the predownloaded local files, when present: + - `/tmp/gh-aw/collect-artifacts/stats/doctor-stats.json` + - `/tmp/gh-aw/collect-artifacts/findings/doctor-findings.json` + +Required procedure for artifact access: + +1. First check the predownloaded local files under + `/tmp/gh-aw/collect-artifacts/`. If both required files are present, use + them as the primary source and do not attempt network artifact lookup. +2. If either local file is missing, prefer GitHub MCP workflow run and artifact tools when they are available. +3. If the matching collect artifacts are not listed yet, wait 2 seconds and retry once using the same access path. +4. If workflow artifact MCP tools are unavailable, use authenticated Bash fallback: + - `GH_TOKEN="$GITHUB_TOKEN" gh api ...` + - or `curl -H "Authorization: Bearer $GITHUB_TOKEN" ...` +5. Only use workspace or runner filesystem search as a secondary diagnostic + note after both the predownloaded local files and the authenticated + access paths fail. +6. Do not use unauthenticated `gh api`, unauthenticated `curl`, or raw REST calls without `GITHUB_TOKEN`. + +Expect these files: + +- `doctor-stats.json` +- `doctor-findings.json` + +Validate both artifacts before analysis. Treat any missing file, invalid +JSON, missing required field, wrong schema version, or type mismatch as +`artifact_invalid`. + +Minimum required validation for `doctor-stats.json`: + +- `schema_version == "doctor-stats/v3"` +- `collected_at` +- `report_date` +- `window.from` +- `window.to` +- `repo.full_name` +- `repo.default_branch` +- `workflow.run_id` +- `workflow.run_attempt` +- `workflow.event_name` +- `workflow.actor` +- `source_repo.full_name` +- `source_repo.source_branch` +- `source_repo.source_sha` +- `activity.commits` +- `activity.prs` +- `activity.issues` +- `snapshot.prs` +- `snapshot.issues` + +Minimum required validation for `doctor-findings.json`: + +- `schema_version == "doctor-findings/v4"` +- `collected_at` +- `report_date` +- `repo.full_name` +- `repo.default_branch` +- `source_repo.source_branch` +- `source_repo.source_sha` +- `decision.should_act` +- `decision.act_reason` +- `decision.managed_drift_present` +- `decision.runtime_drift_present` +- `decision.observed_findings_present` +- `findings[]` + +For every analyzed finding, require: + +- `id` +- `category` +- `severity` +- `path` +- `state` +- `auto_action` +- `message` + +If artifact validation fails: + +- write `doctor-report.json` +- set: + - `issue.action = "failed"` + - `issue.reason = "artifact_invalid"` +- add a precise explanation to `runtime_notes[]` +- do not call safe outputs +- exit with failure + +## Analysis Scope + +Analyze only findings from `doctor-findings.json` where: + +- `category == "observed"` +- `auto_action == "analyze"` + +Exclude from issue generation: + +- `managed` +- `runtime` +- `operational` +- observed checks outside the allowed path scope + +Keep `info` findings in `analysis[]`, but they do not qualify for an issue. +Qualifying findings are only severities: + +- `warning` +- `error` + +## Analysis Rules + +For each in-scope observed finding: + +1. Preserve the original `finding_id`, `path`, `severity`, `state`, and `message`. +2. Read the current repository path when it exists and use that only as supporting evidence. +3. Produce one `analysis[]` item with: + - `finding_id` + - `category = "observed"` + - `path` + - `severity` + - `state` + - `qualifies_for_issue` + - `summary` + - `recommendation` + - `evidence[]` + +Use these guidance rules: + +- `AGENTS.md` missing: + - explain that the repo is missing the agent entry point + - recommend restoring the file or a valid symlink in the root +- `CLAUDE.md` missing: + - explain that the repo is missing the Claude-facing entry point + - recommend restoring the file or a valid symlink in the root +- `DOCS.md` missing: + - explain that the repo is missing the main docs entry point in the root + - recommend restoring the file or a valid symlink in the root +- `docs/quickstart.md` missing or placeholder: + - recommend a real first-run or onboarding guide +- `.specify/memory/constitution.md` missing or placeholder: + - recommend restoring and tracking a real working agreement + +When files exist but appear placeholder-like or incomplete, use concrete evidence such as: + +- obvious placeholder markers: `TODO`, `TBD`, `placeholder`, `coming soon`, `lorem ipsum`, `template` +- extremely short content with no actionable project-specific instructions + +Do not create duplicate recommendations for the same path family. Group repeated ideas. + +## Issue Policy + +Deterministic issue title: + +- `Toolkit doctor: observed drift` + +Preferred labels when available: + +- `toolkit` +- `doctor` + +Issue body must contain these sections: + +- `## Summary` +- `## Findings` +- `## Recommendations` +- `## Context` + +Issue body requirements: + +- concise summary of current qualifying warnings and errors +- bullet list or table of qualifying findings with: + - path + - severity + - message +- short grouped recommendations +- link to the `workflow_run_url` value from `` +- short activity context from `doctor-stats.json` only if it clearly helps prioritization + +Use read-only GitHub tools to find an existing open issue with the exact title `Toolkit doctor: observed drift`. +Do not create duplicates. + +Before issue mutation: + +1. Check whether labels `toolkit` and `doctor` exist. +2. If one or both labels are missing: + - continue without the missing labels + - record the problem in `runtime_notes[]` +3. If an existing deterministic issue is locked: + - write `doctor-report.json` + - set: + - `issue.action = "failed"` + - `issue.reason = "issue_locked"` + - do not create a duplicate + - exit with failure + +## Issue Lifecycle + +After analysis, compute: + +- `findings_analyzed` +- `qualifying_findings` +- `warning_count` +- `error_count` +- `qualifying_findings_present` + +Then apply this lifecycle: + +1. If `qualifying_findings_present == false`: + - if an open deterministic issue exists: + - add a resolution comment that observed drift is no longer present in the latest analyze run + - close that issue + - set: + - `issue.action = "closed"` + - `issue.reason = "closed_resolved"` + - otherwise: + - set: + - `issue.action = "none"` + - `issue.reason = "no_qualifying_findings"` +2. If `qualifying_findings_present == true` and `issues_enabled != "true"`: + - perform the full analysis + - do not create, update, close, or comment on issues + - set: + - `issue.action = "skipped"` + - `issue.reason = "issues_disabled"` + - if an existing deterministic issue exists, keep its metadata in the report but do not mutate it +3. If `qualifying_findings_present == true` and no open deterministic issue exists: + - create one issue through `create-issue` + - set: + - `issue.action = "created"` + - `issue.reason = "created_new"` +4. If `qualifying_findings_present == true` and an open deterministic issue exists: + - update that exact issue through `update-issue` + - set: + - `issue.action = "updated"` + - `issue.reason = "updated_existing"` + +Never use direct GitHub write APIs for issues. Safe outputs only. + +## doctor-report.json + +Always write `doctor-report.json` before exiting, including skip and failure paths. + +Write the same final JSON payload to all of these paths: + +- `./doctor-report.json` +- `/tmp/gh-aw/agent/doctor-report.json` +- `/tmp/gh-aw/agent/doctor-report-${report_date}.json` + +This is a temporary workaround until the compiled reusable workflow gets a +dedicated `upload-artifact` step for a `doctor-report-YYYY-MM-DD` artifact. + +Required top-level schema: + +```json +{ + "schema_version": "doctor-report/v1", + "analyzed_at": "RFC3339", + "report_date": "YYYY-MM-DD", + "repo": { + "full_name": "owner/repo" + }, + "workflow": { + "run_id": 0, + "run_attempt": 0, + "event_name": "workflow_dispatch|schedule|...", + "actor": "github-user" + }, + "inputs": { + "stats_artifact_name": "doctor-stats-YYYY-MM-DD", + "findings_artifact_name": "doctor-findings-YYYY-MM-DD" + }, + "decision": { + "observed_findings_present": true, + "issues_enabled": true, + "analyze_day_match": true, + "manual_override": false, + "qualifying_findings_present": true + }, + "issue": { + "action": "created|updated|closed|skipped|none|failed", + "reason": "created_new|updated_existing|closed_resolved|issues_disabled|not_analyze_day|no_observed_findings|no_qualifying_findings|artifact_invalid|model_error|issue_locked", + "number": 0, + "url": "https://github.com/owner/repo/issues/123", + "title": "Toolkit doctor: observed drift" + }, + "summary": { + "findings_analyzed": 0, + "qualifying_findings": 0, + "warning_count": 0, + "error_count": 0 + }, + "runtime_notes": [], + "analysis": [ + { + "finding_id": "observed.docs_quickstart_md.missing", + "category": "observed", + "path": "docs/quickstart.md", + "severity": "warning", + "state": "missing", + "qualifies_for_issue": true, + "summary": "Repository is missing the onboarding quickstart.", + "recommendation": "Add a real quickstart with first-run instructions for contributors.", + "evidence": [ + "doctor-findings.json: observed.docs_quickstart_md.missing", + "docs/quickstart.md is absent in the checked out repository" + ] + } + ] +} +``` + +Populate `issue.number`, `issue.url`, and `issue.title` with `null` when absent. +`analysis[]` must include every analyzed observed finding, including +`info`, with `qualifies_for_issue` set appropriately. + +## Failure Handling + +If you encounter a temporary tool or execution error while downloading +artifacts, reading repository context, or building issue text: + +1. retry once +2. if the retry succeeds, continue +3. if the retry fails, write `doctor-report.json` with: + - `issue.action = "failed"` + - `issue.reason = "model_error"` +4. add a precise note to `runtime_notes[]` +5. exit with failure + +For all failure paths: + +- prefer a valid `doctor-report.json` over incomplete changes +- do not create duplicate issues +- do not mutate repository files + +## Final Checklist + +Before finishing, verify all of the following: + +- `doctor-report.json` exists and is valid JSON +- the mirrored report copies exist in `/tmp/gh-aw/agent/` +- `schema_version == "doctor-report/v1"` +- issue decisions use only the allowed enums +- safe outputs, if any, were used only after artifact validation and only when allowed by runtime gates +- no repository files were modified other than `doctor-report.json` and temporary scratch files diff --git a/.github/workflows/doctor-analyze.prompt.md b/.github/workflows/doctor-analyze.prompt.md new file mode 100644 index 0000000..0958728 --- /dev/null +++ b/.github/workflows/doctor-analyze.prompt.md @@ -0,0 +1,455 @@ +# Doctor Analyze + +## TL;DR + +Read-only analyze job for the `doctor` workflow. Downloads `collect` artifacts, +validates findings, and manages a deterministic GitHub issue for observed drift. +Writes `doctor-report.json` as the only persistent output. + +You are the `analyze` job for the repository described in the `` block above. +This workflow is called only from `doctor.yml` and must stay read-only for repository files. +Never create PRs. Never edit tracked repository files. The only persistent outputs you may write are: + +- `doctor-report.json` in the workspace root +- mirrored copies of the same report under `/tmp/gh-aw/agent/` so the + current compiled workflow uploads them via generic agent artifacts + +Temporary scratch files are allowed only if they are needed to build or validate the report. + +## Runtime Context + +Treat the `` block from the system prompt as the runtime contract from `collect`. +It must provide these keys: + +- `issues_enabled` +- `observed_findings_present` +- `stats_artifact_name` +- `findings_artifact_name` +- `source_sha` +- `installed_source_sha` +- `installed_runtime_sha` +- `analyze_day` +- `report_tz` +- `force_analyze` +- `run_id` +- `actor` +- `workflow_run_url` + +Read `run_attempt` from env var `GITHUB_RUN_ATTEMPT` via Bash. +Read `event_name` from env var `GITHUB_EVENT_NAME` via Bash. +If the `` block is missing any required key, record +that in `runtime_notes[]`, write `doctor-report.json` with +`issue.reason = "model_error"`, and exit with failure. + +Use `report_tz` to derive: + +- `report_date` in `YYYY-MM-DD` +- `today_name` as a lowercase English weekday name +- `analyze_day_match = (today_name == analyze_day.lower())` +- `manual_override = (force_analyze == true)` + +## Operating Rules + +- Use GitHub MCP tools only for read operations: artifacts, issues, labels, workflow metadata, repository contents. +- Use safe outputs only for issue mutations: `create-issue`, `update-issue`, `close-issue`, `add-comment`. +- Never perform direct write mutations through bash, git, or raw GitHub API calls. +- Prefer GitHub MCP tools for workflow runs, artifacts, issues, and labels. +- If workflow artifact MCP tools are unavailable in the exposed tool list, + you may use `Bash` with authenticated GitHub API calls for artifact lookup + and download only. +- Never use unauthenticated `gh` or unauthenticated `curl` for GitHub API access. +- For authenticated fallback in Bash, export + `GH_TOKEN="$GITHUB_TOKEN"` before `gh api`, or use `curl` with header + `Authorization: Bearer $GITHUB_TOKEN`. +- `collect` is the source of truth for findings and severities. Do not invent new finding IDs or new finding categories. +- Limit issue-generating analysis to findings where: + - `category == observed` + - `auto_action == analyze` + - `path` is one of: + - `AGENTS.md` + - `CLAUDE.md` + - `DOCS.md` + - `.specify/memory/constitution.md` + - `docs/quickstart.md` +- You may read repository files to improve recommendations and evidence, + but not to expand the scope beyond the allowed observed-only paths. + +## Required Procedure + +1. Start by creating a scratch work directory for downloaded artifacts and intermediate notes. +2. Compute `report_date`, `today_name`, `analyze_day_match`, and `manual_override`. +3. Initialize `runtime_notes[]` and a draft `doctor-report.json` object. + +### Runtime Gate + +Apply gates in this order: + +1. If `observed_findings_present != "true"`: + - write `doctor-report.json` + - set: + - `decision.observed_findings_present = false` + - `decision.qualifying_findings_present = false` + - `issue.action = "none"` + - `issue.reason = "no_observed_findings"` + - do not download artifacts + - do not call safe outputs + - exit successfully +2. If `manual_override` is false and `analyze_day_match` is false: + - write `doctor-report.json` + - set: + - `decision.observed_findings_present = true` + - `decision.qualifying_findings_present = false` + - `issue.action = "skipped"` + - `issue.reason = "not_analyze_day"` + - do not call safe outputs + - exit successfully +3. Otherwise continue to artifact download and analysis. + +## Artifact Download And Validation + +Download artifacts from the current workflow run identified in +`` using the exact artifact names from `collect`: + +- the `stats_artifact_name` value from `` +- the `findings_artifact_name` value from `` +- the predownloaded local files, when present: + - `/tmp/gh-aw/collect-artifacts/stats/doctor-stats.json` + - `/tmp/gh-aw/collect-artifacts/findings/doctor-findings.json` + +Required procedure for artifact access: + +1. First check the predownloaded local files under + `/tmp/gh-aw/collect-artifacts/`. If both required files are present, use + them as the primary source and do not attempt network artifact lookup. +2. If either local file is missing, prefer GitHub MCP workflow run and artifact tools when they are available. +3. If the matching collect artifacts are not listed yet, wait 2 seconds and retry once using the same access path. +4. If workflow artifact MCP tools are unavailable, use authenticated Bash fallback: + - `GH_TOKEN="$GITHUB_TOKEN" gh api ...` + - or `curl -H "Authorization: Bearer $GITHUB_TOKEN" ...` +5. Only use workspace or runner filesystem search as a secondary diagnostic + note after both the predownloaded local files and the authenticated + access paths fail. +6. Do not use unauthenticated `gh api`, unauthenticated `curl`, or raw REST calls without `GITHUB_TOKEN`. + +Expect these files: + +- `doctor-stats.json` +- `doctor-findings.json` + +Validate both artifacts before analysis. Treat any missing file, invalid +JSON, missing required field, wrong schema version, or type mismatch as +`artifact_invalid`. + +Minimum required validation for `doctor-stats.json`: + +- `schema_version == "doctor-stats/v3"` +- `collected_at` +- `report_date` +- `window.from` +- `window.to` +- `repo.full_name` +- `repo.default_branch` +- `workflow.run_id` +- `workflow.run_attempt` +- `workflow.event_name` +- `workflow.actor` +- `source_repo.full_name` +- `source_repo.source_branch` +- `source_repo.source_sha` +- `activity.commits` +- `activity.prs` +- `activity.issues` +- `snapshot.prs` +- `snapshot.issues` + +Minimum required validation for `doctor-findings.json`: + +- `schema_version == "doctor-findings/v4"` +- `collected_at` +- `report_date` +- `repo.full_name` +- `repo.default_branch` +- `source_repo.source_branch` +- `source_repo.source_sha` +- `decision.should_act` +- `decision.act_reason` +- `decision.managed_drift_present` +- `decision.runtime_drift_present` +- `decision.observed_findings_present` +- `findings[]` + +For every analyzed finding, require: + +- `id` +- `category` +- `severity` +- `path` +- `state` +- `auto_action` +- `message` + +If artifact validation fails: + +- write `doctor-report.json` +- set: + - `issue.action = "failed"` + - `issue.reason = "artifact_invalid"` +- add a precise explanation to `runtime_notes[]` +- do not call safe outputs +- exit with failure + +## Analysis Scope + +Analyze only findings from `doctor-findings.json` where: + +- `category == "observed"` +- `auto_action == "analyze"` + +Exclude from issue generation: + +- `managed` +- `runtime` +- `operational` +- observed checks outside the allowed path scope + +Keep `info` findings in `analysis[]`, but they do not qualify for an issue. +Qualifying findings are only severities: + +- `warning` +- `error` + +## Analysis Rules + +For each in-scope observed finding: + +1. Preserve the original `finding_id`, `path`, `severity`, `state`, and `message`. +2. Read the current repository path when it exists and use that only as supporting evidence. +3. Produce one `analysis[]` item with: + - `finding_id` + - `category = "observed"` + - `path` + - `severity` + - `state` + - `qualifies_for_issue` + - `summary` + - `recommendation` + - `evidence[]` + +Use these guidance rules: + +- `AGENTS.md` missing: + - explain that the repo is missing the agent entry point + - recommend restoring the file or a valid symlink in the root +- `CLAUDE.md` missing: + - explain that the repo is missing the Claude-facing entry point + - recommend restoring the file or a valid symlink in the root +- `DOCS.md` missing: + - explain that the repo is missing the main docs entry point in the root + - recommend restoring the file or a valid symlink in the root +- `docs/quickstart.md` missing or placeholder: + - recommend a real first-run or onboarding guide +- `.specify/memory/constitution.md` missing or placeholder: + - recommend restoring and tracking a real working agreement + +When files exist but appear placeholder-like or incomplete, use concrete evidence such as: + +- obvious placeholder markers: `TODO`, `TBD`, `placeholder`, `coming soon`, `lorem ipsum`, `template` +- extremely short content with no actionable project-specific instructions + +Do not create duplicate recommendations for the same path family. Group repeated ideas. + +## Issue Policy + +Deterministic issue title: + +- `Toolkit doctor: observed drift` + +Preferred labels when available: + +- `toolkit` +- `doctor` + +Issue body must contain these sections: + +- `## Summary` +- `## Findings` +- `## Recommendations` +- `## Context` + +Issue body requirements: + +- concise summary of current qualifying warnings and errors +- bullet list or table of qualifying findings with: + - path + - severity + - message +- short grouped recommendations +- link to the `workflow_run_url` value from `` +- short activity context from `doctor-stats.json` only if it clearly helps prioritization + +Use read-only GitHub tools to find an existing open issue with the exact title `Toolkit doctor: observed drift`. +Do not create duplicates. + +Before issue mutation: + +1. Check whether labels `toolkit` and `doctor` exist. +2. If one or both labels are missing: + - continue without the missing labels + - record the problem in `runtime_notes[]` +3. If an existing deterministic issue is locked: + - write `doctor-report.json` + - set: + - `issue.action = "failed"` + - `issue.reason = "issue_locked"` + - do not create a duplicate + - exit with failure + +## Issue Lifecycle + +After analysis, compute: + +- `findings_analyzed` +- `qualifying_findings` +- `warning_count` +- `error_count` +- `qualifying_findings_present` + +Then apply this lifecycle: + +1. If `qualifying_findings_present == false`: + - if an open deterministic issue exists: + - add a resolution comment that observed drift is no longer present in the latest analyze run + - close that issue + - set: + - `issue.action = "closed"` + - `issue.reason = "closed_resolved"` + - otherwise: + - set: + - `issue.action = "none"` + - `issue.reason = "no_qualifying_findings"` +2. If `qualifying_findings_present == true` and `issues_enabled != "true"`: + - perform the full analysis + - do not create, update, close, or comment on issues + - set: + - `issue.action = "skipped"` + - `issue.reason = "issues_disabled"` + - if an existing deterministic issue exists, keep its metadata in the report but do not mutate it +3. If `qualifying_findings_present == true` and no open deterministic issue exists: + - create one issue through `create-issue` + - set: + - `issue.action = "created"` + - `issue.reason = "created_new"` +4. If `qualifying_findings_present == true` and an open deterministic issue exists: + - update that exact issue through `update-issue` + - set: + - `issue.action = "updated"` + - `issue.reason = "updated_existing"` + +Never use direct GitHub write APIs for issues. Safe outputs only. + +## doctor-report.json + +Always write `doctor-report.json` before exiting, including skip and failure paths. + +Write the same final JSON payload to all of these paths: + +- `./doctor-report.json` +- `/tmp/gh-aw/agent/doctor-report.json` +- `/tmp/gh-aw/agent/doctor-report-${report_date}.json` + +This is a temporary workaround until the compiled reusable workflow gets a +dedicated `upload-artifact` step for a `doctor-report-YYYY-MM-DD` artifact. + +Required top-level schema: + +```json +{ + "schema_version": "doctor-report/v1", + "analyzed_at": "RFC3339", + "report_date": "YYYY-MM-DD", + "repo": { + "full_name": "owner/repo" + }, + "workflow": { + "run_id": 0, + "run_attempt": 0, + "event_name": "workflow_dispatch|schedule|...", + "actor": "github-user" + }, + "inputs": { + "stats_artifact_name": "doctor-stats-YYYY-MM-DD", + "findings_artifact_name": "doctor-findings-YYYY-MM-DD" + }, + "decision": { + "observed_findings_present": true, + "issues_enabled": true, + "analyze_day_match": true, + "manual_override": false, + "qualifying_findings_present": true + }, + "issue": { + "action": "created|updated|closed|skipped|none|failed", + "reason": "created_new|updated_existing|closed_resolved|issues_disabled|not_analyze_day|no_observed_findings|no_qualifying_findings|artifact_invalid|model_error|issue_locked", + "number": 0, + "url": "https://github.com/owner/repo/issues/123", + "title": "Toolkit doctor: observed drift" + }, + "summary": { + "findings_analyzed": 0, + "qualifying_findings": 0, + "warning_count": 0, + "error_count": 0 + }, + "runtime_notes": [], + "analysis": [ + { + "finding_id": "observed.docs_quickstart_md.missing", + "category": "observed", + "path": "docs/quickstart.md", + "severity": "warning", + "state": "missing", + "qualifies_for_issue": true, + "summary": "Repository is missing the onboarding quickstart.", + "recommendation": "Add a real quickstart with first-run instructions for contributors.", + "evidence": [ + "doctor-findings.json: observed.docs_quickstart_md.missing", + "docs/quickstart.md is absent in the checked out repository" + ] + } + ] +} +``` + +Populate `issue.number`, `issue.url`, and `issue.title` with `null` when absent. +`analysis[]` must include every analyzed observed finding, including +`info`, with `qualifies_for_issue` set appropriately. + +## Failure Handling + +If you encounter a temporary tool or execution error while downloading +artifacts, reading repository context, or building issue text: + +1. retry once +2. if the retry succeeds, continue +3. if the retry fails, write `doctor-report.json` with: + - `issue.action = "failed"` + - `issue.reason = "model_error"` +4. add a precise note to `runtime_notes[]` +5. exit with failure + +For all failure paths: + +- prefer a valid `doctor-report.json` over incomplete changes +- do not create duplicate issues +- do not mutate repository files + +## Final Checklist + +Before finishing, verify all of the following: + +- `doctor-report.json` exists and is valid JSON +- the mirrored report copies exist in `/tmp/gh-aw/agent/` +- `schema_version == "doctor-report/v1"` +- issue decisions use only the allowed enums +- safe outputs, if any, were used only after artifact validation and only when allowed by runtime gates +- no repository files were modified other than `doctor-report.json` and temporary scratch files diff --git a/.github/workflows/doctor.yml b/.github/workflows/doctor.yml new file mode 100644 index 0000000..f5b0a69 --- /dev/null +++ b/.github/workflows/doctor.yml @@ -0,0 +1,320 @@ +name: doctor + +on: + schedule: + - cron: '0 2 * * *' + workflow_dispatch: + inputs: + force_analyze: + description: Force analyze outside configured analyze day + required: false + type: boolean + default: false + +permissions: + contents: read + issues: read + pull-requests: read + actions: read + +env: + SOURCE_REPO: G-Core/agent-toolkit + REPORT_TZ: UTC + PYTHON_VERSION: '3.12' + ANALYZE_DAY: monday + +jobs: + collect: + runs-on: ubuntu-latest + outputs: + should_act: ${{ steps.collect.outputs.should_act }} + source_sha: ${{ steps.collect.outputs.source_sha }} + installed_source_sha: ${{ steps.collect.outputs.installed_source_sha }} + installed_runtime_sha: ${{ steps.collect.outputs.installed_runtime_sha }} + act_reason: ${{ steps.collect.outputs.act_reason }} + analyze_day: ${{ steps.collect.outputs.analyze_day }} + report_tz: ${{ steps.collect.outputs.report_tz }} + issues_enabled: ${{ steps.collect.outputs.issues_enabled }} + observed_findings_present: ${{ steps.collect.outputs.observed_findings_present }} + stats_artifact_name: ${{ steps.collect.outputs.stats_artifact_name }} + findings_artifact_name: ${{ steps.collect.outputs.findings_artifact_name }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Collect runtime inputs from GitHub API + id: collect_inputs + uses: actions/github-script@v8 + env: + SOURCE_REPO: ${{ env.SOURCE_REPO }} + SOURCE_GITHUB_TOKEN: ${{ secrets.TOOLKIT_READ_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require("fs"); + const path = require("path"); + + const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); + const repoMeta = await github.rest.repos.get({ owner, repo }); + const repoDefaultBranch = repoMeta.data.default_branch; + const issuesEnabled = repoMeta.data.has_issues ? "true" : "false"; + + const now = new Date(); + const reportDate = now.toISOString().slice(0, 10); + const reportDateStart = new Date(`${reportDate}T00:00:00Z`); + const windowTo = reportDateStart.toISOString(); + const windowFrom = new Date(reportDateStart.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const collectedAt = now.toISOString(); + + const commits = await github.paginate(github.rest.repos.listCommits, { + owner, + repo, + since: windowFrom, + until: windowTo, + per_page: 100, + }); + const dailyCommits = commits.map((item) => ({ + sha: item.sha, + author: item.author?.login || item.commit?.author?.name || "unknown", + authored_at: item.commit?.author?.date || null, + message: item.commit?.message || "", + })); + + const pullsAll = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "all", + sort: "updated", + direction: "desc", + per_page: 100, + }); + const inWindow = (value) => { + if (!value) { + return false; + } + return value >= windowFrom && value < windowTo; + }; + const mapPull = (item) => ({ + number: item.number, + author: item.user?.login || "unknown", + title: item.title || "", + state: item.state || "open", + created_at: item.created_at, + merged_at: item.merged_at, + closed_at: item.closed_at, + labels: (item.labels || []).map((label) => label.name), + assignees: (item.assignees || []).map((assignee) => assignee.login), + }); + const dailyPRs = pullsAll + .filter((item) => inWindow(item.created_at) || inWindow(item.merged_at) || inWindow(item.closed_at)) + .map(mapPull); + + const issuesAll = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "all", + since: windowFrom, + per_page: 100, + }); + const isPlainIssue = (item) => !item.pull_request; + const mapIssue = (item) => ({ + number: item.number, + author: item.user?.login || "unknown", + title: item.title || "", + state: item.state || "open", + created_at: item.created_at, + closed_at: item.closed_at, + labels: (item.labels || []).map((label) => + typeof label === "string" ? label : label.name + ), + assignees: (item.assignees || []).map((assignee) => assignee.login), + }); + const dailyIssues = issuesAll + .filter((item) => isPlainIssue(item) && (inWindow(item.created_at) || inWindow(item.closed_at))) + .map(mapIssue); + + const pullsOpen = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + per_page: 100, + }); + const snapshotPRs = pullsOpen.map((item) => ({ + number: item.number, + author: item.user?.login || "unknown", + title: item.title || "", + created_at: item.created_at, + days_open: Math.floor((Date.now() - Date.parse(item.created_at)) / 86400000), + labels: (item.labels || []).map((label) => label.name), + assignees: (item.assignees || []).map((assignee) => assignee.login), + })); + + const issuesOpen = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + per_page: 100, + }); + const snapshotIssues = issuesOpen + .filter(isPlainIssue) + .map((item) => ({ + number: item.number, + author: item.user?.login || "unknown", + title: item.title || "", + created_at: item.created_at, + assignees: (item.assignees || []).map((assignee) => assignee.login), + labels: (item.labels || []).map((label) => + typeof label === "string" ? label : label.name + ), + })); + + const activityPayload = { + commits: dailyCommits, + prs: dailyPRs, + issues: dailyIssues, + snapshot_prs: snapshotPRs, + snapshot_issues: snapshotIssues, + }; + + const workDir = path.join(process.env.RUNNER_TEMP, "doctor-inputs"); + fs.mkdirSync(workDir, { recursive: true }); + const activityPath = path.join(workDir, "activity.json"); + fs.writeFileSync(activityPath, JSON.stringify(activityPayload)); + + core.setOutput("activity_path", activityPath); + core.setOutput("repo_default_branch", repoDefaultBranch); + core.setOutput("issues_enabled", issuesEnabled); + core.setOutput("collected_at", collectedAt); + core.setOutput("report_date", reportDate); + core.setOutput("window_from", windowFrom); + core.setOutput("window_to", windowTo); + + - name: Checkout toolkit source + uses: actions/checkout@v4 + with: + repository: ${{ env.SOURCE_REPO }} + ref: doctor-v1 + path: .doctor-source + token: ${{ secrets.TOOLKIT_READ_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Run collect + id: collect + env: + DOCTOR_REPO_ROOT: ${{ github.workspace }} + DOCTOR_OUTPUT_DIR: ${{ runner.temp }}/doctor + DOCTOR_SOURCE_ROOT: ${{ github.workspace }}/.doctor-source + DOCTOR_ACTIVITY_PATH: ${{ steps.collect_inputs.outputs.activity_path }} + DOCTOR_ISSUES_ENABLED: ${{ steps.collect_inputs.outputs.issues_enabled }} + DOCTOR_REPO_FULL_NAME: ${{ github.repository }} + DOCTOR_REPO_DEFAULT_BRANCH: ${{ steps.collect_inputs.outputs.repo_default_branch }} + DOCTOR_SOURCE_REPO: ${{ env.SOURCE_REPO }} + DOCTOR_SOURCE_BRANCH: doctor-v1 + DOCTOR_COLLECTED_AT: ${{ steps.collect_inputs.outputs.collected_at }} + DOCTOR_REPORT_DATE: ${{ steps.collect_inputs.outputs.report_date }} + DOCTOR_WINDOW_FROM: ${{ steps.collect_inputs.outputs.window_from }} + DOCTOR_WINDOW_TO: ${{ steps.collect_inputs.outputs.window_to }} + DOCTOR_ANALYZE_DAY: ${{ env.ANALYZE_DAY }} + DOCTOR_REPORT_TZ: ${{ env.REPORT_TZ }} + run: python .doctor-source/workflows/runtime/collect.py + + - name: Upload doctor-stats artifact + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.collect.outputs.stats_artifact_name }} + path: ${{ runner.temp }}/doctor/doctor-stats.json + if-no-files-found: error + + - name: Upload doctor-findings artifact + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.collect.outputs.findings_artifact_name }} + path: ${{ runner.temp }}/doctor/doctor-findings.json + if-no-files-found: error + + act: + needs: collect + if: ${{ needs.collect.outputs.should_act == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + actions: read + steps: + - name: Checkout target repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Download doctor-findings artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.collect.outputs.findings_artifact_name }} + path: ${{ runner.temp }}/doctor-findings + + - name: Checkout toolkit source + uses: actions/checkout@v4 + with: + repository: ${{ env.SOURCE_REPO }} + ref: doctor-v1 + path: .doctor-source + token: ${{ secrets.TOOLKIT_READ_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run act + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCTOR_REPO_ROOT: ${{ github.workspace }} + DOCTOR_SOURCE_ROOT: ${{ github.workspace }}/.doctor-source + DOCTOR_FINDINGS_PATH: ${{ runner.temp }}/doctor-findings/doctor-findings.json + DOCTOR_SOURCE_SHA: ${{ needs.collect.outputs.source_sha }} + DOCTOR_INSTALLED_SOURCE_SHA: ${{ needs.collect.outputs.installed_source_sha }} + DOCTOR_INSTALLED_RUNTIME_SHA: ${{ needs.collect.outputs.installed_runtime_sha }} + DOCTOR_ACT_REASON: ${{ needs.collect.outputs.act_reason }} + DOCTOR_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DOCTOR_SOURCE_REPO: ${{ env.SOURCE_REPO }} + DOCTOR_WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: python .doctor-source/workflows/act.py + + analyze: + needs: + - collect + - act + if: >- + ${{ + always() && + needs.collect.result == 'success' && + (needs.act.result == 'success' || needs.act.result == 'skipped') + }} + # Required for reusable gh-aw analyze with safe outputs. GitHub validates + # the caller permission envelope before analyze starts; the current + # compiled lock also requests discussions: write. + permissions: + actions: read + contents: read + issues: write + pull-requests: write + discussions: write + uses: G-Core/agent-toolkit/.github/workflows/doctor-analyze.lock.yml@doctor-v1 + secrets: inherit + with: + force_analyze: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_analyze == 'true' }} + analyze_day: ${{ needs.collect.outputs.analyze_day }} + report_tz: ${{ needs.collect.outputs.report_tz }} + issues_enabled: ${{ needs.collect.outputs.issues_enabled }} + observed_findings_present: ${{ needs.collect.outputs.observed_findings_present }} + stats_artifact_name: ${{ needs.collect.outputs.stats_artifact_name }} + findings_artifact_name: ${{ needs.collect.outputs.findings_artifact_name }} + source_sha: ${{ needs.collect.outputs.source_sha }} + installed_source_sha: ${{ needs.collect.outputs.installed_source_sha }} + installed_runtime_sha: ${{ needs.collect.outputs.installed_runtime_sha }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..61ac6ca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,228 @@ +# AI Agent Instructions for FastEdge JS SDK + +## CRITICAL: Read Smart, Not Everything + +**DO NOT read all context files upfront.** This repository uses a **discovery-based context system** to minimize token usage while maximizing effectiveness. + +--- + +## Getting Started: Discovery Pattern + +### Step 1: Read the Index (REQUIRED — ~130 lines) + +**First action when starting work:** Read `context/CONTEXT_INDEX.md` + +This lightweight file gives you: +- Project quick start (what this repo does in 10 lines) +- Documentation map organized by topic with sizes +- Decision tree for what to read based on your task +- Search patterns for finding information + +### Step 2: Read Based on Your Task (JUST-IN-TIME) + +Use the decision tree in CONTEXT_INDEX.md to determine what to read. **Only read what's relevant to your current task.** + +**Examples:** + +**Task: "Add a new CLI flag to fastedge-build"** +- Read: `context/development/BUILD_SYSTEM.md` (CLI build section) +- Read: `src/cli/fastedge-build/build.ts` (entry point) +- Grep: `context/CHANGELOG.md` for similar past changes + +**Task: "Fix regex precompilation bug"** +- Read: `context/architecture/COMPONENTIZE_PIPELINE.md` (Stage 2) +- Read: `src/componentize/precompile.ts` +- Run: `pnpm run test:unit:dev` to verify + +**Task: "Add new host API (e.g., cache)"** +- Read: `context/architecture/RUNTIME_ARCHITECTURE.md` (builtins + WIT) +- Read: `runtime/fastedge/builtins/fastedge.cpp` (template) +- Run: `pnpm run generate:wit-world` after changes + +**Task: "Update TypeScript type definitions"** +- Read: `types/` directory (authoritative API surface) +- Run: `pnpm run build:types` to verify +- Check: `github-pages/src/content/docs/reference/` if user-facing docs need updates + +### Step 3: Search, Don't Read Everything + +**Use grep and search tools** instead of reading large files linearly: + +- **CHANGELOG.md**: Will grow over time — always grep, never read end-to-end +- **Architecture docs** (~160-170 lines): Read specific sections by heading +- **Source code**: Use path aliases to navigate (`~componentize/`, `~utils/`, etc.) + +--- + +## Decision Tree Reference + +**Quick lookup for common tasks:** + +| Task Type | What to Read | +|-----------|-------------| +| **Adding a CLI feature** | BUILD_SYSTEM + relevant CLI entry point | +| **Fixing componentize bug** | COMPONENTIZE_PIPELINE + specific stage file | +| **Adding runtime host API** | RUNTIME_ARCHITECTURE (builtins + WIT sections) | +| **Modifying static assets** | COMPONENTIZE_PIPELINE (Build Types) + `src/server/static-assets/` | +| **Updating type definitions** | `types/` directory + `github-pages/` reference docs | +| **Adding an example** | Browse `examples/` for similar existing example | +| **Changing build system** | BUILD_SYSTEM + `esbuild/` scripts | +| **Working with WIT** | RUNTIME_ARCHITECTURE (WIT section) + `runtime/FastEdge-wit/` | +| **Writing tests** | TESTING_GUIDE | +| **Understanding the system** | PROJECT_OVERVIEW (~150 lines) | +| **Updating docs site** | `github-pages/` (Astro, separate workspace) | +| **Working on fastedge-init** | `src/cli/fastedge-init/` + PROJECT_OVERVIEW (Build Types) | + +--- + +## Anti-Patterns (What NOT to Do) + +**Don't:** Read all 6 context docs upfront (~700 lines wasted if you only need one) +**Don't:** Read `runtime/` C++ source for JS-only CLI changes +**Don't:** Read `github-pages/` Astro source when working on SDK internals +**Don't:** Read entire architecture docs when you need one specific section +**Don't:** Modify `lib/*.wasm` directly — these are build artifacts + +**Do:** Read `context/CONTEXT_INDEX.md` first — always +**Do:** Use grep to search CHANGELOG and large source files +**Do:** Read `types/` for the authoritative public API surface +**Do:** Read `examples/` for real-world usage patterns +**Do:** Follow path aliases (`~componentize/`, `~utils/`) when navigating source + +--- + +## Critical Working Practices + +### Task Checklists (ALWAYS USE) + +When starting any non-trivial task (multi-step, multiple files, features, etc.): + +1. Use `TaskCreate` to break work into discrete steps +2. Mark tasks `in_progress` when starting, `completed` when done +3. This helps track progress and prevents missed steps + +### Parallel Agents + +For independent work, spawn parallel agents: +- Research different subsystems simultaneously +- Run tests while writing docs +- Read multiple source files at once + +### Documentation Maintenance + +When you make significant changes, update the relevant context docs: + +1. **After adding a feature:** Add a CHANGELOG.md entry +2. **After changing architecture:** Update the relevant architecture doc +3. **After changing build config:** Update BUILD_SYSTEM.md +4. **After adding tests:** Verify TESTING_GUIDE.md is still accurate + +**CHANGELOG entry format:** +```markdown +## [YYYY-MM-DD] — Brief Description + +### Overview +One sentence summary. + +### Changes +- Bullet list of what changed +``` + +--- + +## Context Organization + +``` +FastEdge-sdk-js/ +├── CLAUDE.md ← YOU ARE HERE +├── context/ +│ ├── CONTEXT_INDEX.md ← Read first (discovery hub) +│ ├── PROJECT_OVERVIEW.md ← New to codebase? Start here +│ ├── CHANGELOG.md ← Search with grep +│ ├── architecture/ +│ │ ├── COMPONENTIZE_PIPELINE.md ← JS→WASM pipeline (4 stages) +│ │ └── RUNTIME_ARCHITECTURE.md ← StarlingMonkey, builtins, WIT +│ └── development/ +│ ├── BUILD_SYSTEM.md ← esbuild, TypeScript, path aliases +│ └── TESTING_GUIDE.md ← Jest setup, test organization +├── src/ ← TypeScript source +│ ├── cli/ ← 3 CLI tools +│ ├── componentize/ ← Compilation pipeline +│ ├── server/static-assets/ ← Static site support +│ └── utils/ ← Shared utilities +├── runtime/ ← StarlingMonkey + builtins (C++/Rust) +├── types/ ← TypeScript declarations (public API) +├── examples/ ← 13 standalone example apps +├── github-pages/ ← Astro docs site (GitHub Pages) +├── docs/ ← Pipeline docs (planned) +├── esbuild/ ← Build scripts +├── config/ ← Jest + ESLint config +├── integration-tests/ ← CLI integration tests +└── compiler/ ← Docker build environment +``` + +--- + +## Search Tips + +**Find pipeline code:** +```bash +grep -r "componentize" src/ +grep -r "precompile" src/componentize/ +``` + +**Find runtime builtins:** +```bash +grep -r "add_builtin" runtime/fastedge/ +grep -r "INIT_ONLY" runtime/ +``` + +**Find test files:** +```bash +find src/ -name "*.test.ts" +find integration-tests/ -name "*.test.*" +``` + +**Find path alias usage:** +```bash +grep -r "~componentize/" src/ +grep -r "~utils/" src/ +``` + +**Find CLI argument definitions:** +```bash +grep -r "arg(" src/cli/ +``` + +--- + +## Quick Reference + +**Tech Stack:** TypeScript, esbuild, Jest, StarlingMonkey (SpiderMonkey), Wizer, JCO +**Package:** `@gcoredev/fastedge-sdk-js` v2.1.0 +**Node:** >= 20, pnpm >= 10 +**License:** Apache-2.0 + +**Common Commands:** + +| Command | Purpose | +|---------|---------| +| `pnpm run build:js` | Build CLI + libs + types (no runtime) | +| `pnpm run build:dev` | Full build including runtime (slow) | +| `pnpm run test:unit:dev` | Fast unit tests | +| `pnpm run test:unit` | Unit + slow tests | +| `pnpm run test:integration` | Integration tests | +| `pnpm run typecheck` | Type checking only | +| `pnpm run lint` | ESLint | +| `pnpm run generate:wit-world` | Regenerate WIT bindings | + +--- + +## Summary + +1. Read `context/CONTEXT_INDEX.md` first +2. Use the decision tree to find relevant docs +3. Read only what you need for your current task +4. Use grep for CHANGELOG and large files +5. Update context docs after significant changes +6. Use TaskCreate for multi-step work diff --git a/config/eslint/repo/.eslintrc.cjs b/config/eslint/repo/.eslintrc.cjs index 79fa2a3..339546c 100644 --- a/config/eslint/repo/.eslintrc.cjs +++ b/config/eslint/repo/.eslintrc.cjs @@ -34,6 +34,7 @@ module.exports = { 'lib/**', '**/runtime/fastedge/**', '**/runtime/StarlingMonkey/**', + 'github-pages/**', ], plugins: [ '@typescript-eslint', @@ -75,7 +76,11 @@ module.exports = { 'import/group-exports': 'off', 'import/exports-last': 'off', 'import/no-default-export': 'off', + 'import/unambiguous': 'off', 'no-console': 'off', + 'no-return-await': 'off', + 'prefer-destructuring': 'off', + '@typescript-eslint/no-unused-vars': 'off', }, }, { diff --git a/config/jest/jest.config.js b/config/jest/jest.config.js index 8f3ce6f..e2fa563 100644 --- a/config/jest/jest.config.js +++ b/config/jest/jest.config.js @@ -15,7 +15,7 @@ const config = { testPathIgnorePatterns: [ 'node_modules', 'dist', - 'docs', + 'github-pages', 'runtime/StarlingMonkey/', 'runtime/fastedge/deps', ], diff --git a/context/CHANGELOG.md b/context/CHANGELOG.md new file mode 100644 index 0000000..84aac9d --- /dev/null +++ b/context/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +Track significant changes to the FastEdge JS SDK codebase. +When this file grows large, use grep to search — don't read linearly. + +--- + +## [2026-03-26] — Examples Consolidation + +### Overview +Moved 6 code snippets from `github-pages/examples/` into `examples/` as standalone projects, eliminating duplication. The Astro docs site now imports example code from the main `examples/` folder via a Vite alias. + +### Changes +- Created 6 new standalone examples: `hello-world`, `downstream-fetch`, `downstream-modify-response`, `headers`, `kv-store-basic`, `variables-and-secrets` +- Added Vite resolve alias `@examples` → `../examples/` in `github-pages/astro.config.mjs` +- Updated all 6 MDX files to import from `@examples//src/index.js?raw` +- Renamed `basic.mdx` → `hello-world.mdx` +- Reorganized `examples/README.md` into "Getting Started" and "Full Examples" sections +- Deleted `github-pages/examples/` (no longer needed) + +--- + +## [2026-03-25] — Agent Context Setup + +### Overview +Added CLAUDE.md and context/ folder for agent discoverability. Renamed `docs/` to `github-pages/` per company-wide convention to make room for pipeline-compatible documentation. + +### Changes +- Renamed `docs/` → `github-pages/` (Astro GitHub Pages site) +- Updated references in `tsconfig.json`, `pnpm-workspace.yaml`, `.github/workflows/docs.yaml`, `config/jest/jest.config.js` +- Created `context/` folder with discovery-based navigation system: + - `PROJECT_OVERVIEW.md` — lightweight project overview + - `architecture/COMPONENTIZE_PIPELINE.md` — JS→WASM compilation pipeline + - `architecture/RUNTIME_ARCHITECTURE.md` — StarlingMonkey, builtins, WIT + - `development/BUILD_SYSTEM.md` — esbuild, TypeScript, path aliases + - `development/TESTING_GUIDE.md` — Jest setup, test organization + - `CONTEXT_INDEX.md` — discovery hub + - `CHANGELOG.md` — this file +- Created `CLAUDE.md` — agent onboarding instructions diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md new file mode 100644 index 0000000..6bc1a63 --- /dev/null +++ b/context/CONTEXT_INDEX.md @@ -0,0 +1,142 @@ +# Context Discovery Index + +## Quick Start + +- **Project:** FastEdge JS SDK + CLI toolchain +- **Package:** `@gcoredev/fastedge-sdk-js` v2.1.0 +- **Purpose:** Compile JS/TS apps into WASM components for Gcore FastEdge edge platform +- **CLIs:** `fastedge-build` (compiler), `fastedge-init` (scaffolder), `fastedge-assets` (static manifests) +- **Runtime:** StarlingMonkey (SpiderMonkey → WASM) with custom C++ builtins +- **WIT World:** `gcore:fastedge/reactor` — imports http, dictionary, secret, key-value; exports http-handler +- **Build:** esbuild + Wizer + JCO → WASM Component Model +- **Tests:** Jest (unit in `src/**/__tests__/`, integration in `integration-tests/`) +- **Node:** >= 22, pnpm >= 10 + +--- + +## Documentation Map + +### Architecture (read when modifying internal structure) + +| Document | Lines | Purpose | +|----------|-------|---------| +| `architecture/COMPONENTIZE_PIPELINE.md` | ~160 | The 4-stage JS→WASM compilation chain (esbuild → regex precompile → Wizer → JCO). Read when working on `fastedge-build` or `src/componentize/`. | +| `architecture/RUNTIME_ARCHITECTURE.md` | ~170 | StarlingMonkey runtime, C++ builtins (env, kv, secret, fs, console), host-api bridge, WIT definitions, build process. Read when working on `runtime/`. | + +### Development (read when implementing or testing) + +| Document | Lines | Purpose | +|----------|-------|---------| +| `development/BUILD_SYSTEM.md` | ~110 | esbuild scripts, TypeScript config (3 tsconfigs), path aliases, npm package contents. Read when changing build configuration. | +| `development/TESTING_GUIDE.md` | ~90 | Jest setup, unit vs integration tests, test organization, running tests. Read when adding or modifying tests. | + +### Reference (search on-demand) + +| Document | Lines | Purpose | +|----------|-------|---------| +| `PROJECT_OVERVIEW.md` | ~150 | Lightweight project overview — architecture, key modules, dev setup, common commands. Read when new to the codebase. | +| `CHANGELOG.md` | ~25+ | Change history. Use grep, don't read linearly as this file grows. | + +### External (not in context/) + +| Resource | Location | Purpose | +|----------|----------|---------| +| TypeScript declarations | `types/` | Authoritative public API surface (env, fs, kv, secret, globals) | +| GitHub Pages docs | `github-pages/` | Astro-based user-facing documentation site | +| Pipeline docs | `docs/` (planned) | Human-facing docs feeding into fastedge-plugin | +| Examples | `examples/` | 13 standalone example apps showing real patterns | + +--- + +## Decision Tree: What Should I Read? + +### Adding a CLI Feature (flag, option, subcommand) +1. Read `development/BUILD_SYSTEM.md` (CLI build section) +2. Read the relevant CLI entry point: `src/cli/fastedge-{build,init,assets}/` +3. Grep `CHANGELOG.md` for similar past changes + +### Fixing a Componentize Bug +1. Read `architecture/COMPONENTIZE_PIPELINE.md` +2. Read the specific pipeline stage file (see Key Files table in that doc) +3. Run `pnpm run test:unit:dev` to verify + +### Adding a Runtime Host API +1. Read `architecture/RUNTIME_ARCHITECTURE.md` (builtins + host-api + WIT sections) +2. Read existing builtin as template: `runtime/fastedge/builtins/fastedge.cpp` +3. Update WIT: `runtime/FastEdge-wit/` + `runtime/fastedge/host-api/wit/` +4. Run `pnpm run generate:wit-world` + +### Modifying Static Assets System +1. Read `architecture/COMPONENTIZE_PIPELINE.md` (Build Types section) +2. Read `src/server/static-assets/` source files +3. Check tests in `src/server/static-assets/**/__tests__/` + +### Updating Type Definitions +1. Read `types/` directory (authoritative API surface) +2. Check `github-pages/src/content/docs/reference/` for user-facing docs +3. Run `pnpm run build:types` to verify + +### Adding a New Example +1. Browse `examples/` for an existing example similar to your target +2. Each example is standalone with its own `package.json` +3. Install SDK via `npm install --save-dev @gcoredev/fastedge-sdk-js` + +### Changing the Build System +1. Read `development/BUILD_SYSTEM.md` +2. Check `esbuild/cli-binaries.js` and `esbuild/fastedge-libs.js` +3. Verify path aliases match in `tsconfig.json` and `config/jest/jest.config.js` + +### Working with WIT Definitions +1. Read `architecture/RUNTIME_ARCHITECTURE.md` (WIT section) +2. Read `runtime/FastEdge-wit/world.wit` (top-level world) +3. Read `runtime/fastedge/host-api/wit/` (local bindings) +4. Run `pnpm run generate:wit-world` after changes + +### Writing Tests +1. Read `development/TESTING_GUIDE.md` +2. Follow co-located pattern: `__tests__/` next to source +3. Integration tests go in `integration-tests/` + +### Understanding the System (new to codebase) +1. Read `PROJECT_OVERVIEW.md` (~150 lines) +2. Skim `architecture/COMPONENTIZE_PIPELINE.md` (pipeline diagram) +3. Browse `examples/` for usage patterns + +### Modifying esbuild Configuration +1. Read `development/BUILD_SYSTEM.md` (esbuild sections) +2. Read `esbuild/cli-binaries.js` or `esbuild/fastedge-libs.js` +3. Check external packages list — adding/removing externals affects bundle size + +### Working on fastedge-init Scaffolder +1. Read `src/cli/fastedge-init/` source files +2. Read `PROJECT_OVERVIEW.md` (Build Types section) +3. Check `integration-tests/` for CLI tests + +### Updating the Docs Site (GitHub Pages) +1. The Astro site is in `github-pages/` (separate pnpm workspace) +2. Code examples in the docs are imported from `examples/` via a Vite alias (`@examples` → `../examples/`). Edit the source in `examples//src/index.js` — the docs site picks it up automatically. +3. Changes to `github-pages/**` trigger the docs deploy workflow +4. Run `cd github-pages && pnpm build` to verify locally + +--- + +## Search Tips + +- **Don't** read `CHANGELOG.md` linearly — grep for keywords +- **Grep patterns:** + - `grep -r "componentize" src/` — find pipeline references + - `grep -r "INIT_ONLY" runtime/` — find initialization-only APIs + - `grep -r "add_builtin" runtime/` — find registered builtins + - `grep -r "external:" esbuild/` — find externalized packages +- **File discovery:** `find src/ -name "*.test.ts"` to locate tests + +## Documentation Size Reference + +| Category | Documents | Total Lines | +|----------|-----------|-------------| +| Architecture | 2 docs | ~330 | +| Development | 2 docs | ~200 | +| Reference | 2 docs | ~175 | +| **Total** | **6 docs** | **~700** | + +All documents are designed for single-sitting reads. No doc exceeds 170 lines. diff --git a/context/PROJECT_OVERVIEW.md b/context/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..257bf52 --- /dev/null +++ b/context/PROJECT_OVERVIEW.md @@ -0,0 +1,151 @@ +# FastEdge JS SDK - Project Overview + +## Purpose + +The FastEdge JS SDK (`@gcoredev/fastedge-sdk-js`) enables developers to build JavaScript/TypeScript applications that compile to WebAssembly components for deployment on Gcore's FastEdge edge platform. It provides three CLI tools for the development lifecycle and a custom JavaScript runtime built on SpiderMonkey. + +**npm package:** `@gcoredev/fastedge-sdk-js` v2.1.0 +**Repository:** `G-Core/FastEdge-sdk-js` +**License:** Apache-2.0 + +## CLI Tools + +| Tool | Purpose | Entry Point | +|------|---------|-------------| +| `fastedge-build` | Compile JS/TS to WASM component | `src/cli/fastedge-build/build.ts` | +| `fastedge-init` | Interactive project scaffolding wizard | `src/cli/fastedge-init/init.ts` | +| `fastedge-assets` | Static asset manifest generation | `src/cli/fastedge-assets/asset-cli.ts` | + +## Architecture Overview + +### Compilation Pipeline (fastedge-build) + +``` +JS/TS source + → esbuild bundle (single file) + → regex precompilation (Unicode → ASCII for SpiderMonkey) + → Wizer pre-initialization (snapshot with fastedge-runtime.wasm) + → JCO componentization (WASI preview1 adapter) + → output .wasm component +``` + +See `context/architecture/COMPONENTIZE_PIPELINE.md` for full details. + +### Runtime + +The runtime is based on **StarlingMonkey** (Mozilla SpiderMonkey compiled to WASM) with custom C++ builtins providing FastEdge-specific APIs (env, kv, secret, fs, console). The runtime is defined by the `gcore:fastedge` WIT world. + +See `context/architecture/RUNTIME_ARCHITECTURE.md` for full details. + +### Build Types + +- **`http`** — Standard HTTP event handler (`addEventListener('fetch', ...)`) +- **`static`** — Static site serving (assets embedded in WASM via manifest) + +## Key Modules + +| Directory | Purpose | +|-----------|---------| +| `src/cli/` | CLI entry points (3 tools) | +| `src/componentize/` | JS→WASM compilation pipeline | +| `src/server/static-assets/` | Asset manifest, loader, embedded static server | +| `src/utils/` | Shared utilities (paths, config, logging) | +| `src/constants/` | Project constants (`.fastedge` directory) | +| `runtime/fastedge/` | StarlingMonkey build + C++ builtins + host-api + WIT | +| `runtime/StarlingMonkey/` | Git submodule (SpiderMonkey-based JS engine) | +| `runtime/FastEdge-wit/` | WIT world definition (`gcore:fastedge`) | +| `types/` | TypeScript declarations (authoritative public API surface) | +| `github-pages/` | Astro documentation site (GitHub Pages) | +| `examples/` | Standalone example apps (13 examples: 6 getting-started + 7 full) | +| `config/` | Jest + ESLint configurations | +| `esbuild/` | esbuild build scripts | +| `compiler/` | Docker compiler for CI/CD | + +## Package Exports + +**Binaries:** `fastedge-build`, `fastedge-init`, `fastedge-assets` +**Library:** `lib/index.js` (re-exports `create-static-server.js`) +**Runtime:** `lib/fastedge-runtime.wasm`, `lib/preview1-adapter.wasm` +**Types:** `types/index.d.ts` → env, fs, kv, secret, globals + +## Application Model + +FastEdge apps use a Service Worker-style API: + +```js +async function app(event) { + const request = event.request; + return new Response(`You made a request to ${request.url}`); +} + +addEventListener('fetch', (event) => { + event.respondWith(app(event)); +}); +``` + +**Constraint:** `event.respondWith()` must be called synchronously within the event listener. The handler passed to it can be async. + +## Runtime APIs (available in WASM) + +| Import | API | +|--------|-----| +| `fastedge::env` | `getEnv(name)` — environment variables | +| `fastedge::kv` | `KvStore.open(name)` → get, scan, zrangeByScore, zscan, bfExists | +| `fastedge::secret` | `getSecret(name)` — deployment-time secrets | +| `fastedge::fs` | Filesystem access for embedded static assets | +| Global | `fetch`, `Request`, `Response`, `Headers`, `URL`, `crypto`, streams, timers | + +## Development Setup + +### Prerequisites + +- Node >= 22, pnpm >= 10 +- Rust + `wasm32-wasi` target +- wasi-sdk v20 (at `/opt/wasi-sdk/`) +- binaryen, cbindgen, build-essential + +### First Time + +```sh +git submodule update --recursive --init +pnpm install +``` + +### Common Commands + +| Command | Purpose | +|---------|---------| +| `pnpm run build:js` | Build CLI + libs + types (no runtime rebuild) | +| `pnpm run build:dev` | Full build including runtime (slow) | +| `pnpm run test:unit:dev` | Fast unit tests | +| `pnpm run test:unit` | Unit tests + slow tests | +| `pnpm run test:integration` | Integration tests | +| `pnpm run typecheck` | Type checking only | +| `pnpm run lint` | ESLint | + +### Path Aliases (tsconfig.json) + +| Alias | Maps To | +|-------|---------| +| `~componentize/*` | `src/componentize/*` | +| `~utils/*` | `src/utils/*` | +| `~static-assets/*` | `src/server/static-assets/*` | +| `~fastedge-build/*` | `src/cli/fastedge-build/*` | +| `~fastedge-init/*` | `src/cli/fastedge-init/*` | +| `~fastedge-assets/*` | `src/cli/fastedge-assets/*` | +| `~constants/*` | `src/constants/*` | + +## Project Configuration + +- **`.fastedge/build-config.js`** — Per-project build config (created by `fastedge-init`) +- **`tsconfig.json`** — Main TypeScript config (noEmit, path aliases) +- **`tsconfig.build.json`** — Type declaration generation +- **`tsconfig.typecheck.json`** — Type checking without emit + +## Philosophy + +- **WASM Component Model** — produces standard components (not core modules) +- **StarlingMonkey runtime** — SpiderMonkey-based (not V8, not QuickJS) +- **Type-safe** — TypeScript throughout, esbuild for bundling, tsc for types only +- **Service Worker API** — familiar pattern for web developers +- **Edge-first** — designed for cold-start performance via Wizer pre-initialization diff --git a/context/architecture/COMPONENTIZE_PIPELINE.md b/context/architecture/COMPONENTIZE_PIPELINE.md new file mode 100644 index 0000000..c725733 --- /dev/null +++ b/context/architecture/COMPONENTIZE_PIPELINE.md @@ -0,0 +1,159 @@ +# Componentize Pipeline + +## Overview + +The componentize pipeline is the core of `fastedge-build`. It converts a JavaScript/TypeScript source file into a WASM Component Model binary that can run on the FastEdge edge platform. + +**Entry point:** `src/componentize/componentize.ts` → `componentize(jsInput, output, opts)` +**Called by:** `src/cli/fastedge-build/config-build.ts` → `buildWasm()` / `buildFromConfig()` + +## Pipeline Stages + +``` + ┌─────────────────┐ + │ JS/TS Source │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + Stage 1 │ esbuild Bundle │ getJsInputContents() + └────────┬────────┘ + │ + ┌────────▼────────┐ + Stage 2 │ Regex Precompile│ precompile() + └────────┬────────┘ + │ + ┌────────▼────────┐ + Stage 3 │ Wizer Init │ spawnSync(wizer, ...) + │ + fastedge- │ + │ runtime.wasm │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + Stage 4 │ JCO Component │ componentNew() + │ + preview1 │ + │ adapter.wasm │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Output .wasm │ + └─────────────────┘ +``` + +### Stage 1: esbuild Bundling + +**File:** `src/componentize/get-js-input.ts` → `getJsInputContents()` +**Bundler:** `src/componentize/es-bundle.ts` → `esBundle()` + +Bundles the user's JS/TS source into a single file using esbuild. This resolves all imports and produces a self-contained script ready for WASM embedding. + +- Controlled by `preBundleJSInput` option (default: `true`) +- If `false`, reads the file directly without bundling + +### Stage 2: Regex Precompilation + +**File:** `src/componentize/precompile.ts` → `precompile()` +**Origin:** Adapted from Fastly's `js-compute-runtime` (Apache 2.0) + +**Why this exists:** SpiderMonkey inside StarlingMonkey has limited regex support. Unicode property escapes (`\p{...}`) and other advanced regex features need transformation to ASCII-compatible equivalents before the runtime can handle them. + +**How it works:** +1. Parses JS source with `acorn` (AST) +2. Walks AST with `acorn-walk` finding all regex literals +3. Transforms each regex using `regexpu-core` (Unicode → ASCII) +4. Uses `magic-string` to replace regexes in-place +5. Prepends a precompilation block that exercises each regex with ASCII and UTF-8 strings — this ensures SpiderMonkey interns and compiles them during Wizer initialization rather than at request time + +### Stage 3: Wizer Pre-initialization + +**Tool:** `@bytecodealliance/wizer` +**Input:** `lib/fastedge-runtime.wasm` (StarlingMonkey engine) + bundled JS +**Output:** Pre-initialized core WASM module + +Wizer creates a snapshot of the StarlingMonkey runtime with the user's JavaScript already loaded and executed up to the point of the `addEventListener('fetch', ...)` call. This eliminates cold-start JavaScript parsing and initialization at request time. + +**Key flags:** +- `--allow-wasi` — enables WASI imports +- `--wasm-bulk-memory=true` — required for StarlingMonkey +- `--wasm-reference-types=true` — required for StarlingMonkey +- `--inherit-env=true` — passes environment to initialization +- `-r _start=wizer.resume` — entry point mapping + +**Environment variables:** +- `ENABLE_PBL` — enables pre-built library mode (optional) + +### Stage 4: JCO Component Composition + +**Tool:** `@bytecodealliance/jco` → `componentNew()` +**Adapter:** `lib/preview1-adapter.wasm` + +Wraps the Wizer output (a core WASM module) into a WASM Component Model component by attaching the WASI preview1 adapter. This makes the module compatible with any WASM Component Model host (including the FastEdge CDN's Wasmtime runtime). + +After composition, `addWasmMetadata()` (`src/componentize/add-wasm-metadata.ts`) stamps the component with package metadata from `package.json`. + +## Build Types + +### HTTP Handler (`type: 'http'`) + +Standard path: source → componentize → output.wasm + +### Static Site (`type: 'static'`) + +Extended path: source → `createStaticAssetsManifest()` → componentize → output.wasm + +For static builds, `createStaticAssetsManifest()` (`src/server/static-assets/asset-manifest/create-manifest.ts`) scans the project's static files, inlines them into a manifest, and generates an entry point that serves them using `create-static-server.ts`. + +## Configuration + +### CLI Arguments (`fastedge-build`) + +``` +fastedge-build # Direct mode +fastedge-build --input --output # Named options +fastedge-build --config # Config-driven +fastedge-build -c # Default config (.fastedge/build-config.js) +``` + +### BuildConfig Interface + +```typescript +// src/cli/fastedge-build/types.ts +interface BuildConfig extends Partial { + type?: 'static' | 'http'; + entryPoint: string; + wasmOutput: string; + tsConfigPath?: string; +} +``` + +### ComponentizeOptions + +```typescript +// src/componentize/componentize.ts +interface ComponentizeOptions { + debug?: boolean; + wasmEngine?: string; // default: lib/fastedge-runtime.wasm + enableStdout?: boolean; + enablePBL?: boolean; // pre-built library mode + preBundleJSInput?: boolean; // default: true +} +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `src/componentize/componentize.ts` | Main pipeline orchestrator | +| `src/componentize/get-js-input.ts` | Stage 1: esbuild bundling | +| `src/componentize/es-bundle.ts` | esbuild configuration for user code | +| `src/componentize/precompile.ts` | Stage 2: regex precompilation | +| `src/componentize/add-wasm-metadata.ts` | Post-Stage 4: metadata stamping | +| `src/cli/fastedge-build/build.ts` | CLI entry point (arg parsing) | +| `src/cli/fastedge-build/config-build.ts` | Config-driven build orchestration | +| `src/cli/fastedge-build/types.ts` | BuildConfig type definition | +| `lib/fastedge-runtime.wasm` | StarlingMonkey engine (built artifact) | +| `lib/preview1-adapter.wasm` | WASI preview1 adapter (built artifact) | + +## Testing + +- **Unit tests:** `src/componentize/__tests__/` — tests for add-wasm-metadata, componentize, get-js-input +- **Integration tests:** `integration-tests/` — tests full CLI build output (fastedge-build.test.js, generates-output.test.js) diff --git a/context/architecture/RUNTIME_ARCHITECTURE.md b/context/architecture/RUNTIME_ARCHITECTURE.md new file mode 100644 index 0000000..15ec890 --- /dev/null +++ b/context/architecture/RUNTIME_ARCHITECTURE.md @@ -0,0 +1,167 @@ +# Runtime Architecture + +## Overview + +The FastEdge JS SDK includes a custom JavaScript runtime built on Mozilla's SpiderMonkey engine (via the StarlingMonkey project). This runtime compiles to WebAssembly and provides the execution environment for FastEdge applications on the edge platform. + +The runtime lives in `runtime/` and produces two key artifacts: +- `lib/fastedge-runtime.wasm` — the JavaScript engine + custom builtins +- `lib/preview1-adapter.wasm` — WASI preview1 compatibility adapter + +## StarlingMonkey + +**Location:** `runtime/StarlingMonkey/` (git submodule) +**What it is:** Mozilla SpiderMonkey (Firefox's JS engine) compiled to `wasm32-wasip1` +**What it provides:** Standard Web API implementations — `fetch`, `Request`, `Response`, `Headers`, `URL`, `crypto`, streams, timers, `TextEncoder`/`TextDecoder`, etc. + +StarlingMonkey is an upstream open-source project. FastEdge extends it with custom builtins (see below) rather than modifying it directly. + +## Custom Builtins (C++) + +FastEdge adds three builtin modules on top of StarlingMonkey, registered via CMake's `add_builtin()`: + +### fastedge.cpp — FastEdge Global Object + +**File:** `runtime/fastedge/builtins/fastedge.cpp` +**CMake name:** `fastedge::fastedge` + +Provides three sub-objects on the `fastedge` global: + +| Object | JS API | Function | +|--------|--------|----------| +| `fastedge.env` | `getEnv(name)` | Read environment variables via host API | +| `fastedge.fs` | `readFileSync(path)` | Read embedded files (INIT_ONLY — runs during Wizer pre-init) | +| `fastedge.secret` | `getSecret(name)` | Read deployment-time secrets via host API | + +**Note:** `readFileSync` is marked `INIT_ONLY` — it can only be called during Wizer initialization, not at request time. This is how static assets get embedded into the WASM binary. + +### kv-store.cpp — Key-Value Store + +**File:** `runtime/fastedge/builtins/kv-store.cpp` +**CMake name:** `fastedge::kv_store` + +Provides the `KvStore` class: + +| Method | Purpose | +|--------|---------| +| `KvStore.open(name)` | Open a named KV store (static factory) | +| `.get(key)` | Get value by key | +| `.scan(cursor)` | Iterate keys | +| `.zrangeByScore(key, min, max)` | Sorted set range query | +| `.zscan(key, cursor)` | Sorted set scan | +| `.bfExists(key, item)` | Bloom filter membership test | + +### console-override.cpp — Console Override + +**File:** `runtime/fastedge/builtins/console-override.cpp` +**CMake name:** `fastedge::console_override` + +Overrides the default `console` implementation to route logging through the FastEdge host API. + +## Host API (C++ → WIT Bridge) + +**Location:** `runtime/fastedge/host-api/` + +The host API layer bridges C++ builtins to the WIT-defined host imports. When a builtin like `getEnv()` is called, it invokes `host_api::get_env_vars()` which maps to a WIT import that the FastEdge CDN runtime (Wasmtime) fulfills. + +**Key files:** +- `fastedge_host_api.cpp` — host API implementation +- `include/fastedge_host_api.h` — C++ header +- `bindings/` — auto-generated WIT bindings (cbindgen) +- `wit/` — local WIT definitions for the host API world +- `src/` — additional host API source + +## WIT World Definition + +**Location:** `runtime/FastEdge-wit/world.wit` + +```wit +package gcore:fastedge; + +world reactor { + import http; + import http-client; + import dictionary; + import secret; + import key-value; + + export http-handler; +} +``` + +### Imports (host provides to WASM) + +| Interface | WIT File | Purpose | +|-----------|----------|---------| +| `http` | `http.wit` | HTTP request/response types | +| `http-client` | `http-client.wit` | Outbound HTTP requests (`fetch`) | +| `dictionary` | `dictionary.wit` | Environment variable dictionary | +| `secret` | `secret.wit` | Secret retrieval | +| `key-value` | `key-value.wit` | KV store operations | + +### Exports (WASM provides to host) + +| Interface | WIT File | Purpose | +|-----------|----------|---------| +| `http-handler` | `http-handler.wit` | Handle incoming HTTP requests | + +### WIT Generation Scripts + +- `runtime/fastedge/scripts/merge-wit-bindings.js` — merges WIT from multiple sources +- `runtime/fastedge/scripts/create-wit-bindings.sh` — generates C bindings from WIT +- Run via: `pnpm run generate:wit-world` (runs merge then bindings) + +## Build Process + +### Local Build + +```sh +# Debug build (faster, larger output) +pnpm run build:monkey:dev +# → runtime/fastedge/build.sh --debug + +# Release build +pnpm run build:monkey:prod +# → runtime/fastedge/build.sh +``` + +### What build.sh Does + +1. Sets build type (`Debug` or `Release`) +2. Runs CMake configure: `HOST_API=$(realpath host-api) cmake -B build-{type}` +3. Runs CMake build: `cmake --build build-{type} --parallel 8` +4. Copies outputs: + - `build-{type}/starling-raw.wasm/starling-raw.wasm` → `lib/fastedge-runtime.wasm` + - `build-{type}/starling-raw.wasm/preview1-adapter.wasm` → `lib/preview1-adapter.wasm` + +### Prerequisites for Runtime Build + +| Dependency | Version | Install | +|------------|---------|---------| +| Rust | latest stable | `curl -so rust.sh https://sh.rustup.rs && sh rust.sh -y` | +| wasm32-wasi target | — | `rustup target add wasm32-wasi` | +| wasi-sdk | v20 | Download from GitHub, install to `/opt/wasi-sdk/` | +| binaryen | — | `sudo apt install binaryen` | +| cbindgen | — | `cargo install cbindgen` | +| build-essential | — | `sudo apt install build-essential` | +| CMake | >= 3.27 | Required by CMakeLists.txt | + +### Docker Compiler + +**Location:** `compiler/` + +For CI/CD, a Docker image encapsulates all native build dependencies. See `compiler/README.md` for details. + +## Key Directories + +| Directory | Contents | +|-----------|----------| +| `runtime/StarlingMonkey/` | SpiderMonkey WASM engine (git submodule) | +| `runtime/fastedge/builtins/` | C++ builtin implementations (3 files) | +| `runtime/fastedge/host-api/` | Host API bridge (C++ → WIT) | +| `runtime/fastedge/host-api/wit/` | Local WIT definitions for host API | +| `runtime/fastedge/scripts/` | WIT binding generation scripts | +| `runtime/fastedge/build-debug/` | Debug build artifacts | +| `runtime/fastedge/build-release/` | Release build artifacts | +| `runtime/FastEdge-wit/` | Top-level WIT world definition | +| `compiler/` | Docker build environment | diff --git a/context/development/BUILD_SYSTEM.md b/context/development/BUILD_SYSTEM.md new file mode 100644 index 0000000..d026d9a --- /dev/null +++ b/context/development/BUILD_SYSTEM.md @@ -0,0 +1,106 @@ +# Build System + +## Overview + +The SDK uses **esbuild** for bundling and **tsc** (TypeScript compiler) for type checking and type declaration generation only. There are no Webpack or Rollup configurations. + +## Build Scripts + +### Full Builds + +| Command | What It Does | +|---------|-------------| +| `pnpm run build:js` | Build CLI + libs + types (no runtime) — runs typecheck first, then parallel: build:cli, build:libs, build:types | +| `pnpm run build:dev` | Full build including runtime — parallel: build:js + build:monkey:dev | +| `pnpm run build:monkey:dev` | Runtime only (debug) — `runtime/fastedge/build.sh --debug` | +| `pnpm run build:monkey:prod` | Runtime only (release) — `runtime/fastedge/build.sh` | + +### Individual Builds + +| Command | What It Does | +|---------|-------------| +| `pnpm run build:cli` | CLI binaries only — `esbuild/cli-binaries.js` | +| `pnpm run build:libs` | Library code only — `esbuild/fastedge-libs.js` | +| `pnpm run build:types` | Type declarations only — `tsc -p tsconfig.build.json` | +| `pnpm run build:type-references` | Types + fastedge reference injection | +| `pnpm run typecheck` | Type checking without emit — `tsc -p tsconfig.typecheck.json` | + +### Other + +| Command | What It Does | +|---------|-------------| +| `pnpm run generate:wit-world` | Regenerate WIT bindings (merge + create) | +| `pnpm run lint` | ESLint with repo config | +| `pnpm run semantic-release` | Automated release | + +## esbuild Configuration + +### CLI Binaries (`esbuild/cli-binaries.js`) + +Builds 3 CLI tools from TypeScript source to bundled ESM: + +| Entry Point | Output | +|-------------|--------| +| `src/cli/fastedge-assets/asset-cli.ts` | `bin/fastedge-assets.js` | +| `src/cli/fastedge-build/build.ts` | `bin/fastedge-build.js` | +| `src/cli/fastedge-init/init.ts` | `bin/fastedge-init.js` | + +**Settings:** `platform: "node"`, `format: "esm"`, `bundle: true` + +**External packages** (not bundled — resolved at runtime): +`@bytecodealliance/wizer`, `@bytecodealliance/jco`, `esbuild`, `enquirer`, `regexpu-core`, `acorn`, `magic-string`, `acorn-walk` + +After bundling, prepends `#!/usr/bin/env node` shebang to each output file. + +### Library Code (`esbuild/fastedge-libs.js`) + +Builds library exports: + +| Entry Point | Output | +|-------------|--------| +| `src/server/static-assets/static-server/create-static-server.ts` | `lib/create-static-server.js` | + +**Settings:** `format: "esm"`, `bundle: true` +**External:** `fastedge::fs` (runtime module, not a Node package) + +Also generates `lib/index.js` as a re-export barrel file. + +## TypeScript Configuration + +### Three Configs + +| Config | Purpose | Emit | +|--------|---------|------| +| `tsconfig.json` | Main config — IDE, path aliases, includes | `noEmit: true` | +| `tsconfig.build.json` | Type declaration generation | Generates `.d.ts` files | +| `tsconfig.typecheck.json` | Type checking in CI | `noEmit: true` | + +### Path Aliases + +Defined in `tsconfig.json` and mirrored in `config/jest/jest.config.js`: + +| Alias | Maps To | +|-------|---------| +| `~componentize/*` | `src/componentize/*` | +| `~constants/*` | `src/constants/*` | +| `~fastedge-assets/*` | `src/cli/fastedge-assets/*` | +| `~fastedge-build/*` | `src/cli/fastedge-build/*` | +| `~fastedge-init/*` | `src/cli/fastedge-init/*` | +| `~static-assets/*` | `src/server/static-assets/*` | +| `~utils/*` | `src/utils/*` | + +**Important:** When adding a new path alias, update both `tsconfig.json` and `config/jest/jest.config.js`. + +## npm Package Contents + +The `files` field in `package.json` controls what ships to npm: + +``` +types/ — TypeScript declarations +bin/*.js — 3 CLI tools +lib/*.wasm — fastedge-runtime.wasm + preview1-adapter.wasm +lib/*.js — create-static-server.js + index.js +README.md +``` + +**Not shipped:** `src/`, `runtime/`, `config/`, `esbuild/`, `examples/`, `github-pages/`, `integration-tests/` diff --git a/context/development/TESTING_GUIDE.md b/context/development/TESTING_GUIDE.md new file mode 100644 index 0000000..8c99fba --- /dev/null +++ b/context/development/TESTING_GUIDE.md @@ -0,0 +1,91 @@ +# Testing Guide + +## Test Framework + +- **Jest** v29 with `babel-jest` transform +- **Config:** `config/jest/jest.config.js` +- **Babel:** `@babel/preset-env` + `@babel/preset-typescript` +- **Environment:** Node.js + +## Running Tests + +| Command | Purpose | +|---------|---------| +| `pnpm run test:unit:dev` | Unit tests (fast — excludes slow tests) | +| `pnpm run test:unit` | Unit tests + slow tests (`RUN_SLOW_TESTS=true`) | +| `pnpm run test:integration` | Integration tests only | +| `pnpm run test:solo -- ` | Run a specific test file | + +All test commands use `NODE_ENV=test` and the shared Jest config. + +## Test Organization + +### Unit Tests + +Co-located with source in `src/**/__tests__/*.test.ts`: + +``` +src/ +├── componentize/ +│ └── __tests__/ +│ ├── add-wasm-metadata.test.ts +│ ├── componentize.test.ts +│ └── get-js-input.test.ts +├── utils/ +│ └── __tests__/ +│ ├── color-log.test.ts +│ ├── config-helpers.test.ts +│ ├── content-types.test.ts +│ ├── deep-copy.test.ts +│ ├── file-info.test.ts +│ ├── file-system.test.ts +│ └── input-path-verification.test.ts +└── server/static-assets/ + └── (multiple __tests__/ dirs) + ├── asset-cache.test.ts + ├── create-manifest.test.ts + ├── create-static-server.test.ts + ├── headers.test.ts + ├── static-server.test.ts + └── ... +``` + +### Integration Tests + +Located in `integration-tests/`: + +``` +integration-tests/ +├── fastedge-build.test.js — CLI argument parsing + build modes +├── fastedge-assets.test.js — Asset manifest CLI +├── generates-output.test.js — Full build produces valid WASM +├── test-application/ — Fixture app for build tests +└── test-files/ — Test fixture files +``` + +Integration tests exercise the full CLI tools end-to-end using `@gmrchk/cli-testing-library`. + +### Mocks + +Co-located in `src/**/__mocks__/` directories. Jest auto-discovers these for module mocking. + +## Jest Configuration Details + +**Path aliases** are mapped in the Jest config to match `tsconfig.json`: +```js +moduleNameMapper: { + '^~componentize/(.*)$': '/src/componentize/$1', + '^~utils/(.*)$': '/src/utils/$1', + // ... etc +} +``` + +**Ignored paths:** `node_modules`, `dist`, `github-pages`, `runtime/StarlingMonkey/`, `runtime/fastedge/deps` + +## Writing Tests + +1. **Follow the co-located pattern** — put `__tests__/` next to the source file +2. **Import from `@jest/globals`** for test functions +3. **Use `__mocks__/`** for module-level mocks +4. **Guard slow tests** with `process.env.RUN_SLOW_TESTS` check if appropriate +5. **Integration tests** go in `integration-tests/` and test full CLI output diff --git a/docs/ASSETS_CLI.md b/docs/ASSETS_CLI.md new file mode 100644 index 0000000..d9c5af9 --- /dev/null +++ b/docs/ASSETS_CLI.md @@ -0,0 +1,260 @@ +# fastedge-assets CLI + +Generates a static asset manifest file that maps a directory of static files for embedding into a FastEdge WebAssembly binary. + +## Overview + +`fastedge-assets` scans a source directory and produces a JavaScript/TypeScript module exporting a `staticAssetManifest` object. This manifest is consumed at compile time by `fastedge-build` to embed file contents directly into the WASM binary via Wizer processing. The embedded assets are available at runtime without a file system. + +Run `fastedge-assets` as a pre-build step, before invoking `fastedge-build`. + +## Usage + +```sh +# Positional arguments +npx fastedge-assets +npx fastedge-assets ./public ./src/manifest.ts + +# Named flags +npx fastedge-assets --input ./public --output ./src/manifest.ts + +# Config-driven +npx fastedge-assets --config .fastedge/asset-config.js + +# Help and version +npx fastedge-assets --help +npx fastedge-assets --version +``` + +## Flags + +| Flag | Alias | Type | Description | +| ----------------- | ----- | --------- | ----------------------------------------------------------------------------- | +| `--input ` | `-i` | `string` | Path to the directory of source assets (e.g. `./public`) | +| `--output ` | `-o` | `string` | Output file path for the generated manifest (e.g. `./src/asset-manifest.ts`) | +| `--config ` | `-c` | `string` | Path to an asset config file containing `AssetCacheConfig` fields | +| `--version` | `-v` | `boolean` | Print the package version | +| `--help` | `-h` | `boolean` | Print usage information | + +**Notes:** + +- `--input` must resolve to an existing directory. +- `--output` must resolve to a file path (not a directory). The output file does not need to pre-exist; parent directories are created automatically. +- When `--config` is provided, `publicDir` and `assetManifestPath` from the config file serve as defaults if `--input` or `--output` are not specified on the command line. +- If the output path does not end with `.js`, `.ts`, `.cjs`, or `.mjs`, a `.js` extension is appended automatically. + +## Config File + +When using `--config`, the config file must export an object conforming to `AssetCacheConfig`: + +```ts +interface AssetCacheConfig extends Record { + publicDir: string; + assetManifestPath: string; + contentTypes: Array; + ignoreDotFiles: boolean; + ignorePaths: string[]; + ignoreWellKnown: boolean; +} +``` + +Example config (`.fastedge/asset-config.js`): + +```js +export default { + publicDir: './public', + assetManifestPath: './.fastedge/build/static-asset-manifest.js', + ignoreDotFiles: true, + ignoreWellKnown: false, + ignorePaths: ['./public/drafts'], + contentTypes: [ + { + test: /\.webp$/u, + contentType: 'image/webp', + isText: false, + }, + ], +}; +``` + +### Config Fields + +| Field | Type | Description | +| ------------------- | ------------------------- | ---------------------------------------------------------------------- | +| `publicDir` | `string` | Directory to scan for static files | +| `assetManifestPath` | `string` | Output manifest file path | +| `contentTypes` | `ContentTypeDefinition[]` | Custom content type matchers prepended before built-in defaults | +| `ignoreDotFiles` | `boolean` | When `true`, excludes files and directories whose names begin with `.` | +| `ignorePaths` | `string[]` | Additional paths to exclude from the manifest | +| `ignoreWellKnown` | `boolean` | When `true`, excludes the `.well-known` directory | + +### ContentTypeDefinition + +```ts +interface ContentTypeDefinition { + test: RegExp | ((assetKey: string) => boolean); + contentType: string; + isText: boolean; +} +``` + +| Field | Type | Description | +| ------------- | ------------------------------------------- | ----------------------------------------------------------------------- | +| `test` | `RegExp \| ((assetKey: string) => boolean)` | Pattern or function matched against each asset's URL path | +| `contentType` | `string` | MIME type assigned to matched assets (e.g. `"image/webp"`) | +| `isText` | `boolean` | Whether the file should be treated as text (`true`) or binary (`false`) | + +Custom content types are evaluated before built-in defaults. The first matching entry wins. + +## Manifest Structure + +The generated file exports a `staticAssetManifest` constant: + +```ts +type StaticAssetManifest = Record; + +interface StaticAssetMetadata { + type: string; + assetKey: string; + contentType: string; + isText: boolean; + fileInfo: FileInfo; +} + +interface FileInfo { + hash: string; + size: number; + assetPath: string; + lastModifiedTime: number; +} +``` + +| Field | Type | Description | +| --------------------------- | --------- | --------------------------------------------------------- | +| `type` | `string` | Always `"wasm-inline"` for assets generated by this tool | +| `assetKey` | `string` | URL path key used to look up the asset at runtime | +| `contentType` | `string` | MIME type (e.g. `"text/css"`, `"image/png"`) | +| `isText` | `boolean` | Whether the content is text or binary | +| `fileInfo.hash` | `string` | SHA-256 hex hash of the file contents | +| `fileInfo.size` | `number` | File size in bytes | +| `fileInfo.assetPath` | `string` | Original file path at the time the manifest was generated | +| `fileInfo.lastModifiedTime` | `number` | File last-modified timestamp (Unix seconds) | + +Example generated output: + +```js +/* + * DO NOT EDIT THIS FILE - Generated by @gcoredev/FastEdge-sdk-js + * + * It will be overwritten on the next build. + */ + +const staticAssetManifest = { + '/index.css': { + assetKey: '/index.css', + contentType: 'text/css', + isText: true, + fileInfo: { + size: 466, + hash: 'e17878bc37ca054789c91f5c24e6044a077e172d2c65454a71269a82e61ee686', + lastModifiedTime: 1768985591, + assetPath: './styles/index.css', + }, + lastModifiedTime: 1768985591, + type: 'wasm-inline', + }, +}; + +export { staticAssetManifest }; +``` + +## Default Content Types + +The following MIME types are detected automatically by file extension. Custom `contentTypes` entries are checked first. + +| Extension(s) | Content-Type | Text | +| --------------------- | ------------------------------- | ----- | +| `.txt` | `text/plain` | yes | +| `.html`, `.htm` | `text/html` | yes | +| `.xml` | `application/xml` | yes | +| `.json` | `application/json` | yes | +| `.map` | `application/json` | yes | +| `.js` | `application/javascript` | yes | +| `.ts` | `application/typescript` | yes | +| `.css` | `text/css` | yes | +| `.svg` | `image/svg+xml` | yes | +| `.bmp` | `image/bmp` | no | +| `.png` | `image/png` | no | +| `.gif` | `image/gif` | no | +| `.jpg`, `.jpeg` | `image/jpeg` | no | +| `.ico` | `image/vnd.microsoft.icon` | no | +| `.tif`, `.tiff` | `image/png` | no | +| `.aac` | `audio/aac` | no | +| `.mp3` | `audio/mpeg` | no | +| `.avi` | `video/x-msvideo` | no | +| `.mp4` | `video/mp4` | no | +| `.mpeg` | `video/mpeg` | no | +| `.webm` | `video/webm` | no | +| `.pdf` | `application/pdf` | no | +| `.tar` | `application/x-tar` | no | +| `.zip` | `application/zip` | no | +| `.eot` | `application/vnd.ms-fontobject` | no | +| `.otf` | `font/otf` | no | +| `.ttf` | `font/ttf` | no | +| `.woff` | `font/woff` | no | +| `.woff2` | `font/woff2` | no | +| (no match) | `application/octet-stream` | no | + +## When to Use + +**Use `fastedge-assets` directly when:** + +- You are building an HTTP app and need to embed specific asset directories individually. +- You want separate manifests per asset group (e.g. one manifest for images, another for templates). +- You need control over which directories are scanned and what the output file is named. + +**You do not need to run `fastedge-assets` directly when:** + +- You are using `fastedge-build` with a `static` build type configured in a `build-config.js`. In that mode, `fastedge-build` invokes the manifest generator automatically as part of the build pipeline. + +## Example: Multiple Manifests in One Project + +The following `package.json` generates three separate manifests before building: + +```json +{ + "scripts": { + "build": "npm-run-all -s create-styles-manifest create-images-manifest create-templates-manifest build:wasm", + "create-styles-manifest": "npx fastedge-assets ./styles src/styles-static-assets.ts", + "create-images-manifest": "npx fastedge-assets ./images src/images-static-assets.ts", + "create-templates-manifest": "npx fastedge-assets ./templates src/templates-static-assets.ts", + "build:wasm": "npx fastedge-build -i ./src/index.tsx -o ./dist/app.wasm -t ./tsconfig.json" + } +} +``` + +Each generated manifest is imported and passed to `createStaticServer` at the top level of your entry point. Because Wizer snapshots all top-level code before creating the binary, `createStaticServer` must be called at the top level — not inside a function or async handler. + +```ts +/// +import { createStaticServer } from '@gcoredev/fastedge-sdk-js'; +import { staticAssetManifest as imageManifest } from './images-static-assets.js'; +import { staticAssetManifest as styleManifest } from './styles-static-assets.js'; +import { staticAssetManifest as templateManifest } from './templates-static-assets.js'; + +// Called at top level so Wizer embeds assets into the binary at compile time +const imageServer = createStaticServer(imageManifest, { routePrefix: '/images' }); +const styleServer = createStaticServer(styleManifest, { routePrefix: '/styles' }); +const templateServer = createStaticServer(templateManifest, {}); + +addEventListener('fetch', (event) => { + event.respondWith(imageServer.serveRequest(event.request)); +}); +``` + +## See Also + +- [Static Sites](STATIC_SITES.md) — full static site workflow with embedded assets +- [fastedge-build CLI](BUILD_CLI.md) — compile JavaScript/TypeScript to WebAssembly +- [fastedge-init CLI](INIT_CLI.md) — scaffold a new FastEdge project +- [SDK Runtime API](SDK_API.md) — runtime APIs available inside FastEdge applications diff --git a/docs/BUILD_CLI.md b/docs/BUILD_CLI.md new file mode 100644 index 0000000..620f631 --- /dev/null +++ b/docs/BUILD_CLI.md @@ -0,0 +1,205 @@ +# fastedge-build CLI + +Compile JavaScript or TypeScript source code into a WebAssembly component for deployment on the FastEdge platform. + +## Usage + +```bash +# Direct mode: specify input and output +npx fastedge-build +npx fastedge-build src/index.js app.wasm + +# Named options +npx fastedge-build --input src/index.js --output app.wasm + +# Config-driven build +npx fastedge-build --config .fastedge/build-config.js +npx fastedge-build -c # uses default config path + +# Multiple configs +npx fastedge-build -c config1.js -c config2.js + +# Help and version +npx fastedge-build --help +npx fastedge-build --version +``` + +## Options + +| Flag | Alias | Type | Description | +| ------------ | ----- | ---------- | -------------------------------- | +| `--input` | `-i` | `String` | Input JavaScript/TypeScript file | +| `--output` | `-o` | `String` | Output WebAssembly file path | +| `--tsconfig` | `-t` | `String` | Path to tsconfig.json | +| `--config` | `-c` | `String[]` | Path(s) to build config files | +| `--help` | `-h` | `Boolean` | Show help | +| `--version` | `-v` | `Boolean` | Show version | + +## Build Modes + +### Direct Build + +For standard HTTP handler applications, pass input and output paths directly: + +```bash +npx fastedge-build src/index.js output.wasm +``` + +This runs the full compilation pipeline: + +1. **esbuild** bundles your JS/TS into a single file +2. **Regex precompilation** transforms Unicode regex for the SpiderMonkey engine +3. **Wizer** pre-initializes the StarlingMonkey runtime with your code +4. **JCO** wraps the result into a WebAssembly Component Model binary + +The `--input` and `--output` flags are equivalent to positional arguments: + +```bash +npx fastedge-build --input src/index.js --output app.wasm +``` + +To specify a TypeScript config in direct mode, use `--tsconfig`: + +```bash +npx fastedge-build --input src/index.ts --output app.wasm --tsconfig tsconfig.json +``` + +### Config-Driven Build + +For projects using a build config file (created by `fastedge-init`): + +```bash +npx fastedge-build --config .fastedge/build-config.js +``` + +Config-driven builds support both `http` and `static` build types. Multiple config files are processed sequentially, each producing its own output `.wasm` file: + +```bash +npx fastedge-build -c config-api.js -c config-site.js +``` + +Running `-c` with no path uses the default config file location resolved at runtime. + +## Build Configuration + +The config file exports a `config` object matching the `BuildConfig` interface. + +### HTTP Config + +```js +const config = { + type: 'http', + entryPoint: './src/index.js', + wasmOutput: './dist/app.wasm', + tsConfigPath: './tsconfig.json', // optional +}; + +export { config }; +``` + +### Static Site Config + +```js +const config = { + type: 'static', + entryPoint: '.fastedge/static-index.js', + wasmOutput: './dist/app.wasm', + publicDir: './public', + assetManifestPath: './src/manifest.ts', + ignoreDotFiles: true, + ignorePaths: ['./node_modules'], + ignoreWellKnown: false, +}; + +export { config }; +``` + +### BuildConfig Fields + +| Field | Type | Required | Description | +| -------------- | -------------------- | -------- | -------------------------------------------------- | +| `type` | `'http' \| 'static'` | No | Build type; must be `http` or `static` if provided | +| `entryPoint` | `string` | Yes | Input JavaScript/TypeScript file | +| `wasmOutput` | `string` | Yes | Output WASM file path | +| `tsConfigPath` | `string` | No | Path to tsconfig.json | + +### Static-Only Fields + +When `type` is `'static'`, the following fields from `AssetCacheConfig` apply: + +| Field | Type | Required | Description | +| ------------------- | ------------------------------ | -------- | -------------------------------------------- | +| `publicDir` | `string` | Yes | Directory containing static files to embed | +| `assetManifestPath` | `string` | Yes | Output path for the generated asset manifest | +| `contentTypes` | `Array` | No | Custom content type mappings | +| `ignoreDotFiles` | `boolean` | No | Skip files beginning with `.` | +| `ignorePaths` | `string[]` | No | Paths to exclude from the manifest | +| `ignoreWellKnown` | `boolean` | No | Skip the `.well-known/` directory | + +### ContentTypeDefinition + +```typescript +interface ContentTypeDefinition { + test: RegExp | ((assetKey: string) => boolean); + contentType: string; + isText: boolean; +} +``` + +Custom content-type rules are merged with the built-in defaults. Each rule matches asset paths using either a `RegExp` or a predicate function. Custom rules are evaluated before built-in rules. + +## Build Types + +### `type: 'http'` + +Runs the standard pipeline: esbuild → Wizer → JCO. Produces a single `.wasm` component from the entry point. + +```js +const config = { + type: 'http', + entryPoint: 'src/handler.ts', + wasmOutput: 'dist/handler.wasm', +}; + +export { config }; +``` + +### `type: 'static'` + +Generates a static asset manifest from `publicDir`, embeds it, then runs the standard pipeline. The entry point must read from the manifest at runtime to serve files. + +```js +const config = { + type: 'static', + entryPoint: 'src/static-handler.ts', + wasmOutput: 'dist/static-handler.wasm', + publicDir: 'public', + assetManifestPath: 'src/asset-manifest.ts', + ignoreDotFiles: true, + ignorePaths: ['node_modules'], + ignoreWellKnown: false, +}; + +export { config }; +``` + +If `type` is absent or does not match `'http'` or `'static'`, the build exits with an error. + +## Output + +A successful build writes a `.wasm` file to the path specified by `wasmOutput` (or `--output` in direct mode). The file is a WebAssembly Component Model component compatible with the FastEdge runtime. + +On success the CLI prints: + +``` +Success: Built +``` + +On failure it exits with a non-zero status code and prints an error message. + +## See Also + +- [INIT_CLI.md](INIT_CLI.md) — `fastedge-init` CLI for scaffolding new projects +- [ASSETS_CLI.md](ASSETS_CLI.md) — `fastedge-assets` CLI for asset manifest management +- [STATIC_SITES.md](STATIC_SITES.md) — guide for building and serving static sites +- [SDK_API.md](SDK_API.md) — runtime API reference for handler entry points diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..1272ff6 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,96 @@ +# FastEdge JS SDK Documentation + +The FastEdge JS SDK (`@gcoredev/fastedge-sdk-js`) is the JavaScript/TypeScript development toolkit for building serverless edge applications on Gcore's FastEdge platform. It compiles your code into WebAssembly components that run across global edge data centers. + +## Package + +| Field | Value | +| ----------- | --------------------------- | +| **npm** | `@gcoredev/fastedge-sdk-js` | +| **Version** | `2.1.0` | +| **Node** | `>=22` | +| **License** | `Apache-2.0` | + +## CLI Tools + +| Tool | Command | Purpose | +| ------------------- | ------------------------------------------ | ------------------------------- | +| **fastedge-build** | `npx fastedge-build ` | Compile JS/TS to WebAssembly | +| **fastedge-init** | `npx fastedge-init` | Interactive project scaffolding | +| **fastedge-assets** | `npx fastedge-assets ` | Generate static asset manifest | + +## Documentation + +| Document | Description | +| ------------------------------------ | -------------------------------------------- | +| [Quickstart](quickstart.md) | Installation and first build | +| [fastedge-build CLI](BUILD_CLI.md) | Compile JavaScript to WebAssembly | +| [fastedge-init CLI](INIT_CLI.md) | Scaffold a new FastEdge project | +| [fastedge-assets CLI](ASSETS_CLI.md) | Generate static asset manifests | +| [Static Sites](STATIC_SITES.md) | Serve static websites from WASM | +| [SDK Runtime API](SDK_API.md) | Environment, KV Store, Secrets, and Web APIs | + +## Application Model + +FastEdge apps use the Service Worker API pattern. The `addEventListener('fetch', ...)` call must be at the top level. The callback must synchronously call `event.respondWith()` with a handler that returns a `Response` (or `Promise`). + +```js +/// + +async function handler(event) { + const request = event.request; + return new Response(`Hello from the edge! You requested: ${request.url}`); +} + +addEventListener('fetch', (event) => { + event.respondWith(handler(event)); +}); +``` + +## Build Types + +| Type | Description | CLI | +| ---------- | ----------------------------------- | --------------------------------------------------- | +| **HTTP** | Standard request handler | `fastedge-build src/index.js output.wasm` | +| **Static** | Serve static files embedded in WASM | `fastedge-build --config .fastedge/build-config.js` | + +## Runtime APIs + +Runtime APIs are available via `fastedge::` module specifiers inside your application code. These imports are resolved at compile time by the SDK. + +### FastEdge APIs + +| Import | Export | Signature | +| ------------------- | ---------------------- | ----------------------------------------------------- | +| `fastedge::env` | `getEnv` | `(name: string): string \| null` | +| `fastedge::secret` | `getSecret` | `(name: string): string \| null` | +| `fastedge::secret` | `getSecretEffectiveAt` | `(name: string, effectiveAt: number): string \| null` | +| `fastedge::kv` | `KvStore.open` | `(name: string): KvStoreInstance` | + +### KvStoreInstance Methods + +| Method | Signature | Description | +| --------------- | ----------------------------------------------------------------------- | ------------------------------------------------------ | +| `get` | `(key: string): ArrayBuffer \| null` | Retrieve a value by key | +| `scan` | `(pattern: string): Array` | Retrieve keys matching a prefix pattern (e.g. `foo*`) | +| `zrangeByScore` | `(key: string, min: number, max: number): Array<[ArrayBuffer, number]>` | Retrieve sorted set entries by score range | +| `zscan` | `(key: string, pattern: string): Array<[ArrayBuffer, number]>` | Retrieve sorted set entries matching a prefix pattern | +| `bfExists` | `(key: string, value: string): boolean` | Check if a value exists in a Bloom Filter | + +### Web APIs + +Standard Web APIs available globally: + +- `fetch`, `Request`, `Response`, `Headers` +- `URL`, `URLSearchParams` +- `ReadableStream`, `WritableStream`, `TransformStream` +- `TextEncoder`, `TextDecoder` +- `crypto` (SubtleCrypto) +- `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval` + +## See Also + +- [GitHub Repository](https://github.com/G-Core/FastEdge-sdk-js) +- [npm Package](https://www.npmjs.com/package/@gcoredev/fastedge-sdk-js) +- [FastEdge Platform](https://gcore.com/fastedge) +- [Deployment Guide](https://gcore.com/docs/fastedge/getting-started/create-fastedge-apps#stage-2-deploy-an-app) diff --git a/docs/INIT_CLI.md b/docs/INIT_CLI.md new file mode 100644 index 0000000..16cb6ef --- /dev/null +++ b/docs/INIT_CLI.md @@ -0,0 +1,210 @@ +# fastedge-init CLI + +Interactive scaffolding tool that generates build configuration and starter files for a new FastEdge application. + +## Usage + +```bash +npx fastedge-init +``` + +`fastedge-init` has no flags. All configuration is collected interactively. Run it from the root of your project directory. + +If `.fastedge/build-config.js` already exists, the tool warns you and asks whether to overwrite it before proceeding. Answering `N` or pressing Enter exits without making changes. + +## Application Types + +The first prompt asks what you are building: + +| Option | Description | +| -------------------- | ------------------------------------------------- | +| `Http event-handler` | A request/response handler compiled to WASM | +| `Static website` | A static file server with assets embedded in WASM | + +## HTTP Handler Setup + +### Prompts + +| Prompt | Default | Validation | +| ------------------------ | -------------------------- | --------------------- | +| Path to your entry file | `src/index.js` | File must exist | +| Path to your output file | `.fastedge/dist/main.wasm` | Must end with `.wasm` | + +### Files Created + +| File | Description | +| --------------------------- | ------------------------------------- | +| `.fastedge/build-config.js` | Build and server configuration module | + +### Generated Config + +`.fastedge/build-config.js` exports two named objects using the values from the prompts: + +```js +const config = { + "type": "http", + "tsConfigPath": "./tsconfig.json", + "entryPoint": "src/index.js", + "wasmOutput": ".fastedge/dist/main.wasm" +}; + +const serverConfig = { + "type": "http" +}; + +export { config, serverConfig }; +``` + +### Entry File + +Your entry file must register a `fetch` event listener at the top level: + +```js +async function handleRequest(event) { + return new Response("Hello from FastEdge!", { + headers: { "Content-Type": "text/plain" }, + }); +} + +addEventListener("fetch", (event) => { + event.respondWith(handleRequest(event)); +}); +``` + +### Build Command + +After initialization, compile the application: + +```bash +npx fastedge-build --config .fastedge/build-config.js +``` + +## Static Website Setup + +### Prompts + +| Prompt | Default | Validation | +| ---------------------------------------- | ------------------------------ | ------------------------------------------- | +| Path to your output file | `.fastedge/dist/fastedge.wasm` | Must end with `.wasm` | +| Path to your public directory | `./build` | Directory must exist | +| Is your site a single page application? | `No` | — | +| Path to your SPA entrypoint *(SPA only)* | `./index.html` | File must exist inside the public directory | + +### Files Created + +| File | Description | +| --------------------------- | ---------------------------------------------------------- | +| `.fastedge/static-index.js` | Generated entry file that wires the static server together | +| `.fastedge/build-config.js` | Build and server configuration module | +| `.fastedge/package.json` | Marks `.fastedge/` as an ES module project | +| `.fastedge/jsconfig.json` | Sets the compiler target to ES6 for the project directory | + +### Generated Entry File + +`.fastedge/static-index.js` is generated automatically and should not be edited manually: + +```js +/* + * Generated by @gcoredev/FastEdge-sdk-js fastedge-init + */ + +import { createStaticServer } from "@gcoredev/fastedge-sdk-js"; +import { staticAssetManifest } from "./build/static-asset-manifest.js"; +import { serverConfig } from "./build-config.js"; + +const staticServer = createStaticServer(staticAssetManifest, serverConfig); + +async function handleRequest(event) { + const response = await staticServer.serveRequest(event.request); + if (response != null) { + return response; + } + + return new Response("Not found", { status: 404 }); +} + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); +``` + +### Generated Config + +`.fastedge/build-config.js` exports two named objects using the values from the prompts: + +```js +const config = { + "type": "static", + "entryPoint": ".fastedge/static-index.js", + "ignoreDotFiles": true, + "ignoreDirs": ["./node_modules"], + "ignoreWellKnown": false, + "tsConfigPath": "./tsconfig.json", + "wasmOutput": ".fastedge/dist/fastedge.wasm", + "publicDir": "./build" +}; + +const serverConfig = { + "type": "static", + "extendedCache": [], + "publicDirPrefix": "", + "compression": [], + "notFoundPage": "/404.html", + "autoExt": [], + "autoIndex": ["index.html", "index.htm"], + "spaEntrypoint": null +}; + +export { config, serverConfig }; +``` + +For a SPA, `spaEntrypoint` is set to the normalized path entered at the prompt (e.g., `"/index.html"`). + +### Config Fields + +#### Build Config (`config`) + +| Field | Type | Default | Description | +| ----------------- | ---------- | ------------------------------ | ------------------------------------------------------ | +| `type` | `string` | `"static"` | Build type identifier | +| `entryPoint` | `string` | `.fastedge/static-index.js` | Path to the generated entry file | +| `wasmOutput` | `string` | `.fastedge/dist/fastedge.wasm` | Output path for the compiled WASM file | +| `publicDir` | `string` | — | Directory containing static assets to embed | +| `tsConfigPath` | `string` | `./tsconfig.json` | Path to TypeScript configuration | +| `ignoreDotFiles` | `boolean` | `true` | Exclude files beginning with `.` from the asset bundle | +| `ignoreDirs` | `string[]` | `["./node_modules"]` | Directories to exclude from the asset bundle | +| `ignoreWellKnown` | `boolean` | `false` | When `true`, excludes the `.well-known/` directory | + +#### Server Config (`serverConfig`) + +| Field | Type | Default | Description | +| ----------------- | ----------------- | ------------------------------ | ----------------------------------------------------- | +| `type` | `string` | `"static"` | Server type identifier | +| `extendedCache` | `string[]` | `[]` | Additional paths to serve with long cache TTLs | +| `publicDirPrefix` | `string` | `""` | URL prefix stripped before resolving asset paths | +| `compression` | `string[]` | `[]` | Compression formats (reserved for future use) | +| `notFoundPage` | `string` | `"/404.html"` | Asset path served on 404 | +| `autoExt` | `string[]` | `[]` | Extensions appended when a path has no extension | +| `autoIndex` | `string[]` | `["index.html", "index.htm"]` | Index filenames tried when resolving a directory path | +| `spaEntrypoint` | `string \| null` | `null` | Fallback asset path for unmatched routes in SPA mode | + +### Build Command + +Static sites require two build steps. First, generate the asset manifest from your public directory: + +```bash +npx fastedge-assets ./build .fastedge/build/static-asset-manifest.js +``` + +Then compile to WASM: + +```bash +npx fastedge-build --config .fastedge/build-config.js +``` + +Run `fastedge-assets` again whenever your static assets change. + +## See Also + +- [fastedge-build CLI](BUILD_CLI.md) — compile a project to WebAssembly +- [fastedge-assets CLI](ASSETS_CLI.md) — generate static asset manifests +- [Static Sites](STATIC_SITES.md) — static site configuration and serving behaviour +- [Quickstart](quickstart.md) — installation and first build diff --git a/docs/SDK_API.md b/docs/SDK_API.md new file mode 100644 index 0000000..f899396 --- /dev/null +++ b/docs/SDK_API.md @@ -0,0 +1,666 @@ +# SDK API Reference + +Runtime APIs available to FastEdge applications compiled to WebAssembly. + +## FastEdge APIs + +### Environment Variables + +**Module:** `fastedge::env` + +```typescript +import { getEnv } from "fastedge::env"; +``` + +| Function | Signature | Returns | +| ---------------- | ---------------------------------- | ---------------- | +| `getEnv(name)` | `(name: string) => string \| null` | `string \| null` | + +Retrieves the value of a named environment variable, or `null` if not set. Environment variables are set on the application and injected at request time. + +**Note:** Environment variables can only be read during request processing, not during build-time initialization. + +```javascript +/// + +import { getEnv } from "fastedge::env"; + +async function app(event) { + const hostname = getEnv("HOSTNAME"); + const traceId = getEnv("TRACE_ID"); + + return new Response(`hostname=${hostname} trace=${traceId}`, { status: 200 }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + +--- + +### Secrets + +**Module:** `fastedge::secret` + +```typescript +import { getSecret, getSecretEffectiveAt } from "fastedge::secret"; +``` + +| Function | Signature | Returns | +| -------------------------------------- | ------------------------------------------------------- | ---------------- | +| `getSecret(name)` | `(name: string) => string \| null` | `string \| null` | +| `getSecretEffectiveAt(name, effectiveAt)` | `(name: string, effectiveAt: number) => string \| null` | `string \| null` | + +**Note:** Secrets can only be read during request processing, not during build-time initialization. + +#### `getSecret` + +Retrieves the current value of a named secret variable. + +```javascript +/// + +import { getSecret } from "fastedge::secret"; + +async function app(event) { + const token = getSecret("API_TOKEN"); + + return new Response("ok", { + status: 200, + headers: { "X-Auth": token }, + }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + +#### `getSecretEffectiveAt` + +Retrieves the value of a named secret from a specific slot. The `effectiveAt` parameter is a slot index; when secret rotation is based on time, this is a Unix timestamp. The slot returned is the most recent slot where `slot <= effectiveAt`. + +```javascript +/// + +import { getSecretEffectiveAt } from "fastedge::secret"; + +async function app(event) { + // Retrieve the secret valid at a specific Unix timestamp + const token = getSecretEffectiveAt("API_TOKEN", 1745698356); + + return new Response("ok", { status: 200 }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + +--- + +### KV Store + +**Module:** `fastedge::kv` + +```typescript +import { KvStore } from "fastedge::kv"; +``` + +The `KvStore` class provides access to key-value stores attached to the application. Open a store by name using the static `open` method, then use the returned instance to query data. + +#### `KvStore.open` + +```typescript +static open(name: string): KvStoreInstance +``` + +Opens a named KV store and returns an instance. The `name` must match a store configured on the application. Throws if the store cannot be opened. + +```javascript +/// + +import { KvStore } from "fastedge::kv"; + +async function app(event) { + try { + const kv = KvStore.open("my-store"); + const buf = kv.get("config"); + + if (buf === null) { + return new Response("not found", { status: 404 }); + } + + const text = new TextDecoder().decode(buf); + return new Response(text, { status: 200 }); + } catch (err) { + return new Response("store error", { status: 500 }); + } +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + +#### KvStoreInstance methods + +| Method | Signature | Returns | +| -------------------------------- | --------------------------------------------------------------------------- | ------------------------------ | +| `get(key)` | `(key: string) => ArrayBuffer \| null` | `ArrayBuffer \| null` | +| `scan(pattern)` | `(pattern: string) => Array` | `Array` | +| `zrangeByScore(key, min, max)` | `(key: string, min: number, max: number) => Array<[ArrayBuffer, number]>` | `Array<[ArrayBuffer, number]>` | +| `zscan(key, pattern)` | `(key: string, pattern: string) => Array<[ArrayBuffer, number]>` | `Array<[ArrayBuffer, number]>` | +| `bfExists(key, value)` | `(key: string, value: string) => boolean` | `boolean` | + +##### `get` + +Retrieves the value for a key. Returns `null` if the key does not exist. The returned `ArrayBuffer` can be decoded with `TextDecoder` for string values. + +```javascript +const buf = kv.get("my-key"); +if (buf !== null) { + const text = new TextDecoder().decode(buf); +} +``` + +##### `scan` + +Returns all keys matching a prefix pattern. The pattern must include a wildcard character (e.g., `"prefix*"`). Returns an empty array if no keys match. + +```javascript +const keys = kv.scan("user:*"); +// keys: Array — e.g. ["user:1", "user:2"] +``` + +##### `zrangeByScore` + +Returns all entries from a sorted set (ZSet) whose scores fall within `[min, max]`. Each entry is a `[value, score]` tuple where `value` is an `ArrayBuffer`. Returns an empty array if no entries fall in range. + +```javascript +const entries = kv.zrangeByScore("leaderboard", 100, 500); +for (const [buf, score] of entries) { + const name = new TextDecoder().decode(buf); + console.log(name, score); +} +``` + +##### `zscan` + +Returns all entries from a sorted set whose values match a prefix pattern. The pattern must include a wildcard (e.g., `"foo*"`). Each entry is a `[value, score]` tuple where `value` is an `ArrayBuffer`. Returns an empty array if no entries match. + +```javascript +const entries = kv.zscan("leaderboard", "user:*"); +for (const [buf, score] of entries) { + const name = new TextDecoder().decode(buf); + console.log(name, score); +} +``` + +##### `bfExists` + +Checks whether a value is present in a Bloom Filter stored under the given key. Returns `true` if the value likely exists, `false` if it definitely does not. + +```javascript +const seen = kv.bfExists("visited-ips", event.client.address); +``` + +--- + +## Fetch Event + +Every FastEdge application handles incoming requests by registering a listener for the `"fetch"` event. + +```typescript +addEventListener("fetch", (event: FetchEvent) => void); +``` + +### FetchEvent + +| Property / Method | Type | Description | +| ----------------- | ------------------------------------------------------- | ------------------------------------------------------- | +| `request` | `Request` | The incoming HTTP request from the client. | +| `client` | `ClientInfo` | Information about the downstream client. | +| `respondWith` | `(response: Response \| PromiseLike) => void` | Sends a response back to the client. | +| `waitUntil` | `(promise: Promise) => void` | Extends the service lifetime until the promise settles. | + +`respondWith` must be called synchronously within the event listener, but may be passed a `Promise`. The service is kept alive until the response is fully sent. Use `waitUntil` to perform work (e.g., logging, telemetry) after the response has been sent. + +```javascript +/// + +addEventListener("fetch", event => { + event.respondWith(handleRequest(event)); +}); + +async function handleRequest(event) { + const { request, client } = event; + + event.waitUntil( + logRequest(request.url, client.address) + ); + + return new Response("hello", { status: 200 }); +} + +async function logRequest(url, ip) { + await fetch("https://logging.example.com/log", { + method: "POST", + body: JSON.stringify({ url, ip }), + headers: { "content-type": "application/json" }, + }); +} +``` + +### ClientInfo + +Information about the downstream client that made the request, available as `event.client`. + +| Property | Type | Description | +| ---------------------- | ------------- | ---------------------------------------------------- | +| `address` | `string` | IPv4 or IPv6 address of the downstream client. | +| `tlsJA3MD5` | `string` | JA3 MD5 fingerprint of the TLS client hello. | +| `tlsCipherOpensslName` | `string` | OpenSSL name of the negotiated TLS cipher. | +| `tlsProtocol` | `string` | Negotiated TLS protocol version string. | +| `tlsClientCertificate` | `ArrayBuffer` | Raw bytes of the client TLS certificate, if present. | +| `tlsClientHello` | `ArrayBuffer` | Raw bytes of the TLS client hello message. | + +--- + +## Web APIs + +The following standard Web APIs are available in the FastEdge runtime. + +### Fetch API + +#### `fetch` + +```typescript +fetch(input: RequestInfo | URL, init?: RequestInit): Promise +``` + +Makes an outbound HTTP request. Follows the [WHATWG Fetch specification](https://fetch.spec.whatwg.org/). + +```javascript +const response = await fetch("https://api.example.com/data", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ key: "value" }), +}); +const data = await response.json(); +``` + +#### `Request` + +```typescript +new Request(input: RequestInfo | URL, init?: RequestInit): Request +``` + +| `RequestInit` field | Type | Description | +| ---------------------- | ------------------ | ------------------------------------------------------------------ | +| `method` | `string` | HTTP method. Defaults to `"GET"`. | +| `headers` | `HeadersInit` | Request headers. | +| `body` | `BodyInit \| null` | Request body. | +| `manualFramingHeaders` | `boolean` | When `true`, disables automatic framing header management. | + +| `Request` property / method | Type | Description | +| --------------------------------- | ------------------------------------ | ------------------------------------------------- | +| `method` | `string` | HTTP method. | +| `url` | `string` | Request URL as a string. | +| `headers` | `Headers` | Request headers (read-only on incoming requests). | +| `body` | `ReadableStream \| null` | Request body stream. | +| `bodyUsed` | `boolean` | Whether the body has already been consumed. | +| `clone()` | `() => Request` | Creates a copy of the request. | +| `text()` | `() => Promise` | Reads body as a string. | +| `json()` | `() => Promise` | Reads body and parses as JSON. | +| `arrayBuffer()` | `() => Promise` | Reads body as an `ArrayBuffer`. | +| `setCacheKey(key)` | `(key: string) => void` | Sets a custom cache key for the request. | +| `setManualFramingHeaders(manual)` | `(manual: boolean) => void` | Toggles manual framing header control. | + +**Note:** The `headers` property on an incoming `request` object (from `event.request`) is immutable — calls to `append`, `set`, or `delete` will throw. Clone the request or construct a new `Headers` object to modify headers. + +#### `Response` + +```typescript +new Response(body?: BodyInit | null, init?: ResponseInit): Response +Response.redirect(url: string | URL, status?: number): Response +Response.json(data: any, init?: ResponseInit): Response +``` + +| `ResponseInit` field | Type | Description | +| ---------------------- | ------------- | ---------------------------------------------------------- | +| `status` | `number` | HTTP status code. Defaults to `200`. | +| `statusText` | `string` | HTTP status text. | +| `headers` | `HeadersInit` | Response headers. | +| `manualFramingHeaders` | `boolean` | When `true`, disables automatic framing header management. | + +| `Response` property / method | Type | Description | +| --------------------------------- | ------------------------------------ | ------------------------------------------- | +| `status` | `number` | HTTP status code. | +| `statusText` | `string` | HTTP status text. | +| `ok` | `boolean` | `true` if status is in the range 200–299. | +| `url` | `string` | URL of the response. | +| `headers` | `Headers` | Response headers. | +| `body` | `ReadableStream \| null` | Response body stream. | +| `bodyUsed` | `boolean` | Whether the body has already been consumed. | +| `text()` | `() => Promise` | Reads body as a string. | +| `json()` | `() => Promise` | Reads body and parses as JSON. | +| `arrayBuffer()` | `() => Promise` | Reads body as an `ArrayBuffer`. | +| `setManualFramingHeaders(manual)` | `(manual: boolean) => void` | Toggles manual framing header control. | + +#### `Headers` + +```typescript +new Headers(init?: HeadersInit): Headers +``` + +`HeadersInit` accepts a `Headers` instance, a `string[][]` array of `[name, value]` pairs, or a `Record` object. + +| Method | Signature | +| --------------------- | ---------------------------------------------------------------------------- | +| `get(name)` | `(name: string) => string \| null` | +| `has(name)` | `(name: string) => boolean` | +| `set(name, value)` | `(name: string, value: string) => void` | +| `append(name, value)` | `(name: string, value: string) => void` | +| `delete(name)` | `(name: string) => void` | +| `forEach(callback)` | `(callback: (value: string, key: string, parent: Headers) => void) => void` | +| `entries()` | `() => IterableIterator<[string, string]>` | +| `keys()` | `() => IterableIterator` | +| `values()` | `() => IterableIterator` | + +**Immutability note:** The `headers` object on an incoming `event.request` is read-only. Attempting to mutate it will throw a `TypeError`. To add or change headers, construct a new `Headers` object: + +```javascript +const newHeaders = new Headers(event.request.headers); +newHeaders.set("x-custom", "value"); + +const proxied = new Request(event.request.url, { + method: event.request.method, + headers: newHeaders, + body: event.request.body, +}); +``` + +--- + +### URL API + +#### `URL` + +```typescript +new URL(url: string, base?: string | URL): URL +``` + +Parses and manipulates URLs per the [WHATWG URL specification](https://url.spec.whatwg.org/). + +| Property | Type | Mutable | +| -------------- | ----------------- | ------- | +| `href` | `string` | yes | +| `origin` | `string` | no | +| `protocol` | `string` | yes | +| `username` | `string` | yes | +| `password` | `string` | yes | +| `host` | `string` | yes | +| `hostname` | `string` | yes | +| `port` | `string` | yes | +| `pathname` | `string` | yes | +| `search` | `string` | yes | +| `searchParams` | `URLSearchParams` | no | +| `hash` | `string` | yes | + +```javascript +const url = new URL(event.request.url); +const id = url.searchParams.get("id"); +``` + +#### `URLSearchParams` + +```typescript +new URLSearchParams( + init?: string | ReadonlyArray | Iterable | Record +): URLSearchParams +``` + +| Method | Signature | +| --------------------- | -------------------------------------------------------------------------------------------- | +| `get(name)` | `(name: string) => string \| null` | +| `getAll(name)` | `(name: string) => string[]` | +| `has(name)` | `(name: string) => boolean` | +| `set(name, value)` | `(name: string, value: string) => void` | +| `append(name, value)` | `(name: string, value: string) => void` | +| `delete(name)` | `(name: string) => void` | +| `sort()` | `() => void` | +| `entries()` | `() => IterableIterator<[string, string]>` | +| `keys()` | `() => IterableIterator` | +| `values()` | `() => IterableIterator` | +| `forEach(callback)` | `(callback: (value: string, name: string, searchParams: URLSearchParams) => void) => void` | + +--- + +### Streams API + +The WHATWG Streams API is available for constructing and transforming streaming bodies. + +#### `ReadableStream` + +```typescript +new ReadableStream(underlyingSource?: UnderlyingSource, strategy?: QueuingStrategy): ReadableStream +``` + +| `UnderlyingSource` field | Type | +| ------------------------ | -------------------------------------------------------------------------------- | +| `start` | `(controller: ReadableStreamDefaultController) => any` | +| `pull` | `(controller: ReadableStreamDefaultController) => void \| PromiseLike` | +| `cancel` | `(reason?: any) => void \| PromiseLike` | +| `type` | `"bytes" \| undefined` | +| `autoAllocateChunkSize` | `number` | + +| `ReadableStream` method | Signature | +| ------------------------------------ | -------------------------------------------------------------------------------------------- | +| `getReader()` | `() => ReadableStreamDefaultReader` | +| `pipeTo(dest, options?)` | `(dest: WritableStream, options?: StreamPipeOptions) => Promise` | +| `pipeThrough(transform, options?)` | `(transform: ReadableWritablePair, options?: StreamPipeOptions) => ReadableStream` | +| `tee()` | `() => [ReadableStream, ReadableStream]` | +| `cancel(reason?)` | `(reason?: any) => Promise` | + +```javascript +const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello ")); + controller.enqueue(new TextEncoder().encode("world")); + controller.close(); + }, +}); + +return new Response(stream, { status: 200 }); +``` + +#### `WritableStream` + +```typescript +new WritableStream(underlyingSink?: UnderlyingSink, strategy?: QueuingStrategy): WritableStream +``` + +| `WritableStream` method | Signature | +| ----------------------- | --------------------------------------- | +| `getWriter()` | `() => WritableStreamDefaultWriter` | +| `abort(reason?)` | `(reason?: any) => Promise` | + +#### `TransformStream` + +```typescript +new TransformStream( + transformer?: Transformer, + writableStrategy?: QueuingStrategy, + readableStrategy?: QueuingStrategy, +): TransformStream +``` + +| Property | Type | Description | +| ---------- | ------------------- | ----------------------------------- | +| `readable` | `ReadableStream` | The readable side of the transform. | +| `writable` | `WritableStream` | The writable side of the transform. | + +--- + +### Encoding API + +#### `TextEncoder` / `TextDecoder` + +Standard `TextEncoder` and `TextDecoder` are available as globals for converting between strings and `Uint8Array`. + +```javascript +const encoded = new TextEncoder().encode("hello"); // Uint8Array +const decoded = new TextDecoder().decode(encoded); // "hello" +``` + +#### Base64 + +```typescript +atob(data: string): string +btoa(data: string): string +``` + +| Function | Description | +| -------- | ------------------------------------------------- | +| `btoa` | Encodes a binary string to a Base64 ASCII string. | +| `atob` | Decodes a Base64 ASCII string to a binary string. | + +```javascript +const encoded = btoa("hello world"); // "aGVsbG8gd29ybGQ=" +const decoded = atob(encoded); // "hello world" +``` + +--- + +### Crypto API + +#### `crypto` + +The global `crypto` object provides access to cryptographic operations. + +```typescript +crypto.getRandomValues(array: T): T +crypto.randomUUID(): string +crypto.subtle: SubtleCrypto +``` + +#### `SubtleCrypto` + +Available as `crypto.subtle`. Supported operations: + +| Method | Signature | +| ----------- | -------------------------------------------------------------------------------------------------------------------- | +| `digest` | `(algorithm: AlgorithmIdentifier, data: BufferSource) => Promise` | +| `importKey` | See overloads below | +| `sign` | `(algorithm: AlgorithmIdentifier, key: CryptoKey, data: BufferSource) => Promise` | +| `verify` | `(algorithm: AlgorithmIdentifier, key: CryptoKey, signature: BufferSource, data: BufferSource) => Promise` | + +`importKey` overloads: + +```typescript +// JWK format +subtle.importKey( + format: 'jwk', + keyData: JsonWebKey, + algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams, + extractable: boolean, + keyUsages: ReadonlyArray, +): Promise + +// Raw / other formats +subtle.importKey( + format: Exclude, + keyData: BufferSource, + algorithm: AlgorithmIdentifier | RsaHashedImportParams | HmacImportParams, + extractable: boolean, + keyUsages: KeyUsage[], +): Promise +``` + +Supported `KeyFormat` values: `"jwk"`, `"raw"`. + +```javascript +// Compute SHA-256 digest +const data = new TextEncoder().encode("hello world"); +const hashBuf = await crypto.subtle.digest("SHA-256", data); +const hashHex = Array.from(new Uint8Array(hashBuf)) + .map(b => b.toString(16).padStart(2, "0")) + .join(""); +``` + +--- + +### Timers + +```typescript +setTimeout(callback: (...args: TArgs) => void, delay?: number, ...args: TArgs): number +clearTimeout(timeoutID?: number): void + +setInterval(callback: (...args: TArgs) => void, delay?: number, ...args: TArgs): number +clearInterval(intervalID?: number): void +``` + +| Function | Description | +| --------------- | --------------------------------------------------------------------------- | +| `setTimeout` | Calls `callback` once after `delay` milliseconds. Returns a timer ID. | +| `clearTimeout` | Cancels a timer created by `setTimeout`. | +| `setInterval` | Calls `callback` repeatedly every `delay` milliseconds. Returns a timer ID. | +| `clearInterval` | Cancels a repeating timer created by `setInterval`. | + +--- + +### Console + +The global `console` object writes to stdout. Unlike browser or Node.js implementations, this version does not perform string substitution in format strings — all arguments are stringified and concatenated. + +| Method | Description | +| -------------------- | ---------------------------------------- | +| `console.log` | General output. | +| `console.info` | Informational output. | +| `console.warn` | Warning output. | +| `console.error` | Error output. | +| `console.debug` | Debug output. | +| `console.assert` | Logs if condition is falsy. | +| `console.trace` | Outputs a stack trace. | +| `console.time` | Starts a named timer. | +| `console.timeEnd` | Stops a named timer and logs elapsed ms. | +| `console.timeLog` | Logs current elapsed time for a timer. | +| `console.count` | Logs call count for a label. | +| `console.countReset` | Resets call count for a label. | +| `console.group` | Starts an indented group. | +| `console.groupEnd` | Ends an indented group. | +| `console.dir` | Logs object representation. | +| `console.table` | Logs tabular data. | + +--- + +### Performance API + +```typescript +performance.now(): DOMHighResTimeStamp // number (milliseconds) +performance.timeOrigin: DOMHighResTimeStamp +``` + +`performance.now()` returns a high-resolution timestamp in milliseconds relative to `performance.timeOrigin`. + +```javascript +const start = performance.now(); +// ... work ... +const elapsed = performance.now() - start; +console.log(`elapsed: ${elapsed}ms`); +``` + +--- + +### Additional Globals + +| Global | Type / Signature | Description | +| -------------------------------- | ----------------------------------------------------------- | ------------------------------------------------- | +| `self` | `typeof globalThis` | Reference to the global object. | +| `location` | `WorkerLocation` | URL of the current worker script. | +| `queueMicrotask(callback)` | `(callback: () => void) => void` | Queues a microtask. | +| `structuredClone(value, opts?)` | `(value: any, options?: StructuredSerializeOptions) => any` | Deep-clones a value. Transferable: `ArrayBuffer`. | + +--- + +## See Also + +- [quickstart.md](quickstart.md) — Getting started with your first FastEdge application +- [BUILD_CLI.md](BUILD_CLI.md) — `fastedge-build` CLI reference +- [INIT_CLI.md](INIT_CLI.md) — `fastedge-init` CLI reference +- [STATIC_SITES.md](STATIC_SITES.md) — Serving static assets from WASM +- [ASSETS_CLI.md](ASSETS_CLI.md) — `fastedge-assets` CLI reference diff --git a/docs/STATIC_SITES.md b/docs/STATIC_SITES.md new file mode 100644 index 0000000..7a1e3ef --- /dev/null +++ b/docs/STATIC_SITES.md @@ -0,0 +1,366 @@ +# Static Sites + +Serve static files from a FastEdge WebAssembly binary with no file-system access at runtime. + +## How Embedding Works + +The FastEdge runtime runs inside a WebAssembly sandbox with no access to a host file system. Static files must be embedded directly into the `.wasm` binary at compile time. + +The build pipeline uses [Wizer](https://github.com/bytecodealliance/wizer) for pre-initialization. Wizer executes all top-level JavaScript in your entry point before taking a memory snapshot. When `createStaticServer` is called at the top level, it iterates over the asset manifest and loads every file's bytes into WebAssembly linear memory. Wizer then snapshots that memory state and writes it into the final binary. At runtime, assets are served directly from memory with no startup delay. + +This means: + +- `createStaticServer` **must** be called at module top level, not inside a function or event handler. +- The asset manifest must be generated before `fastedge-build` runs. +- Any file not included in the manifest at build time cannot be served at runtime. + +## Quick Start + +### Step 1: Generate the asset manifest + +```sh +npx fastedge-assets ./public ./src/asset-manifest.ts +``` + +This scans `./public` and writes a manifest file mapping each file's URL path to its metadata. The manifest is a TypeScript/JavaScript module — do not edit it by hand. + +### Step 2: Write the entry point + +```ts +/// +import { createStaticServer } from '@gcoredev/fastedge-sdk-js'; +import { staticAssetManifest } from './asset-manifest.js'; + +// Must be at top level — Wizer snapshots this call +const server = createStaticServer(staticAssetManifest, { + autoIndex: ['index.html'], + autoExt: ['.html'], + notFoundPage: '/404.html', +}); + +addEventListener('fetch', (event: FetchEvent) => { + event.respondWith( + server.serveRequest(event.request).then( + (response) => response ?? new Response('Not found', { status: 404 }), + ), + ); +}); +``` + +### Step 3: Build + +```sh +npx fastedge-build --input ./src/index.ts --output ./dist/app.wasm +``` + +Or use a config-driven build (see [Build Config](#build-config)): + +```sh +npx fastedge-build --config .fastedge/build-config.js +``` + +## Build Config + +When using `fastedge-build` with a config file, set `type: 'static'` to have the asset manifest generated automatically as part of the build pipeline. + +```js +// .fastedge/build-config.js +const config = { + type: 'static', + entryPoint: '.fastedge/static-index.js', + wasmOutput: './dist/app.wasm', + publicDir: './public', + assetManifestPath: './src/asset-manifest.ts', + ignoreDotFiles: true, + ignoreWellKnown: false, + ignorePaths: ['./public/drafts'], +}; + +export { config }; +``` + +### Static-Specific BuildConfig Fields + +The following fields apply when `type` is `'static'`. All other `BuildConfig` fields are documented in [BUILD_CLI.md](BUILD_CLI.md). + +| Field | Type | Required | Description | +| ------------------- | ------------------------------ | -------- | ---------------------------------------------------------------------- | +| `publicDir` | `string` | Yes | Directory to scan for static files to embed | +| `assetManifestPath` | `string` | Yes | Output path for the generated asset manifest module | +| `contentTypes` | `Array` | No | Custom content-type rules prepended before built-in defaults | +| `ignoreDotFiles` | `boolean` | No | When `true`, excludes files and directories whose names begin with `.` | +| `ignorePaths` | `string[]` | No | Additional paths to exclude from the manifest | +| `ignoreWellKnown` | `boolean` | No | When `true`, excludes the `.well-known/` directory | + +## createStaticServer + +```typescript +function createStaticServer( + staticAssetManifest: StaticAssetManifest, + serverConfig: Partial, +): StaticServer +``` + +Creates a static server that serves assets from an in-memory cache built from `staticAssetManifest`. + +**Parameters:** + +| Parameter | Type | Description | +| --------------------- | ----------------------- | ---------------------------------------------------------------------- | +| `staticAssetManifest` | `StaticAssetManifest` | Manifest generated by `npx fastedge-assets` or `type: 'static'` build | +| `serverConfig` | `Partial` | Server behavior options; all fields are optional | + +**Returns:** `StaticServer` + +**Critical constraint:** This function must be called at module top level. Calling it inside a function, event handler, or `async` context prevents Wizer from embedding the assets into the binary. See [Wizer Constraint](#wizer-constraint). + +### Import + +```ts +import { createStaticServer } from '@gcoredev/fastedge-sdk-js'; +``` + +### ServerConfig Fields + +All fields are optional. Pass only the fields you need. + +| Field | Type | Default | Description | +| ----------------- | ------------------------- | ------- | --------------------------------------------------------------------------------------------------------- | +| `publicDirPrefix` | `string` | `''` | Prefix stripped from asset keys before matching request paths | +| `routePrefix` | `string` | `'/'` | URL prefix stripped from incoming request paths before looking up asset keys | +| `extendedCache` | `Array` | `[]` | Paths or patterns that receive a `Cache-Control: max-age=31536000` response header | +| `compression` | `string[]` | `[]` | Content encodings to serve (e.g. `['br', 'gzip']`); matched against the request `Accept-Encoding` header | +| `notFoundPage` | `string \| null` | `null` | Asset path to serve when no match is found (e.g. `'/404.html'`); only served for HTML-accepting requests | +| `autoExt` | `string[]` | `[]` | Extensions to append when no exact path match is found (e.g. `['.html']`) | +| `autoIndex` | `string[]` | `[]` | Index file names to try for directory requests (e.g. `['index.html']`) | +| `spaEntrypoint` | `string \| null` | `null` | Asset path served as the SPA fallback for unmatched routes; only served for HTML-accepting requests | + +#### routePrefix + +Use `routePrefix` when mounting a static server under a URL subpath. The prefix is stripped from the request path before the server looks up an asset key. + +```ts +// Assets have keys like '/logo.png', '/style.css' +// Requests arrive as '/static/logo.png', '/static/style.css' +const server = createStaticServer(manifest, { routePrefix: '/static' }); +``` + +#### extendedCache + +Entries are path strings or regex-style strings prefixed with `regex:`. Regex strings use the format `regex:/pattern/flags` and are converted to `RegExp` objects during normalization. + +```ts +const server = createStaticServer(manifest, { + extendedCache: [ + '/assets/logo.png', + 'regex:/\\.woff2$/i', + ], +}); +``` + +#### autoExt and autoIndex + +`autoExt` appends extensions to the path when no exact match is found. `autoIndex` appends index file names when the request path ends with `/`. + +```ts +// Request: /about → tries /about.html +// Request: /docs/ → tries /docs/index.html +const server = createStaticServer(manifest, { + autoExt: ['.html'], + autoIndex: ['index.html'], +}); +``` + +#### notFoundPage and spaEntrypoint + +Both `notFoundPage` and `spaEntrypoint` are only served for requests that include `text/html` or `*/*` in their `Accept` header. Non-HTML requests (e.g. API calls, asset fetches) that match no asset receive a void response regardless of these settings. + +`spaEntrypoint` is checked first. If it resolves to an asset, it is returned with `Cache-Control: no-store`. If not, `notFoundPage` is checked and returned with `status: 404` and `Cache-Control: no-store`. + +```ts +// Client-side routing: all unmatched HTML requests get /index.html +const server = createStaticServer(manifest, { + spaEntrypoint: '/index.html', +}); + +// Static site with explicit 404 page +const server = createStaticServer(manifest, { + notFoundPage: '/404.html', +}); +``` + +## StaticServer Methods + +```typescript +interface StaticServer { + serveRequest(request: Request): Promise; + readFileString(path: string): Promise; +} +``` + +### serveRequest + +```typescript +serveRequest(request: Request): Promise +``` + +Looks up the asset matching `request.url`'s pathname and returns a `Response` with appropriate headers (`Content-Type`, `ETag`, `Last-Modified`, `Cache-Control`). Handles conditional requests (`If-None-Match`, `If-Modified-Since`) and content encoding negotiation based on the `Accept-Encoding` header. + +Only processes `GET` and `HEAD` requests. All other methods resolve to `void` immediately. + +Returns `void` (resolves to `undefined`) when no matching asset is found and no applicable `notFoundPage` or `spaEntrypoint` is configured. The caller must provide a fallback response in that case. + +```ts +addEventListener('fetch', (event: FetchEvent) => { + event.respondWith( + server.serveRequest(event.request).then( + (response) => response ?? new Response('Not found', { status: 404 }), + ), + ); +}); +``` + +### readFileString + +```typescript +readFileString(path: string): Promise +``` + +Returns the text content of the embedded asset at `path`. The asset must have been classified as a text type (`isText: true`) in the manifest at build time. Throws an error if no asset is found at the given path. Use this to read embedded HTML templates or other text files for further processing at runtime. + +```ts +app.get('/template', async (c) => { + const html = await server.readFileString('/template.html'); + return c.html(html); +}); +``` + +## Wizer Constraint + +Wizer pre-initializes the binary by running all top-level module code before snapshotting memory. `createStaticServer` must be called at the **module top level** to ensure asset bytes are loaded into memory before the snapshot is taken. + +**Correct:** + +```ts +import { createStaticServer } from '@gcoredev/fastedge-sdk-js'; +import { staticAssetManifest } from './asset-manifest.js'; + +// Top-level: runs during Wizer pre-initialization +const server = createStaticServer(staticAssetManifest, {}); + +addEventListener('fetch', (event: FetchEvent) => { + event.respondWith(server.serveRequest(event.request)); +}); +``` + +**Incorrect — assets will not be embedded:** + +```ts +// Do not do this +addEventListener('fetch', (event: FetchEvent) => { + const server = createStaticServer(staticAssetManifest, {}); // inside handler + event.respondWith(server.serveRequest(event.request)); +}); +``` + +## Multiple Manifests + +A single entry point can use multiple static servers, each built from a separate manifest. This is useful when different asset groups need different server configurations (e.g., separate route prefixes or cache policies). + +```sh +npx fastedge-assets ./images src/images-manifest.ts +npx fastedge-assets ./styles src/styles-manifest.ts +npx fastedge-assets ./templates src/templates-manifest.ts +npx fastedge-build -i src/index.ts -o dist/app.wasm -t tsconfig.json +``` + +```ts +/// +import { createStaticServer } from '@gcoredev/fastedge-sdk-js'; +import { staticAssetManifest as imagesManifest } from './images-manifest.js'; +import { staticAssetManifest as stylesManifest } from './styles-manifest.js'; +import { staticAssetManifest as templatesManifest } from './templates-manifest.js'; + +// All three must be at top level +const imageServer = createStaticServer(imagesManifest, { routePrefix: '/images' }); +const styleServer = createStaticServer(stylesManifest, { routePrefix: '/styles' }); +const templateServer = createStaticServer(templatesManifest, {}); + +addEventListener('fetch', (event: FetchEvent) => { + const { pathname } = new URL(event.request.url); + if (pathname.startsWith('/images/')) { + event.respondWith(imageServer.serveRequest(event.request)); + } else if (pathname.startsWith('/styles/')) { + event.respondWith(styleServer.serveRequest(event.request)); + } else { + event.respondWith( + templateServer + .serveRequest(event.request) + .then((r) => r ?? new Response('Not found', { status: 404 })), + ); + } +}); +``` + +## v1 to v2 Migration + +Version 2.x replaced the two-step `createStaticAssetsCache` + `getStaticServer` pattern with a single `createStaticServer` call. + +**Version 1.x:** + +```ts +import { getStaticServer, createStaticAssetsCache } from '@gcoredev/fastedge-sdk-js'; +import { staticAssetManifest } from './build/static-server-manifest.js'; +import { serverConfig } from './build-config.js'; + +const staticAssets = createStaticAssetsCache(staticAssetManifest); +const staticServer = getStaticServer(serverConfig, staticAssets); + +async function handleRequest(event) { + const response = await staticServer.serveRequest(event.request); + if (response != null) { + return response; + } + return new Response('Not found', { status: 404 }); +} + +addEventListener('fetch', (event) => event.respondWith(handleRequest(event))); +``` + +**Version 2.x:** + +```ts +import { createStaticServer } from '@gcoredev/fastedge-sdk-js'; +import { staticAssetManifest } from './build/static-asset-manifest.js'; +import { serverConfig } from './build-config.js'; + +const staticServer = createStaticServer(staticAssetManifest, serverConfig); + +async function handleRequest(event) { + const response = await staticServer.serveRequest(event.request); + if (response != null) { + return response; + } + return new Response('Not found', { status: 404 }); +} + +addEventListener('fetch', (event) => event.respondWith(handleRequest(event))); +``` + +**What changed:** + +| Area | v1.x | v2.x | +| ------------------- | --------------------------------------------- | ------------------------------------------ | +| API | `createStaticAssetsCache` + `getStaticServer` | `createStaticServer` | +| Multiple manifests | Not supported | Supported — one server per manifest | +| Read file as string | Not available | `server.readFileString(path)` | +| Manifest file name | `static-server-manifest.js` | `static-asset-manifest.js` (by convention) | + +If you used `fastedge-init` to scaffold your project, re-running `npx fastedge-init` updates the generated `static-index.js` entry point automatically. + +## See Also + +- [Assets CLI](ASSETS_CLI.md) — `fastedge-assets` reference for manifest generation and config fields +- [Build CLI](BUILD_CLI.md) — `fastedge-build` reference including `type: 'static'` build config +- [Init CLI](INIT_CLI.md) — `fastedge-init` for scaffolding a static site project +- [SDK API](SDK_API.md) — runtime API reference for HTTP handler entry points diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..90a5983 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,183 @@ +# Quickstart + +Get a FastEdge JavaScript application built and ready for deployment. + +## Prerequisites + +- Node.js `>=22` (see `engines.node` in `package.json`) +- npm, yarn, or pnpm + +## Installation + +```bash +npm install --save-dev @gcoredev/fastedge-sdk-js +``` + +## Option 1: Scaffold with fastedge-init + +The fastest way to start a new project: + +```bash +npx fastedge-init +``` + +This interactive wizard will: + +1. Ask what you're building: **HTTP event handler** or **Static website** +2. Create a `.fastedge/` directory with build configuration +3. Generate a `build-config.js` file + +Then build: + +```bash +npx fastedge-build --config .fastedge/build-config.js +``` + +## Option 2: Build Directly + +For an existing JavaScript or TypeScript file, pass input and output as positional arguments: + +```bash +npx fastedge-build src/index.js output.wasm +``` + +Or using explicit flags: + +```bash +npx fastedge-build --input src/index.ts --output app.wasm --tsconfig tsconfig.json +``` + +### fastedge-build CLI Flags + +| Flag | Alias | Type | Description | +| ------------ | ----- | ---------- | ------------------------------- | +| `--input` | `-i` | `string` | Entry point file | +| `--output` | `-o` | `string` | Output `.wasm` file path | +| `--tsconfig` | `-t` | `string` | Path to `tsconfig.json` | +| `--config` | `-c` | `string[]` | Path(s) to build config file(s) | +| `--version` | `-v` | `boolean` | Print version | +| `--help` | `-h` | `boolean` | Print help | + +## Write Your First App + +Create `src/index.js`: + +```js +async function handler(event) { + const request = event.request; + const url = new URL(request.url); + + return new Response(`Hello from FastEdge! You requested: ${url.pathname}`, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); +} + +addEventListener('fetch', (event) => { + event.respondWith(handler(event)); +}); +``` + +Build it: + +```bash +npx fastedge-build src/index.js app.wasm +``` + +The output `app.wasm` is a WebAssembly component ready for deployment on the FastEdge platform. + +## Using Environment Variables + +`getEnv` is only available during request processing, not at build-time initialization. + +```js +/// +import { getEnv } from 'fastedge::env'; + +addEventListener('fetch', (event) => { + event.respondWith( + (async () => { + const apiKey = getEnv('API_KEY'); + if (apiKey === null) { + return new Response('API_KEY not configured', { status: 500 }); + } + return new Response(`API key exists: ${apiKey.length > 0}`); + })(), + ); +}); +``` + +**Signature:** `getEnv(name: string): string | null` + +Returns `null` when the environment variable is not set. + +## Using Secrets + +`getSecret` and `getSecretEffectiveAt` are only available during request processing, not at build-time initialization. + +```js +/// +import { getSecret } from 'fastedge::secret'; + +addEventListener('fetch', (event) => { + event.respondWith( + (async () => { + const token = getSecret('SECRET_TOKEN'); + // Use token to authenticate downstream requests + return new Response('OK'); + })(), + ); +}); +``` + +**Signatures:** + +- `getSecret(name: string): string | null` +- `getSecretEffectiveAt(name: string, effectiveAt: number): string | null` + +Both return `null` when the secret is not set. + +## Using KV Store + +```js +/// +import { KvStore } from 'fastedge::kv'; + +addEventListener('fetch', (event) => { + event.respondWith( + (async () => { + const store = KvStore.open('my-store'); + const value = store.get('my-key'); + + if (value) { + const text = new TextDecoder().decode(value); + return new Response(text); + } + + return new Response('Key not found', { status: 404 }); + })(), + ); +}); +``` + +**Signatures:** + +- `KvStore.open(name: string): KvStoreInstance` +- `KvStoreInstance.get(key: string): ArrayBuffer | null` +- `KvStoreInstance.scan(pattern: string): Array` +- `KvStoreInstance.zrangeByScore(key: string, min: number, max: number): Array<[ArrayBuffer, number]>` +- `KvStoreInstance.zscan(key: string, pattern: string): Array<[ArrayBuffer, number]>` +- `KvStoreInstance.bfExists(key: string, value: string): boolean` + +## Next Steps + +- [fastedge-build CLI](BUILD_CLI.md) — build options and configuration +- [fastedge-init CLI](INIT_CLI.md) — project scaffolding details +- [Static Sites](STATIC_SITES.md) — serve static websites +- [SDK Runtime API](SDK_API.md) — full API reference +- [Deploy your app](https://gcore.com/docs/fastedge/getting-started/create-fastedge-apps#stage-2-deploy-an-app) + +## See Also + +- [INDEX](INDEX.md) — documentation overview +- [Examples](https://github.com/G-Core/FastEdge-sdk-js/tree/main/examples) — real-world patterns diff --git a/docs/src/content/docs/examples/basic.mdx b/docs/src/content/docs/examples/basic.mdx deleted file mode 100644 index bb54fd1..0000000 --- a/docs/src/content/docs/examples/basic.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Basic Example -description: A basic Javascript example. -prev: - link: /FastEdge-sdk-js/examples/main-examples/ - label: Back to examples ---- - -import { Code, LinkCard } from '@astrojs/starlight/components'; -import importedCode from '/examples/basic.js?raw'; - - diff --git a/examples/README.md b/examples/README.md index 3e9f2af..51fda5a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,18 @@ JavaScript examples for building HTTP applications on the [FastEdge](https://gco network using [`@gcoredev/fastedge-sdk-js`](https://www.npmjs.com/package/@gcoredev/fastedge-sdk-js). -## Examples +## Getting Started Examples + +| Example | Description | +| ------------------------------------------------------------- | ------------------------------------------------------------------- | +| [hello-world](./hello-world/) | Simplest request handler — returns the request URL | +| [downstream-fetch](./downstream-fetch/) | Fetch from a downstream HTTP origin | +| [downstream-modify-response](./downstream-modify-response/) | Fetch downstream and transform the response | +| [headers](./headers/) | Header manipulation using environment variables | +| [kv-store-basic](./kv-store-basic/) | Simple KV Store get operation | +| [variables-and-secrets](./variables-and-secrets/) | Read environment variables and secrets | + +## Full Examples | Example | Description | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------- | diff --git a/examples/downstream-fetch/README.md b/examples/downstream-fetch/README.md new file mode 100644 index 0000000..03f9377 --- /dev/null +++ b/examples/downstream-fetch/README.md @@ -0,0 +1,5 @@ +[← Back to examples](../README.md) + +# Downstream Fetch + +Fetch data from a downstream HTTP origin and return the response directly. diff --git a/examples/downstream-fetch/package.json b/examples/downstream-fetch/package.json new file mode 100644 index 0000000..8fa1148 --- /dev/null +++ b/examples/downstream-fetch/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-downstream-fetch", + "version": "1.0.0", + "description": "FastEdge JS example: downstream HTTP fetch", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/downstream-fetch.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.1.0" + } +} diff --git a/docs/examples/downstream-fetch.js b/examples/downstream-fetch/src/index.js similarity index 100% rename from docs/examples/downstream-fetch.js rename to examples/downstream-fetch/src/index.js diff --git a/examples/downstream-modify-response/README.md b/examples/downstream-modify-response/README.md new file mode 100644 index 0000000..df6e8b1 --- /dev/null +++ b/examples/downstream-modify-response/README.md @@ -0,0 +1,5 @@ +[← Back to examples](../README.md) + +# Downstream Modify Response + +Fetch data from a downstream origin, transform the JSON response (slice to first 5 users), and return it with custom headers. diff --git a/examples/downstream-modify-response/package.json b/examples/downstream-modify-response/package.json new file mode 100644 index 0000000..391f323 --- /dev/null +++ b/examples/downstream-modify-response/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-downstream-modify-response", + "version": "1.0.0", + "description": "FastEdge JS example: fetch and modify downstream response", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/downstream-modify-response.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.1.0" + } +} diff --git a/docs/examples/downstream-modify-response.js b/examples/downstream-modify-response/src/index.js similarity index 100% rename from docs/examples/downstream-modify-response.js rename to examples/downstream-modify-response/src/index.js diff --git a/examples/headers/README.md b/examples/headers/README.md new file mode 100644 index 0000000..039e643 --- /dev/null +++ b/examples/headers/README.md @@ -0,0 +1,5 @@ +[← Back to examples](../README.md) + +# Header Manipulation + +Add a custom response header using an environment variable read via `fastedge::env`. diff --git a/examples/headers/package.json b/examples/headers/package.json new file mode 100644 index 0000000..a845a7e --- /dev/null +++ b/examples/headers/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-headers", + "version": "1.0.0", + "description": "FastEdge JS example: header manipulation with env vars", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/headers.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.1.0" + } +} diff --git a/docs/examples/headers.js b/examples/headers/src/index.js similarity index 100% rename from docs/examples/headers.js rename to examples/headers/src/index.js diff --git a/examples/hello-world/README.md b/examples/hello-world/README.md new file mode 100644 index 0000000..717dfb2 --- /dev/null +++ b/examples/hello-world/README.md @@ -0,0 +1,5 @@ +[← Back to examples](../README.md) + +# Hello World + +The simplest possible FastEdge application — returns the request URL back in the response body. diff --git a/examples/hello-world/package.json b/examples/hello-world/package.json new file mode 100644 index 0000000..5367595 --- /dev/null +++ b/examples/hello-world/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-hello-world", + "version": "1.0.0", + "description": "FastEdge JS example: hello world request handler", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/hello-world.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.1.0" + } +} diff --git a/docs/examples/basic.js b/examples/hello-world/src/index.js similarity index 69% rename from docs/examples/basic.js rename to examples/hello-world/src/index.js index eeba2a8..121e450 100644 --- a/docs/examples/basic.js +++ b/examples/hello-world/src/index.js @@ -1,6 +1,6 @@ async function eventHandler(event) { const request = event.request; - return new Response(`You made a request to ${request.url}`); + return new Response(`Hello, you made a request to ${request.url}`); } addEventListener('fetch', (event) => { diff --git a/examples/kv-store-basic/README.md b/examples/kv-store-basic/README.md new file mode 100644 index 0000000..9e5c237 --- /dev/null +++ b/examples/kv-store-basic/README.md @@ -0,0 +1,7 @@ +[← Back to examples](../README.md) + +# KV Store Basic + +The simplest KV Store example — open a named store and get a value by key. + +For a more complete example demonstrating all KV operations (get, scan, zrange, zscan, bfExists), see [kv-store](../kv-store/). diff --git a/examples/kv-store-basic/package.json b/examples/kv-store-basic/package.json new file mode 100644 index 0000000..72c7ee1 --- /dev/null +++ b/examples/kv-store-basic/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-kv-store-basic", + "version": "1.0.0", + "description": "FastEdge JS example: simple KV Store get operation", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/kv-store-basic.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.1.0" + } +} diff --git a/docs/examples/kv-store.js b/examples/kv-store-basic/src/index.js similarity index 100% rename from docs/examples/kv-store.js rename to examples/kv-store-basic/src/index.js diff --git a/examples/variables-and-secrets/README.md b/examples/variables-and-secrets/README.md new file mode 100644 index 0000000..065579c --- /dev/null +++ b/examples/variables-and-secrets/README.md @@ -0,0 +1,5 @@ +[← Back to examples](../README.md) + +# Environment Variables and Secrets + +Read environment variables via `fastedge::env` and secrets via `fastedge::secret`. diff --git a/examples/variables-and-secrets/package.json b/examples/variables-and-secrets/package.json new file mode 100644 index 0000000..8a366f3 --- /dev/null +++ b/examples/variables-and-secrets/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-variables-and-secrets", + "version": "1.0.0", + "description": "FastEdge JS example: environment variables and secrets", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/variables-and-secrets.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.1.0" + } +} diff --git a/docs/examples/variables-and-secrets.js b/examples/variables-and-secrets/src/index.js similarity index 100% rename from docs/examples/variables-and-secrets.js rename to examples/variables-and-secrets/src/index.js diff --git a/fastedge-plugin-source/.generation-config.md b/fastedge-plugin-source/.generation-config.md new file mode 100644 index 0000000..01ca84a --- /dev/null +++ b/fastedge-plugin-source/.generation-config.md @@ -0,0 +1,146 @@ +# FastEdge-sdk-js Documentation Generation Config + +This file contains specifications for maintaining the documentation in `docs/`. +It is **local only** — the plugin pipeline never reads this file. + +## Global Rules + +### Audience +- Application developers using the FastEdge JS SDK +- Agents helping users build FastEdge applications +- NOT SDK contributors (that's what `context/` is for) + +### Style +- Technical prose only — no marketing language, no superlatives, no "easily" or "simply" +- Use code signatures for all parameters and return types (TypeScript) +- Use fenced code blocks for all examples (language-tagged) +- Use tables for structured data (API signatures, config fields, CLI flags) +- Tables must use padded columns aligned to the widest cell in each column. Pad every cell with spaces so columns line up visually in raw markdown. The separator row dashes must match each column's padded width. + Good: `| Variable | Type | Default |` + Bad: `| Variable | Type | Default |` + Good: `| -------------------- | -------- | ------- |` + Bad: `|---|---|---|` +- Every code example must be self-contained and runnable +- Use `@gcoredev/fastedge-sdk-js` package name (not relative paths) + +### Structure +- Every doc file starts with a level-1 heading and a one-line description +- Use level-2 headings for major sections, level-3 for subsections +- No table of contents — keep files navigable by heading structure alone +- End each file with a "See Also" section linking to related doc files + +### Exclusions (apply to all files) +- No internal implementation details (how the code works internally) +- No source code file paths or line numbers +- No version history or changelog entries +- No references to `context/` or `CLAUDE.md` (those are internal developer docs) + +### Accuracy +- All TypeScript signatures must match `types/` declarations exactly +- All CLI flags must match `src/cli/*/` source code +- All import paths must use `fastedge::` specifiers (not Node module paths) +- All config field names must match the actual TypeScript interfaces +- Never hardcode version numbers, Node requirements, or other values that exist in source files (e.g. `package.json` `engines.node`). Instead, instruct the generator to read them from the source. Hardcoded values drift silently. + +### Coverage Rule +- Every public API must appear in at least one doc file +- Every CLI flag must be documented +- Every config field must be documented with type and description + +--- + +## docs/INDEX.md + +**Scope:** Navigation hub — package overview, CLI tools, doc links, application model +**Source files:** `package.json`, `README.md` +**Required content:** +- Package name, version, license +- Table of 3 CLI tools with commands +- Table linking to all other doc files +- Application model example (Service Worker pattern) +- Runtime API import summary +- External links (GitHub, npm, FastEdge platform) + +## docs/quickstart.md + +**Scope:** First-time setup — install, scaffold, build, deploy +**Source files:** `README.md`, `src/cli/fastedge-init/init.ts`, `src/cli/fastedge-build/build.ts` +**Required content:** +- Prerequisites — read the minimum Node version from `package.json` `engines.node` field (do NOT hardcode a version number) +- npm install command +- Two paths: scaffold with fastedge-init OR build directly +- Complete "first app" example with env vars, secrets, and KV store +- Next steps links + +## docs/BUILD_CLI.md + +**Scope:** fastedge-build CLI — all flags, modes, config format +**Source files:** `src/cli/fastedge-build/build.ts`, `src/cli/fastedge-build/types.ts`, `src/cli/fastedge-build/config-build.ts` +**Required content:** +- All CLI flags with aliases, types, descriptions +- Direct mode vs config-driven mode +- BuildConfig interface (all fields) +- Static-only fields (AssetCacheConfig) +- Brief pipeline explanation (esbuild → Wizer → JCO) +- Output description + +**CRITICAL accuracy:** +- Flag table must match `arg()` definition in `build.ts` +- BuildConfig must match interface in `types.ts` +- Build types must match switch statement in `config-build.ts` + +## docs/INIT_CLI.md + +**Scope:** fastedge-init CLI — what it does, what it generates +**Source files:** `src/cli/fastedge-init/init.ts`, `src/cli/fastedge-init/http-handler.ts`, `src/cli/fastedge-init/static-site.ts`, `src/cli/fastedge-init/create-config.ts` +**Required content:** +- Usage (no flags — interactive only) +- HTTP handler setup (files created, config structure) +- Static website setup (files created, config structure, additional prompts) +- Post-scaffolding build command + +## docs/ASSETS_CLI.md + +**Scope:** fastedge-assets CLI — manifest generation +**Source files:** `src/cli/fastedge-assets/asset-cli.ts`, `src/server/static-assets/asset-manifest/create-manifest.ts` +**Required content:** +- CLI flags and usage modes +- Manifest structure (asset metadata format) +- When to use vs automatic generation in static builds + +## docs/STATIC_SITES.md + +**Scope:** Static site support — full workflow, createStaticServer API, server config +**Source files:** `src/server/static-assets/static-server/create-static-server.ts`, `types/server/static-assets/` +**Required content:** +- How embedding works (Wizer pre-initialization) +- Quick start workflow +- Build config fields (static-specific) +- Server config fields (all options) +- createStaticServer API with examples +- Critical constraint: top-level initialization only +- v1 → v2 migration + +**CRITICAL accuracy:** +- ServerConfig fields must match `create-static-server.ts` defaults +- StaticServer methods must match type declarations +- Wizer constraint must be clearly stated + +## docs/SDK_API.md + +**Scope:** All runtime APIs available in WASM +**Source files:** `types/fastedge-env.d.ts`, `types/fastedge-secret.d.ts`, `types/fastedge-kv.d.ts`, `types/globals.d.ts` +**Required content:** +- FastEdge APIs: getEnv, getSecret, getSecretEffectiveAt, KvStore (all methods) +- Web APIs: fetch, Request, Response, Headers, URL, URLSearchParams, streams, encoding, timers, crypto +- FetchEvent: request, client, respondWith +- ClientInfo and GeoData +- Headers immutability note +- Code examples for each FastEdge API + +**CRITICAL accuracy:** +- KvStore method signatures must match `types/fastedge-kv.d.ts` +- `get()` returns `ArrayBuffer | null` (not string) +- `scan()` returns `Array` (not ArrayBuffer) +- `zrangeByScore()` / `zscan()` return `Array<[ArrayBuffer, number]>` tuples +- `getSecretEffectiveAt` takes `(name, effectiveAt)` where effectiveAt is a number diff --git a/fastedge-plugin-source/check-copilot-sync.sh b/fastedge-plugin-source/check-copilot-sync.sh new file mode 100755 index 0000000..03f439b --- /dev/null +++ b/fastedge-plugin-source/check-copilot-sync.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Validates copilot-instructions.md stays in sync with the codebase: +# 1. All doc files in manifest.json are referenced in the mapping table +# 2. All doc files in the mapping table actually exist on disk +# +# This script is part of the fastedge-plugin pipeline contract. +# Canonical template: fastedge-plugin/scripts/sync/templates/check-copilot-sync-template.sh +# Each source repo gets a copy at: fastedge-plugin-source/check-copilot-sync.sh +# +# Exits 0 if in sync, 1 if drift detected. + +set -euo pipefail + +MANIFEST="fastedge-plugin-source/manifest.json" +COPILOT=".github/copilot-instructions.md" +errors=0 + +if [ ! -f "$COPILOT" ]; then + echo "FAIL: $COPILOT does not exist" + exit 1 +fi + +# --- Check 1: manifest doc files appear in the mapping table --- + +# Extract doc/schema paths that appear in mapping table rows (lines starting with '|') +# grep returns exit code 1 when no lines match, which would abort under set -euo pipefail +mapping_table_docs=$(grep '^|' "$COPILOT" | grep -oP '`((?:docs|schemas)/[^`]+)`' | tr -d '`' | sort -u || true) + +if [ -z "$mapping_table_docs" ]; then + echo "FAIL: No doc/schema paths found in $COPILOT mapping table — expected backticked docs/ or schemas/ paths in table rows" + exit 1 +fi + +if [ -f "$MANIFEST" ]; then + doc_files=$(node -e " + const m = require('./$MANIFEST'); + const files = new Set(); + for (const src of Object.values(m.sources)) { + for (const f of src.files) { + if (f.startsWith('docs/') || f.startsWith('schemas/')) { + files.add(f); + } + } + } + [...files].sort().forEach(f => console.log(f)); + ") + + missing=() + for doc in $doc_files; do + if ! echo "$mapping_table_docs" | grep -qF "$doc"; then + missing+=("$doc") + fi + done + + if [ ${#missing[@]} -gt 0 ]; then + echo "FAIL: Doc files from $MANIFEST missing from $COPILOT:" + for f in "${missing[@]}"; do + echo " - $f" + done + errors=1 + else + echo "OK: All manifest doc files are referenced in copilot-instructions.md" + fi +else + echo "SKIP: No manifest found at $MANIFEST" +fi + +# --- Check 2: doc files referenced in mapping table exist on disk --- + +# Reuse mapping_table_docs extracted above for check 2 +stale=() +while IFS= read -r doc_path; do + [ -z "$doc_path" ] && continue + if [ ! -f "$doc_path" ]; then + stale+=("$doc_path") + fi +done <<< "$mapping_table_docs" + +if [ ${#stale[@]} -gt 0 ]; then + echo "FAIL: Doc files referenced in $COPILOT mapping table do not exist:" + for f in "${stale[@]}"; do + echo " - $f" + done + errors=1 +else + echo "OK: All doc files in copilot-instructions mapping table exist on disk" +fi + +# --- Result --- + +if [ $errors -ne 0 ]; then + echo "" + echo "Fix the issues above and re-run this check." + exit 1 +fi diff --git a/fastedge-plugin-source/generate-docs.sh b/fastedge-plugin-source/generate-docs.sh new file mode 100755 index 0000000..a278eaf --- /dev/null +++ b/fastedge-plugin-source/generate-docs.sh @@ -0,0 +1,357 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Generate docs/ from source code using .generation-config.md +# +# Usage: +# ./fastedge-plugin-source/generate-docs.sh # all files (parallel where possible) +# ./fastedge-plugin-source/generate-docs.sh SDK_API.md # specific file +# ./fastedge-plugin-source/generate-docs.sh SDK_API.md BUILD_CLI.md # multiple files +# +# Reference implementation: fastedge-test/fastedge-plugin-source/generate-docs.sh + +# Model to use for generation (sonnet is recommended for cost efficiency) +MODEL="sonnet" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CONFIG_FILE="$SCRIPT_DIR/.generation-config.md" +DOCS_DIR="$REPO_ROOT/docs" + +# --- Cleanup on interrupt --- +# Two mechanisms work together to ensure clean shutdown: +# +# 1. kill_tree() recursively kills each background subshell AND its children +# (the `claude` processes). Plain `kill $pid` only kills the subshell, +# leaving `claude` orphaned. +# +# 2. INTERRUPT_FLAG file signals subshells to stop retrying. Bash variables +# don't cross process boundaries, so a temp file is the reliable way to +# communicate "stop" to background jobs before their next retry iteration. +ALL_PIDS=() +INTERRUPT_FLAG=$(mktemp /tmp/.generate-docs-interrupt.XXXXXX) +rm -f "$INTERRUPT_FLAG" # absent = running; present = stop + +kill_tree() { + local pid=$1 + local children + children=$(pgrep -P "$pid" 2>/dev/null || true) + for child in $children; do + kill_tree "$child" + done + kill "$pid" 2>/dev/null || true +} + +cleanup() { + echo "" + echo "Interrupted — killing background processes..." + touch "$INTERRUPT_FLAG" # tell subshells to stop retrying + trap - INT TERM # prevent re-entry + + for pid in "${ALL_PIDS[@]}"; do + kill_tree "$pid" + done + + # Clean up temp files left by killed generate_file subshells + # Must happen BEFORE kill -- -$$ which kills this script too + rm -f "$DOCS_DIR"/.[A-Z]*.[a-zA-Z0-9]* 2>/dev/null || true + rm -f "$INTERRUPT_FLAG" + + # Belt-and-suspenders: kill entire process group (including this script) + kill -- -$$ 2>/dev/null || true + exit 130 +} + +trap cleanup INT TERM + +# ============================================================================= +# === CUSTOMIZE: Define your doc files and their dependency tiers === +# +# Tier 1: Independent files (generated in parallel — read from source code only) +# Tier 2: Files that reference tier 1 docs (e.g. quickstart, getting-started) +# Tier 3: Files that summarize all other docs (e.g. INDEX.md) +# ============================================================================= + +TIER1_FILES=("BUILD_CLI.md" "INIT_CLI.md" "ASSETS_CLI.md" "STATIC_SITES.md" "SDK_API.md") +TIER2_FILES=("quickstart.md") +TIER3_FILES=("INDEX.md") + +ALL_FILES=("${TIER1_FILES[@]}" "${TIER2_FILES[@]}" "${TIER3_FILES[@]}") + +# ============================================================================= +# === CUSTOMIZE: Map each doc file to its source files === +# +# Keys must match the filenames in the tier arrays above. +# Values are space-separated paths relative to the repo root. +# The script reads each file and passes its content to the generation prompt. +# ============================================================================= + +declare -A SOURCE_FILES +SOURCE_FILES[INDEX.md]="package.json README.md" +SOURCE_FILES[quickstart.md]="README.md src/cli/fastedge-init/init.ts src/cli/fastedge-build/build.ts" +SOURCE_FILES[BUILD_CLI.md]="src/cli/fastedge-build/build.ts src/cli/fastedge-build/types.ts src/cli/fastedge-build/config-build.ts" +SOURCE_FILES[INIT_CLI.md]="src/cli/fastedge-init/init.ts src/cli/fastedge-init/http-handler.ts src/cli/fastedge-init/static-site.ts src/cli/fastedge-init/create-config.ts" +SOURCE_FILES[ASSETS_CLI.md]="src/cli/fastedge-assets/asset-cli.ts src/server/static-assets/asset-manifest/create-manifest.ts" +SOURCE_FILES[STATIC_SITES.md]="src/server/static-assets/static-server/create-static-server.ts" +SOURCE_FILES[SDK_API.md]="types/fastedge-env.d.ts types/fastedge-secret.d.ts types/fastedge-kv.d.ts types/globals.d.ts" + +# ============================================================================= +# === CUSTOMIZE: Package name for the generation prompt === +# ============================================================================= + +PACKAGE_NAME="@gcoredev/fastedge-sdk-js" + +# ============================================================================= +# === END CUSTOMIZATION — everything below is the reusable engine === +# ============================================================================= + +if [ ! -f "$CONFIG_FILE" ]; then + echo "Error: $CONFIG_FILE not found" + exit 1 +fi + +# Determine which files to generate +if [ $# -eq 0 ]; then + targets=("${ALL_FILES[@]}") + run_all=true +else + targets=("$@") + run_all=false + # Validate targets + for target in "${targets[@]}"; do + found=false + for valid in "${ALL_FILES[@]}"; do + if [ "$target" = "$valid" ]; then + found=true + break + fi + done + if [ "$found" = false ]; then + echo "Error: unknown doc file '$target'" + echo "Valid files: ${ALL_FILES[*]}" + exit 1 + fi + done +fi + +mkdir -p "$DOCS_DIR" + +# Clean up stale temp files from previous interrupted runs +rm -f "$DOCS_DIR"/.[A-Z]*.[a-zA-Z0-9]* 2>/dev/null || true + +generate_file() { + local target="$1" + + # Validate that SOURCE_FILES has an entry for this target + if [ -z "${SOURCE_FILES[$target]+set}" ] || [ -z "${SOURCE_FILES[$target]}" ]; then + echo " ERROR: no SOURCE_FILES entry for '$target' — add one to the CUSTOMIZE section" + return 1 + fi + + local sources="${SOURCE_FILES[$target]}" + + # Build the source files content block + local source_content="" + local loaded=0 + for src in $sources; do + local full_path="$REPO_ROOT/$src" + if [ ! -f "$full_path" ]; then + echo " Warning: source file $src not found, skipping" + continue + fi + source_content+=" +--- FILE: $src --- +$(cat "$full_path") +--- END FILE --- +" + loaded=$((loaded + 1)) + done + + if [ "$loaded" -eq 0 ]; then + echo " ERROR: all source files for '$target' are missing (expected: $sources)" + return 1 + fi + + # Extract the section for this target from generation-config + # Use awk variable to avoid regex delimiter issues with / + local section + local escaped_target="docs/$target" + section=$(awk -v start="## $escaped_target" ' + $0 == start { found=1; next } + found && /^## docs\// { exit } + found { print } + ' "$CONFIG_FILE") + + # Validate that the config section exists and has content + if [ -z "$(echo "$section" | tr -d '[:space:]')" ]; then + echo " ERROR: no instructions found for '$target' — add a '## docs/$target' section to $CONFIG_FILE" + return 1 + fi + + # Check for existing doc to enable incremental updates + local existing_doc="" + local existing_path="$DOCS_DIR/$target" + local mode="Generate" + if [ -f "$existing_path" ]; then + existing_doc=$(cat "$existing_path") + mode="Update" + fi + + if [ "$mode" = "Update" ]; then + echo "Updating docs/$target ..." + else + echo "Generating docs/$target ..." + fi + + local existing_section="" + if [ -n "$existing_doc" ]; then + existing_section=" +# Existing Content for docs/$target +Use this as the baseline. Preserve all accurate content and manual additions. Only change what is incorrect, incomplete, or missing per the source code. Keep sections not covered by the instructions above. Apply table formatting rules to all tables. + + + +$existing_doc + +" + fi + + local prompt + prompt="$(cat < "$tmpfile" + + # Validate: first non-empty line must start with # + local first_line + first_line=$(grep -m1 '.' "$tmpfile" || true) + if [[ "$first_line" == \#* ]]; then + mv "$tmpfile" "$DOCS_DIR/$target" + echo " Done: docs/$target" + return 0 + fi + + echo " Attempt $attempt/$max_attempts failed for $target (got conversational output), retrying..." + attempt=$((attempt + 1)) + done + + rm -f "$tmpfile" + echo " FAILED after $max_attempts attempts: docs/$target" + return 1 +} + +# Run a tier of files in parallel, wait for all to complete +run_tier() { + local tier_name="$1" + shift + local files=("$@") + local pids=() + local failed=() + + # Skip empty tiers + if [ ${#files[@]} -eq 0 ]; then + return 0 + fi + + echo "--- $tier_name (${#files[@]} files in parallel) ---" + + for target in "${files[@]}"; do + generate_file "$target" & + pids+=($!) + ALL_PIDS+=($!) + done + + # Wait for all and collect failures + for i in "${!pids[@]}"; do + if ! wait "${pids[$i]}"; then + failed+=("${files[$i]}") + fi + done + + if [ ${#failed[@]} -gt 0 ]; then + echo " FAILED in $tier_name: ${failed[*]}" + return 1 + fi + return 0 +} + +# --- Main execution --- + +if [ "$run_all" = true ]; then + # Parallel tiered execution + tier_failed=false + + run_tier "Tier 1: Core reference" "${TIER1_FILES[@]}" || tier_failed=true + + if [ "$tier_failed" = false ]; then + run_tier "Tier 2: Quickstart" "${TIER2_FILES[@]}" || tier_failed=true + else + echo "Skipping Tier 2 due to Tier 1 failures" + fi + + if [ "$tier_failed" = false ]; then + run_tier "Tier 3: Index" "${TIER3_FILES[@]}" || tier_failed=true + else + echo "Skipping Tier 3 due to earlier failures" + fi + + echo "" + echo "=== Generation Complete ===" + echo "Generated: ${#ALL_FILES[@]} file(s) in docs/" + if [ "$tier_failed" = true ]; then + echo "Some files failed — check output above" + exit 1 + fi + + # Regenerate llms.txt from docs/ contents + "$SCRIPT_DIR/generate-llms-txt.sh" +else + # Specific files: run sequentially (user chose explicit order) + failed=() + for target in "${targets[@]}"; do + if ! generate_file "$target"; then + failed+=("$target") + echo " FAILED: docs/$target" + fi + done + + echo "" + echo "=== Generation Complete ===" + echo "Generated: ${#targets[@]} file(s) in docs/" + if [ ${#failed[@]} -gt 0 ]; then + echo "Failed: ${failed[*]}" + exit 1 + fi +fi diff --git a/fastedge-plugin-source/generate-llms-txt.sh b/fastedge-plugin-source/generate-llms-txt.sh new file mode 100755 index 0000000..5ccb7e3 --- /dev/null +++ b/fastedge-plugin-source/generate-llms-txt.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Generate llms.txt from docs/ contents and package.json +# +# Produces an llms.txt file at the repo root that indexes all documentation +# files in docs/. This follows the llms.txt proposal (llmstxt.org) to help +# LLM agents discover and navigate package documentation. +# +# Usage: +# ./fastedge-plugin-source/generate-llms-txt.sh +# +# Setup: +# 1. Copy this file to /fastedge-plugin-source/generate-llms-txt.sh +# 2. chmod +x fastedge-plugin-source/generate-llms-txt.sh +# 3. Called automatically by generate-docs.sh after a full generation run +# +# Requirements: jq, bash 4+ +# No customization needed — package name and docs are discovered at runtime. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DOCS_DIR="$REPO_ROOT/docs" +OUTPUT="$REPO_ROOT/llms.txt" + +# --- Validate prerequisites --- + +if [ ! -f "$REPO_ROOT/package.json" ]; then + echo "Error: package.json not found in $REPO_ROOT" + exit 1 +fi + +if [ ! -d "$DOCS_DIR" ]; then + echo "Error: docs/ directory not found" + exit 1 +fi + +if [ ! -f "$DOCS_DIR/INDEX.md" ]; then + echo "Error: docs/INDEX.md not found — required for llms.txt summary" + exit 1 +fi + +if ! command -v jq &>/dev/null; then + echo "Error: jq is required but not installed" + exit 1 +fi + +# --- Extract metadata --- + +PACKAGE_NAME=$(jq -r '.name' "$REPO_ROOT/package.json") + +# Extract summary from INDEX.md line 3 (expected format: blockquote or plain text after H1 + blank line) +# Strips leading "> " if present +SUMMARY=$(sed -n '3p' "$DOCS_DIR/INDEX.md" | sed 's/^> //') + +if [ -z "$SUMMARY" ]; then + echo "Warning: could not extract summary from docs/INDEX.md line 3, using package name" + SUMMARY="Documentation for $PACKAGE_NAME" +fi + +# --- Build llms.txt --- + +{ + echo "# $PACKAGE_NAME" + echo "" + echo "> $SUMMARY" + echo "" + echo "## Documentation" + echo "" + + # INDEX.md first — it's the entry point + index_heading=$(head -1 "$DOCS_DIR/INDEX.md" | sed 's/^#\+ //') + echo "- [$index_heading](docs/INDEX.md)" + + # Remaining docs alphabetically, skip INDEX.md + for doc in "$DOCS_DIR"/*.md; do + filename=$(basename "$doc") + [ "$filename" = "INDEX.md" ] && continue + + heading=$(head -1 "$doc" | sed 's/^#\+ //') + if [ -z "$heading" ]; then + # Fallback: use filename without extension + heading="${filename%.md}" + fi + + echo "- [$heading](docs/$filename)" + done +} > "$OUTPUT" + +echo " Done: llms.txt ($(wc -l < "$OUTPUT") lines)" diff --git a/fastedge-plugin-source/manifest.json b/fastedge-plugin-source/manifest.json new file mode 100644 index 0000000..ef32d8a --- /dev/null +++ b/fastedge-plugin-source/manifest.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://fastedge-plugin-source/manifest/v1", + "repo_id": "FastEdge-sdk-js", + "version": "1.0.0", + "sources": { + "sdk-api": { + "files": [ + "docs/SDK_API.md" + ], + "required": true, + "description": "Runtime APIs — environment variables, secrets, KV store, Web APIs available in WASM" + }, + "build-cli": { + "files": [ + "docs/BUILD_CLI.md" + ], + "required": true, + "description": "fastedge-build CLI — compilation flags, config-driven builds, build types" + }, + "init-cli": { + "files": [ + "docs/INIT_CLI.md" + ], + "required": false, + "description": "fastedge-init CLI — interactive project scaffolding wizard" + }, + "static-sites": { + "files": [ + "docs/STATIC_SITES.md", + "docs/ASSETS_CLI.md" + ], + "required": false, + "description": "Static site support — createStaticServer API, asset manifests, server config" + }, + "quickstart": { + "files": [ + "docs/quickstart.md" + ], + "required": true, + "description": "Installation, first build, basic usage patterns with code examples" + } + }, + "target_mapping": { + "sdk-api": { + "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/sdk-reference.md", + "section": null + }, + "build-cli": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/build-cli.md", + "section": null + }, + "init-cli": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/init-cli.md", + "section": null + }, + "static-sites": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/static-sites.md", + "section": null + }, + "quickstart": { + "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/quickstart.md", + "section": null + } + }, + "validation": { + "mode": "advisory", + "strict_fields": ["sdk-api", "build-cli", "quickstart"] + } +} diff --git a/docs/.gitignore b/github-pages/.gitignore similarity index 100% rename from docs/.gitignore rename to github-pages/.gitignore diff --git a/docs/.node-version b/github-pages/.node-version similarity index 100% rename from docs/.node-version rename to github-pages/.node-version diff --git a/docs/astro.config.mjs b/github-pages/astro.config.mjs similarity index 97% rename from docs/astro.config.mjs rename to github-pages/astro.config.mjs index 1ef8d5b..0921880 100644 --- a/docs/astro.config.mjs +++ b/github-pages/astro.config.mjs @@ -6,6 +6,11 @@ export default defineConfig({ site: 'https://g-core.github.io', base: '/FastEdge-sdk-js', vite: { + resolve: { + alias: { + '@examples': new URL('../examples', import.meta.url).pathname, + }, + }, server: { watch: { // Use polling instead of native file watchers to avoid EMFILE errors diff --git a/docs/docs/src/env.d.ts b/github-pages/docs/src/env.d.ts similarity index 100% rename from docs/docs/src/env.d.ts rename to github-pages/docs/src/env.d.ts diff --git a/docs/package.json b/github-pages/package.json similarity index 100% rename from docs/package.json rename to github-pages/package.json diff --git a/docs/public/context7.json b/github-pages/public/context7.json similarity index 100% rename from docs/public/context7.json rename to github-pages/public/context7.json diff --git a/docs/public/fastedge-init-http.png b/github-pages/public/fastedge-init-http.png similarity index 100% rename from docs/public/fastedge-init-http.png rename to github-pages/public/fastedge-init-http.png diff --git a/docs/public/fastedge-init-static.png b/github-pages/public/fastedge-init-static.png similarity index 100% rename from docs/public/fastedge-init-static.png rename to github-pages/public/fastedge-init-static.png diff --git a/docs/public/favicon.svg b/github-pages/public/favicon.svg similarity index 100% rename from docs/public/favicon.svg rename to github-pages/public/favicon.svg diff --git a/docs/src/content/config.ts b/github-pages/src/content/config.ts similarity index 100% rename from docs/src/content/config.ts rename to github-pages/src/content/config.ts diff --git a/docs/src/content/docs/examples/downstream-fetch.mdx b/github-pages/src/content/docs/examples/downstream-fetch.mdx similarity index 57% rename from docs/src/content/docs/examples/downstream-fetch.mdx rename to github-pages/src/content/docs/examples/downstream-fetch.mdx index d1d9ec3..bd19544 100644 --- a/docs/src/content/docs/examples/downstream-fetch.mdx +++ b/github-pages/src/content/docs/examples/downstream-fetch.mdx @@ -7,6 +7,6 @@ prev: --- import { Code } from '@astrojs/starlight/components'; -import importedCode from '/examples/downstream-fetch.js?raw'; +import importedCode from '@examples/downstream-fetch/src/index.js?raw'; - + diff --git a/docs/src/content/docs/examples/downstream-modify-response.mdx b/github-pages/src/content/docs/examples/downstream-modify-response.mdx similarity index 57% rename from docs/src/content/docs/examples/downstream-modify-response.mdx rename to github-pages/src/content/docs/examples/downstream-modify-response.mdx index e0b9635..9d21cd0 100644 --- a/docs/src/content/docs/examples/downstream-modify-response.mdx +++ b/github-pages/src/content/docs/examples/downstream-modify-response.mdx @@ -7,6 +7,6 @@ prev: --- import { Code } from '@astrojs/starlight/components'; -import importedCode from '/examples/downstream-modify-response.js?raw'; +import importedCode from '@examples/downstream-modify-response/src/index.js?raw'; - + diff --git a/docs/src/content/docs/examples/headers.mdx b/github-pages/src/content/docs/examples/headers.mdx similarity index 63% rename from docs/src/content/docs/examples/headers.mdx rename to github-pages/src/content/docs/examples/headers.mdx index d5e32a3..6512b62 100644 --- a/docs/src/content/docs/examples/headers.mdx +++ b/github-pages/src/content/docs/examples/headers.mdx @@ -7,6 +7,6 @@ prev: --- import { Code } from '@astrojs/starlight/components'; -import importedCode from '/examples/headers.js?raw'; +import importedCode from '@examples/headers/src/index.js?raw'; - + diff --git a/github-pages/src/content/docs/examples/hello-world.mdx b/github-pages/src/content/docs/examples/hello-world.mdx new file mode 100644 index 0000000..3e52e32 --- /dev/null +++ b/github-pages/src/content/docs/examples/hello-world.mdx @@ -0,0 +1,12 @@ +--- +title: Hello World +description: The simplest possible FastEdge application. +prev: + link: /FastEdge-sdk-js/examples/main-examples/ + label: Back to examples +--- + +import { Code } from '@astrojs/starlight/components'; +import importedCode from '@examples/hello-world/src/index.js?raw'; + + diff --git a/docs/src/content/docs/examples/kv-store.mdx b/github-pages/src/content/docs/examples/kv-store.mdx similarity index 57% rename from docs/src/content/docs/examples/kv-store.mdx rename to github-pages/src/content/docs/examples/kv-store.mdx index 8a01a8f..093fc89 100644 --- a/docs/src/content/docs/examples/kv-store.mdx +++ b/github-pages/src/content/docs/examples/kv-store.mdx @@ -7,6 +7,6 @@ prev: --- import { Code } from '@astrojs/starlight/components'; -import importedCode from '/examples/kv-store.js?raw'; +import importedCode from '@examples/kv-store-basic/src/index.js?raw'; - + diff --git a/docs/src/content/docs/examples/main-examples.mdx b/github-pages/src/content/docs/examples/main-examples.mdx similarity index 65% rename from docs/src/content/docs/examples/main-examples.mdx rename to github-pages/src/content/docs/examples/main-examples.mdx index 10e5918..6042dcf 100644 --- a/docs/src/content/docs/examples/main-examples.mdx +++ b/github-pages/src/content/docs/examples/main-examples.mdx @@ -5,21 +5,12 @@ description: A landing page for JS Examples. import { LinkCard, CardGrid } from '@astrojs/starlight/components'; -This is a brief list of examples, demonstrating the basic usage of FastEdge compute, there are more -standalone examples contained in the repo. +All examples below are standalone projects you can clone and build. For the full list including advanced examples, see the examples folder on GitHub. - - examples - - -## Some Basic Examples +## Getting Started - + + diff --git a/docs/src/content/docs/guides/creating-a-static-manifest.md b/github-pages/src/content/docs/guides/creating-a-static-manifest.md similarity index 92% rename from docs/src/content/docs/guides/creating-a-static-manifest.md rename to github-pages/src/content/docs/guides/creating-a-static-manifest.md index 5d82b1a..4577737 100644 --- a/docs/src/content/docs/guides/creating-a-static-manifest.md +++ b/github-pages/src/content/docs/guides/creating-a-static-manifest.md @@ -71,6 +71,5 @@ which `wizer` snapshots the current state, and creates the final wasm binary wit contents included within the memory at startup. This process ensures there is **NO** start-up delay and all files are available at runtime. -There is a more complete example in our -FastEdge-examples -repo. +There is a more complete example +here. diff --git a/docs/src/content/docs/guides/creating-a-wasm.md b/github-pages/src/content/docs/guides/creating-a-wasm.md similarity index 100% rename from docs/src/content/docs/guides/creating-a-wasm.md rename to github-pages/src/content/docs/guides/creating-a-wasm.md diff --git a/docs/src/content/docs/guides/fastedge-init.md b/github-pages/src/content/docs/guides/fastedge-init.md similarity index 100% rename from docs/src/content/docs/guides/fastedge-init.md rename to github-pages/src/content/docs/guides/fastedge-init.md diff --git a/docs/src/content/docs/guides/fastedge-sdk-js.md b/github-pages/src/content/docs/guides/fastedge-sdk-js.md similarity index 100% rename from docs/src/content/docs/guides/fastedge-sdk-js.md rename to github-pages/src/content/docs/guides/fastedge-sdk-js.md diff --git a/docs/src/content/docs/guides/fastedge.md b/github-pages/src/content/docs/guides/fastedge.md similarity index 100% rename from docs/src/content/docs/guides/fastedge.md rename to github-pages/src/content/docs/guides/fastedge.md diff --git a/docs/src/content/docs/guides/installation.md b/github-pages/src/content/docs/guides/installation.md similarity index 94% rename from docs/src/content/docs/guides/installation.md rename to github-pages/src/content/docs/guides/installation.md index 2fbe61d..94b9ffb 100644 --- a/docs/src/content/docs/guides/installation.md +++ b/github-pages/src/content/docs/guides/installation.md @@ -5,7 +5,7 @@ description: Install guide for FastEdge-sdk-js :::note[Prerequisites] -Node version 18 or above installed. +Node version 22 or above installed. ```sh node --version diff --git a/docs/src/content/docs/guides/serving-a-static-site.md b/github-pages/src/content/docs/guides/serving-a-static-site.md similarity index 100% rename from docs/src/content/docs/guides/serving-a-static-site.md rename to github-pages/src/content/docs/guides/serving-a-static-site.md diff --git a/docs/src/content/docs/index.mdx b/github-pages/src/content/docs/index.mdx similarity index 100% rename from docs/src/content/docs/index.mdx rename to github-pages/src/content/docs/index.mdx diff --git a/docs/src/content/docs/migrating/v1-v2.md b/github-pages/src/content/docs/migrating/v1-v2.md similarity index 100% rename from docs/src/content/docs/migrating/v1-v2.md rename to github-pages/src/content/docs/migrating/v1-v2.md diff --git a/docs/src/content/docs/reference/fastedge/env/index.md b/github-pages/src/content/docs/reference/fastedge/env/index.md similarity index 100% rename from docs/src/content/docs/reference/fastedge/env/index.md rename to github-pages/src/content/docs/reference/fastedge/env/index.md diff --git a/docs/src/content/docs/reference/fastedge/kv/bloom-filter.md b/github-pages/src/content/docs/reference/fastedge/kv/bloom-filter.md similarity index 100% rename from docs/src/content/docs/reference/fastedge/kv/bloom-filter.md rename to github-pages/src/content/docs/reference/fastedge/kv/bloom-filter.md diff --git a/docs/src/content/docs/reference/fastedge/kv/key-value.md b/github-pages/src/content/docs/reference/fastedge/kv/key-value.md similarity index 100% rename from docs/src/content/docs/reference/fastedge/kv/key-value.md rename to github-pages/src/content/docs/reference/fastedge/kv/key-value.md diff --git a/docs/src/content/docs/reference/fastedge/kv/open.md b/github-pages/src/content/docs/reference/fastedge/kv/open.md similarity index 100% rename from docs/src/content/docs/reference/fastedge/kv/open.md rename to github-pages/src/content/docs/reference/fastedge/kv/open.md diff --git a/docs/src/content/docs/reference/fastedge/kv/zset.md b/github-pages/src/content/docs/reference/fastedge/kv/zset.md similarity index 100% rename from docs/src/content/docs/reference/fastedge/kv/zset.md rename to github-pages/src/content/docs/reference/fastedge/kv/zset.md diff --git a/docs/src/content/docs/reference/fastedge/secret/get-secret-effective-at.md b/github-pages/src/content/docs/reference/fastedge/secret/get-secret-effective-at.md similarity index 100% rename from docs/src/content/docs/reference/fastedge/secret/get-secret-effective-at.md rename to github-pages/src/content/docs/reference/fastedge/secret/get-secret-effective-at.md diff --git a/docs/src/content/docs/reference/fastedge/secret/get-secret.md b/github-pages/src/content/docs/reference/fastedge/secret/get-secret.md similarity index 100% rename from docs/src/content/docs/reference/fastedge/secret/get-secret.md rename to github-pages/src/content/docs/reference/fastedge/secret/get-secret.md diff --git a/docs/src/content/docs/reference/headers.md b/github-pages/src/content/docs/reference/headers.md similarity index 100% rename from docs/src/content/docs/reference/headers.md rename to github-pages/src/content/docs/reference/headers.md diff --git a/docs/src/content/docs/reference/overview.md b/github-pages/src/content/docs/reference/overview.md similarity index 100% rename from docs/src/content/docs/reference/overview.md rename to github-pages/src/content/docs/reference/overview.md diff --git a/docs/src/content/docs/reference/request.md b/github-pages/src/content/docs/reference/request.md similarity index 100% rename from docs/src/content/docs/reference/request.md rename to github-pages/src/content/docs/reference/request.md diff --git a/docs/src/content/docs/reference/response.md b/github-pages/src/content/docs/reference/response.md similarity index 100% rename from docs/src/content/docs/reference/response.md rename to github-pages/src/content/docs/reference/response.md diff --git a/docs/src/env.d.ts b/github-pages/src/env.d.ts similarity index 100% rename from docs/src/env.d.ts rename to github-pages/src/env.d.ts diff --git a/docs/src/styles/custom.css b/github-pages/src/styles/custom.css similarity index 100% rename from docs/src/styles/custom.css rename to github-pages/src/styles/custom.css diff --git a/docs/src/test.js b/github-pages/src/test.js similarity index 100% rename from docs/src/test.js rename to github-pages/src/test.js diff --git a/docs/tsconfig.json b/github-pages/tsconfig.json similarity index 100% rename from docs/tsconfig.json rename to github-pages/tsconfig.json diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..77a380f --- /dev/null +++ b/llms.txt @@ -0,0 +1,13 @@ +# @gcoredev/fastedge-sdk-js + +> The FastEdge JS SDK (`@gcoredev/fastedge-sdk-js`) is the JavaScript/TypeScript development toolkit for building serverless edge applications on Gcore's FastEdge platform. It compiles your code into WebAssembly components that run across global edge data centers. + +## Documentation + +- [FastEdge JS SDK Documentation](docs/INDEX.md) +- [fastedge-assets CLI](docs/ASSETS_CLI.md) +- [fastedge-build CLI](docs/BUILD_CLI.md) +- [fastedge-init CLI](docs/INIT_CLI.md) +- [Quickstart](docs/quickstart.md) +- [SDK API Reference](docs/SDK_API.md) +- [Static Sites](docs/STATIC_SITES.md) diff --git a/package.json b/package.json index 64ff701..4b31a8b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@gcoredev/fastedge-sdk-js", "version": "2.1.0", "engines": { - "node": ">=20", + "node": ">=22", "pnpm": ">=10" }, "license": "Apache-2.0", @@ -42,17 +42,18 @@ "build:libs": "node esbuild/fastedge-libs.js", "build:type-references": "tsc -p ./tsconfig.build.json && node ./scripts/inject-fastedge-reference-types.js", "build:types": "tsc -p ./tsconfig.build.json", - "typecheck": "tsc -p ./tsconfig.typecheck.json ", + "generate:docs": "./fastedge-plugin-source/generate-docs.sh", "generate:wit-world": "npm-run-all -s wit:merge wit:bindings", - "wit:merge": "./runtime/fastedge/scripts/merge-wit-bindings.js", - "wit:bindings": "./runtime/fastedge/scripts/create-wit-bindings.sh", "lint": "npx eslint -c ./config/eslint/repo/.eslintrc.cjs .", + "prepare": "husky", "semantic-release": "semantic-release", "test:solo": "NODE_ENV=test jest -c ./config/jest/jest.config.js --", "test:unit:dev": "NODE_ENV=test jest -c ./config/jest/jest.config.js -- src/", "test:unit": "NODE_ENV=test RUN_SLOW_TESTS=true jest -c ./config/jest/jest.config.js -- src/", "test:integration": "NODE_ENV=test jest -c ./config/jest/jest.config.js -- integration-tests/", - "prepare": "husky" + "typecheck": "tsc -p ./tsconfig.typecheck.json ", + "wit:merge": "./runtime/fastedge/scripts/merge-wit-bindings.js", + "wit:bindings": "./runtime/fastedge/scripts/create-wit-bindings.sh" }, "devDependencies": { "@babel/core": "^7.28.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c730e2f..10976e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,19 +133,19 @@ importers: specifier: ^5.8.2 version: 5.9.3 - docs: + examples/ab-testing: dependencies: - '@astrojs/starlight': - specifier: ^0.37.6 - version: 0.37.6(astro@5.17.3(@types/node@25.0.3)(rollup@4.55.1)(terser@5.44.1)(typescript@5.9.3)) - astro: - specifier: ^5.17.3 - version: 5.17.3(@types/node@25.0.3)(rollup@4.55.1)(terser@5.44.1)(typescript@5.9.3) - sharp: - specifier: ^0.34.5 - version: 0.34.5 + '@gcoredev/fastedge-sdk-js': + specifier: ^2.1.0 + version: 2.2.0 - examples/ab-testing: + examples/downstream-fetch: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.1.0 + version: 2.2.0 + + examples/downstream-modify-response: dependencies: '@gcoredev/fastedge-sdk-js': specifier: ^2.1.0 @@ -157,12 +157,30 @@ importers: specifier: ^2.1.0 version: 2.2.0 + examples/headers: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.1.0 + version: 2.2.0 + + examples/hello-world: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.1.0 + version: 2.2.0 + examples/kv-store: dependencies: '@gcoredev/fastedge-sdk-js': specifier: ^2.1.0 version: 2.2.0 + examples/kv-store-basic: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.1.0 + version: 2.2.0 + examples/mcp-server: dependencies: '@gcoredev/fastedge-sdk-js': @@ -216,6 +234,24 @@ importers: specifier: ^4.7.8 version: 4.7.8 + examples/variables-and-secrets: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.1.0 + version: 2.2.0 + + github-pages: + dependencies: + '@astrojs/starlight': + specifier: ^0.37.6 + version: 0.37.6(astro@5.17.3(@types/node@25.0.3)(rollup@4.55.1)(terser@5.44.1)(typescript@5.9.3)) + astro: + specifier: ^5.17.3 + version: 5.17.3(@types/node@25.0.3)(rollup@4.55.1)(terser@5.44.1)(typescript@5.9.3) + sharp: + specifier: ^0.34.5 + version: 0.34.5 + packages: '@astrojs/compiler@2.13.0': @@ -5860,9 +5896,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.6.2: - resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} - ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -11932,7 +11965,7 @@ snapshots: dependencies: destr: 2.0.5 node-fetch-native: 1.6.7 - ufo: 1.6.2 + ufo: 1.6.3 ohash@2.0.11: {} @@ -13226,8 +13259,6 @@ snapshots: typescript@5.9.3: {} - ufo@1.6.2: {} - ufo@1.6.3: {} uglify-js@3.19.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a0c749c..ec00f4a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,5 @@ packages: - - docs + - github-pages - examples/* onlyBuiltDependencies: diff --git a/scripts/filename-validation.sh b/scripts/filename-validation.sh index f6c62e8..3421f85 100755 --- a/scripts/filename-validation.sh +++ b/scripts/filename-validation.sh @@ -6,6 +6,7 @@ ignore_paths=( "./runtime/fastedge/build-debug" "./runtime/fastedge/build-release" "./runtime/fastedge/host-api" + "./runtime/fastedge/deps" "./node_modules" "./docs/node_modules" ) @@ -48,9 +49,11 @@ for file in $files; do # Extract the filename without the extension filename=$(basename -- "$file") - # Remove .d.ts extension if present + # Remove compound extensions, then the final extension if [[ $filename == *.d.ts ]]; then filename="${filename%.d.ts}" + elif [[ $filename == *.lock.yml || $filename == *.lock.yaml ]]; then + filename="${filename%.lock.*}" else # Remove the single extension filename="${filename%.*}" diff --git a/tsconfig.json b/tsconfig.json index 055c116..0f10171 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,5 @@ "typeRoots": ["./node_modules/@types", "./src/componentize/types"] }, "include": ["src/**/*", "**/*.test.ts", "**/*.spec.ts", "./types/*.d.ts"], - "exclude": ["node_modules", "docs/**", "runtime", "dist", "coverage"] + "exclude": ["node_modules", "github-pages/**", "runtime", "dist", "coverage"] } diff --git a/types/fastedge-env.d.ts b/types/fastedge-env.d.ts index f1914a6..d3c1f27 100644 --- a/types/fastedge-env.d.ts +++ b/types/fastedge-env.d.ts @@ -5,7 +5,7 @@ declare module 'fastedge::env' { * **Note**: The environment variables can only be retrieved when processing requests, not during build-time initialization. * * @param {string} name - The name of the environment variable. - * @returns {string} The value of the environment variable. + * @returns {string | null} The value of the environment variable, or `null` if not set. * * @example * ```js @@ -25,5 +25,5 @@ declare module 'fastedge::env' { * addEventListener("fetch", event => event.respondWith(app(event))); * ``` */ - function getEnv(name: string): string; + function getEnv(name: string): string | null; } diff --git a/types/fastedge-secret.d.ts b/types/fastedge-secret.d.ts index efbd234..9b4a73c 100644 --- a/types/fastedge-secret.d.ts +++ b/types/fastedge-secret.d.ts @@ -5,7 +5,7 @@ declare module 'fastedge::secret' { * **Note**: The secret variables can only be retrieved when processing requests, not during build-time initialization. * * @param {string} name - The name of the secret variable. - * @returns {string} The value of the secret variable. + * @returns {string | null} The value of the secret variable, or `null` if not set. * * @example * ```js @@ -25,7 +25,7 @@ declare module 'fastedge::secret' { * addEventListener("fetch", event => event.respondWith(app(event))); * ``` */ - function getSecret(name: string): string; + function getSecret(name: string): string | null; /** * Function to get the value for the provided secret variable name from a specific slot. @@ -34,7 +34,7 @@ declare module 'fastedge::secret' { * * @param {string} name - The name of the secret variable. * @param {number} effectiveAt - The slot index of the secret. (effectiveAt >= secret_slots.slot) - * @returns {string} The value of the secret variable. + * @returns {string | null} The value of the secret variable, or `null` if not set. * * @example * ```js @@ -54,5 +54,5 @@ declare module 'fastedge::secret' { * addEventListener("fetch", event => event.respondWith(app(event))); * ``` */ - function getSecretEffectiveAt(name: string, effectiveAt: number): string; + function getSecretEffectiveAt(name: string, effectiveAt: number): string | null; }