diff --git a/.github/scripts/check-version-sync.sh b/.github/scripts/check-version-sync.sh index 4354026..0adac00 100755 --- a/.github/scripts/check-version-sync.sh +++ b/.github/scripts/check-version-sync.sh @@ -75,12 +75,12 @@ runtime_constant="$(extract_quoted_version runtime-js/bootstrap/index.ts \ '.*runtimeJsVersion = "([0-9]+\.[0-9]+\.[0-9]+)";.*')" web_api_constant="$(extract_quoted_version runtime-js/web-api/index.ts \ '.*webApiVersion = "([0-9]+\.[0-9]+\.[0-9]+)";.*')" -website_version="$(extract_quoted_version 'website/app/(home)/page.tsx' \ +website_version="$(extract_quoted_version 'website/components/home/home-page.tsx' \ '.*softwareVersion: "([0-9]+\.[0-9]+\.[0-9]+)",.*')" for version_entry in \ "runtime-js/bootstrap/index.ts:${runtime_constant}" \ "runtime-js/web-api/index.ts:${web_api_constant}" \ - "website/app/(home)/page.tsx:${website_version}"; do + "website/components/home/home-page.tsx:${website_version}"; do version_file="${version_entry%%:*}" declared_version="${version_entry#*:}" if [[ "$declared_version" != "$version" ]]; then diff --git a/.github/scripts/release-notes.py b/.github/scripts/release-notes.py new file mode 100644 index 0000000..772769d --- /dev/null +++ b/.github/scripts/release-notes.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Extract one version's release body from the repository changelog.""" +import argparse +from datetime import date +import json +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parents[2] + + +def version_headings(text): + headings = [] + fence = None + offset = 0 + for line in text.splitlines(keepends=True): + marker = re.fullmatch(r" {0,3}(`{3,}|~{3,})([^\n]*)\n?", line) + if fence: + if (marker and marker[1][0] == fence[0] + and len(marker[1]) >= len(fence) and not marker[2].strip()): + fence = None + elif marker and (marker[1][0] == "~" or "`" not in marker[2]): + fence = marker[1] + else: + heading = re.fullmatch(r"## (.+?)\s*", line) + if heading: + headings.append((heading[1], offset, offset + len(line))) + offset += len(line) + if fence: + raise ValueError("unclosed changelog code fence") + return headings + + +def extract(text, version, require_date=False): + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", version): + raise ValueError("expected an unprefixed release version") + text = text.replace("\r\n", "\n") + headings = version_headings(text) + matches = [i for i, h in enumerate(headings) + if re.fullmatch(r"\[" + re.escape(version) + r"\](?: - .+)?", h[0])] + if len(matches) != 1: + raise ValueError(f"expected exactly one changelog section for {version}") + i = matches[0] + if require_date: + dated = re.fullmatch(r"\[" + re.escape(version) + r"\] - (\d{4}-\d{2}-\d{2})", headings[i][0]) + if not dated: + raise ValueError(f"release {version} requires a YYYY-MM-DD date, not a draft status") + try: + date.fromisoformat(dated[1]) + except ValueError: + raise ValueError(f"invalid release date for {version}: {dated[1]}") from None + end = headings[i + 1][1] if i + 1 < len(headings) else len(text) + body = text[headings[i][2]:end].strip() + if not any(line.strip() and not line.lstrip().startswith('#') for line in body.splitlines()): + raise ValueError(f"empty changelog section for {version}") + return body + "\n" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", nargs="?") + parser.add_argument("--changelog", type=Path, default=ROOT / "CHANGELOG.md") + parser.add_argument("--release-tag", help="require the matching vVERSION tag and a valid release date") + args = parser.parse_args() + version = args.version or json.loads((ROOT / "packages/tysel/package.json").read_text())["version"] + try: + if args.release_tag is not None and args.release_tag != f"v{version}": + raise ValueError(f"release tag {args.release_tag} does not match version {version}") + notes = extract(args.changelog.read_text(encoding="utf-8"), version, + require_date=args.release_tag is not None) + except (OSError, ValueError) as error: + parser.exit(1, f"release notes: {error}\n") + sys.stdout.write(notes) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test-release-notes.py b/.github/scripts/test-release-notes.py new file mode 100644 index 0000000..9eb39b8 --- /dev/null +++ b/.github/scripts/test-release-notes.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path +import unittest +import subprocess +import sys +import tempfile + +spec = importlib.util.spec_from_file_location("release_notes", Path(__file__).with_name("release-notes.py")) +notes = importlib.util.module_from_spec(spec) +spec.loader.exec_module(notes) + + +class ReleaseNotesTests(unittest.TestCase): + def test_exact_version_and_boundaries(self): + text = "# Changelog\n\n## [Unreleased]\nFuture\n\n## [0.3.0] - Unreleased\n\n### Fixed\n- Recovery.\n\n## [0.2.0]\nOld\n" + self.assertEqual(notes.extract(text, "0.3.0"), "### Fixed\n- Recovery.\n") + self.assertEqual(notes.extract(text, "0.2.0"), "Old\n") + + def test_prerelease_is_not_stable(self): + text = "## [0.3.0-rc.1]\nPreview\n## [0.3.0]\nStable\n" + self.assertEqual(notes.extract(text, "0.3.0-rc.1"), "Preview\n") + self.assertEqual(notes.extract(text, "0.3.0"), "Stable\n") + + def test_fenced_headings_preserve_migration_steps(self): + for opening, interior, closing in [ + ("```markdown", "## Example heading", "```"), + ("~~~~markdown", "## [0.3.0]\n~~~\n## More examples", "~~~~"), + (" ````", "```\n## [0.2.0]", " `````"), + ]: + body = f"### Migration\n{opening}\n{interior}\n{closing}\n- Required migration step.\n" + text = f"## [0.3.0] - 2026-09-09\n{body}\n## [0.2.0]\nOld\n" + with self.subTest(opening=opening): + self.assertEqual(notes.extract(text, "0.3.0", require_date=True), body) + self.assertEqual(notes.extract(text.replace("\n", "\r\n"), "0.3.0"), body) + with self.assertRaisesRegex(ValueError, "unclosed"): + notes.extract("## [0.3.0]\n```\n## Example\n", "0.3.0") + + def test_release_dates(self): + for suffix in ["", " - Unreleased", " - Unpublished draft", " - 2026-02-29", " - 2026-13-01", " - 2026-9-09"]: + text = f"## [0.3.0]{suffix}\n- Changes.\n" + with self.subTest(suffix=suffix): + self.assertEqual(notes.extract(text, "0.3.0"), "- Changes.\n") + with self.assertRaises(ValueError): + notes.extract(text, "0.3.0", require_date=True) + for version in ["0.3.0", "0.3.0-rc.1"]: + self.assertEqual(notes.extract(f"## [{version}] - 2024-02-29\n- Changes.\n", version, require_date=True), "- Changes.\n") + + def test_cli_release_mode(self): + script = str(Path(__file__).with_name("release-notes.py").resolve()) + with tempfile.TemporaryDirectory() as folder: + changelog = Path(folder) / "CHANGELOG.md" + for status, tag, succeeds in [ + ("Unreleased", None, True), + ("Unreleased", "v0.3.0", False), + ("2026-09-09", "v0.3.0", True), + ("2026-09-09", "v0.2.0", False), + ("2026-02-30", "v0.3.0", False), + ]: + changelog.write_text(f"## [0.3.0] - {status}\n- Changes.\n") + command = [sys.executable, script, "0.3.0", "--changelog", str(changelog)] + if tag: + command += ["--release-tag", tag] + result = subprocess.run(command, capture_output=True, text=True) + with self.subTest(status=status, tag=tag): + self.assertEqual(result.returncode == 0, succeeds, result.stderr) + self.assertEqual(result.stdout, "- Changes.\n" if succeeds else "") + + def test_missing_duplicate_empty_and_invalid_fail(self): + for text, version in [ + ("## [0.2.0]\nOld", "0.3.0"), + ("## [0.3.0]\nA\n## [0.3.0] - Today\nB", "0.3.0"), + ("## [0.3.0]\n\n### Fixed\n", "0.3.0"), + ("## [0.3.0]\nValid", "v0.3.0"), + ]: + with self.subTest(text=text, version=version), self.assertRaises(ValueError): + notes.extract(text, version) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cff632..fae8380 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 + - name: Validate release notes + run: | + python3 .github/scripts/test-release-notes.py + python3 .github/scripts/release-notes.py > /dev/null - name: Classify changed files id: classify shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d8f2e3..7a6ef6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,22 @@ env: TYSEL_TOOLCHAIN_IMAGE: ghcr.io/wangcch/tysel-toolchain jobs: + release-notes: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Validate changelog before building release artifacts + run: | + python3 .github/scripts/test-release-notes.py + args=() + if [[ "$GITHUB_REF_TYPE" == tag ]]; then + args+=(--release-tag "$GITHUB_REF_NAME") + fi + python3 .github/scripts/release-notes.py "${args[@]}" > /dev/null + policy: + needs: release-notes runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -100,6 +115,7 @@ jobs: if-no-files-found: error package-npm: + needs: release-notes runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -124,6 +140,7 @@ jobs: if-no-files-found: error package-rust-sdk: + needs: release-notes runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -139,6 +156,7 @@ jobs: if-no-files-found: error package-component-starters: + needs: release-notes runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -186,6 +204,7 @@ jobs: if-no-files-found: error build-linux: + needs: release-notes strategy: fail-fast: false matrix: @@ -260,6 +279,7 @@ jobs: if-no-files-found: error build-darwin: + needs: release-notes strategy: fail-fast: false matrix: @@ -543,13 +563,15 @@ jobs: version="$(bash .github/scripts/check-version-sync.sh)" channel="$(bash .github/scripts/release-channel.sh "$version")" bash .github/scripts/verify-release-assets.sh target/publish "$version" + python3 .github/scripts/release-notes.py "$version" --release-tag "$GITHUB_REF_NAME" > target/release-notes.md if gh release view "$GITHUB_REF_NAME" --json isDraft > target/release-state.json 2>/dev/null; then jq -e '.isDraft == true' target/release-state.json > /dev/null || { echo "release is already public and immutable" >&2 exit 1 } + gh release edit "$GITHUB_REF_NAME" --notes-file target/release-notes.md else - release_flags=(--draft --verify-tag --generate-notes) + release_flags=(--draft --verify-tag --notes-file target/release-notes.md) [[ "$channel" == stable ]] || release_flags+=(--prerelease) gh release create "$GITHUB_REF_NAME" "${release_flags[@]}" fi diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index c6d59ea..ee28a54 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -105,9 +105,18 @@ jobs: - name: Check benchmark evidence loader run: pnpm test:benchmarks working-directory: website + - name: Check localization contracts + run: pnpm test:i18n && pnpm i18n:check + working-directory: website - name: Build static website run: pnpm build working-directory: website + - name: Verify localized HTML and section links + run: python3 scripts/check-localized-html.py + working-directory: website + - name: Verify sitemap and multilingual SEO + run: python3 scripts/check-seo.py + working-directory: website - name: Verify generated documentation is committed run: | changes="$(git status --short --untracked-files=all -- website/content)" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f9bbef5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# Changelog + +User-visible changes are recorded here. Release notes reuse the matching version +section; detailed contracts and procedures remain in the linked documentation. +Use an exact version heading (including any prerelease suffix), and replace +`Unreleased` with the release date before tagging. Keep entries focused on user +impact rather than individual commits. PR checks allow `Unreleased`; tag releases +require a matching version and a valid date before artifact builds start. + +## [0.3.0] - 2026-09-09 + +### Upgrade notes + +- Durable stores migrate from log v1/v2 to v3 on open. Stop old writers and back + up the complete store first; rollback requires the pre-upgrade snapshot and + matching runtime. Legacy tasks without wakeup or execution records are not + automatically recovered. See the [upgrade procedure](https://tysel.dev/docs/operations/production#durable-log-v3-upgrade). +- External writes still need provider idempotency or reconciliation. Applications + own outbox delivery: admission retries require the original bundle, and completed + tasks must be retained beyond the retry horizon because pruning removes deduplication + records. See the [durable contract](https://tysel.dev/reference/runtime/durable). + +### Added and fixed + +- Recover active durable tasks after process death and preserve recovery across + hot reload, with execution fencing and retries for temporary completion-storage failures. +- Add stable idempotency keys for durable admission and signals, plus retained + results and cleanup through `tysel durable result` and `tysel durable prune`. +- Add Streams, `TextEncoder.encodeInto`, `TextEncoderStream`, body `bytes()` helpers, + `Response.error()` and `Response.redirect()`; fix HTTP body byte preservation, + URL path handling and malformed query decoding. +- Add guided project setup, automatic manifest-type synchronization during + development, and structured diagnostics for manifest and import errors. +- Include queue time and body consumption in HTTP deadlines, and drain connections + during shutdown. +- Add reviewed Chinese documentation and localized website navigation. +- Fix promise retention during stream backpressure. + +### Performance + +- Cache durable export metadata per source revision and wake the local scheduler + after signal commits, while retaining periodic polling for recovery and other instances. + +## [0.2.0] - 2026-09-04 + +- Added verified cross-target packaging for Linux and macOS on x64 and arm64, + with authenticated runtime downloads and offline reuse of verified cached + runtimes. Cross-target builds require a managed installation and trust policy. +- Published Linux toolchain images and hardened `tysel image` handling of + existing binaries, release sidecars and generated container metadata. +- Added `tysel test --filter` and `--list`, including machine-readable discovery + and exact test-ID selection. +- Added source-mapped development diagnostic events and improved installer + feedback and the development server's displayed URL. +- Upgraded QuickJS-NG to 0.16.2. + +[Release](https://github.com/wangcch/tysel/releases/tag/v0.2.0) · +[Changes since 0.1.1](https://github.com/wangcch/tysel/compare/v0.1.1...v0.2.0) + +## [0.1.1] - 2026-09-02 + +- Corrected TypeScript SDK publication to the scoped `@tysel/sdk` package. +- Made registry publication recoverable after partial release failures. + +[Release](https://github.com/wangcch/tysel/releases/tag/v0.1.1) · +[Changes since 0.1.0](https://github.com/wangcch/tysel/compare/v0.1.0...v0.1.1) + +## [0.1.0] - Unpublished draft + +The tag exists, but its GitHub Release remains a draft with no publication date. +This section records the tagged baseline, not a completed public release. + +- Introduced native packaging of TypeScript applications as a single executable, + with host-platform builds and a Web-API-first runtime. +- Provided HTTP services, cron and queue workers, MCP tools and explicit host + capability configuration. +- Added durable steps, effects, sleeps, retries and signal waits with persisted + boundary history. Active-execution crash recovery was not guaranteed; the + recovery and admission fixes are recorded under 0.3.0. +- Included experimental Rust and Go Wasm Component tasks with bounded execution. +- Provided project setup, development, testing, compatibility checks and signed + release verification tooling. + +[Tagged source](https://github.com/wangcch/tysel/tree/v0.1.0) diff --git a/Cargo.lock b/Cargo.lock index de6b945..651f3b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -434,6 +434,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -703,6 +715,16 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", +] + [[package]] name = "digest" version = "0.10.7" @@ -783,6 +805,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -805,7 +833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2931,7 +2959,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3028,7 +3056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3139,6 +3167,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -3331,7 +3365,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3639,7 +3673,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "tysel-bench-compare" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -3655,7 +3689,7 @@ dependencies = [ [[package]] name = "tysel-build" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "ed25519-dalek", @@ -3674,14 +3708,14 @@ dependencies = [ [[package]] name = "tysel-cap-fs" -version = "0.2.0" +version = "0.3.0" dependencies = [ "libc", ] [[package]] name = "tysel-cap-llm" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bytes", "http-body-util", @@ -3697,7 +3731,7 @@ dependencies = [ [[package]] name = "tysel-cap-mcp" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -3706,7 +3740,7 @@ dependencies = [ [[package]] name = "tysel-cap-postgres" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bytes", "futures-util", @@ -3720,7 +3754,7 @@ dependencies = [ [[package]] name = "tysel-cap-redis" -version = "0.2.0" +version = "0.3.0" dependencies = [ "redis", "tokio", @@ -3729,7 +3763,7 @@ dependencies = [ [[package]] name = "tysel-cap-sqlite" -version = "0.2.0" +version = "0.3.0" dependencies = [ "rusqlite", "serde_json", @@ -3738,7 +3772,7 @@ dependencies = [ [[package]] name = "tysel-capability" -version = "0.2.0" +version = "0.3.0" dependencies = [ "semver", "serde", @@ -3747,10 +3781,12 @@ dependencies = [ [[package]] name = "tysel-cli" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", + "dialoguer", + "json-strip-comments", "libc", "notify", "reqwest", @@ -3763,12 +3799,14 @@ dependencies = [ "tysel-cap-mcp", "tysel-capability", "tysel-distribution", + "tysel-durable", "tysel-engine", "tysel-engine-qjs", "tysel-manifest", "tysel-observability", "tysel-package", "tysel-runtime", + "tysel-task", "tysel-task-rpc", "tysel-testkit", "wat", @@ -3776,7 +3814,7 @@ dependencies = [ [[package]] name = "tysel-component-sdk" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -3784,7 +3822,7 @@ dependencies = [ [[package]] name = "tysel-distribution" -version = "0.2.0" +version = "0.3.0" dependencies = [ "semver", "serde", @@ -3794,8 +3832,9 @@ dependencies = [ [[package]] name = "tysel-durable" -version = "0.2.0" +version = "0.3.0" dependencies = [ + "getrandom 0.4.3", "native-tls", "postgres", "postgres-native-tls", @@ -3810,14 +3849,14 @@ dependencies = [ [[package]] name = "tysel-engine" -version = "0.2.0" +version = "0.3.0" dependencies = [ "thiserror", ] [[package]] name = "tysel-engine-qjs" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bytes", "futures-util", @@ -3851,7 +3890,7 @@ dependencies = [ [[package]] name = "tysel-engine-wasm" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde_json", "sha2 0.10.9", @@ -3866,7 +3905,7 @@ dependencies = [ [[package]] name = "tysel-ipc" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -3876,7 +3915,7 @@ dependencies = [ [[package]] name = "tysel-isolate" -version = "0.2.0" +version = "0.3.0" dependencies = [ "libc", "serde_json", @@ -3891,17 +3930,18 @@ dependencies = [ [[package]] name = "tysel-manifest" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", "thiserror", "toml", + "toml_edit", ] [[package]] name = "tysel-observability" -version = "0.2.0" +version = "0.3.0" dependencies = [ "opentelemetry", "opentelemetry-otlp", @@ -3913,7 +3953,7 @@ dependencies = [ [[package]] name = "tysel-package" -version = "0.2.0" +version = "0.3.0" dependencies = [ "semver", "serde", @@ -3924,14 +3964,14 @@ dependencies = [ [[package]] name = "tysel-policy" -version = "0.2.0" +version = "0.3.0" dependencies = [ "tysel-capability", ] [[package]] name = "tysel-release-signing" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "ed25519-dalek", @@ -3943,7 +3983,7 @@ dependencies = [ [[package]] name = "tysel-runtime" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bytes", "futures-util", @@ -3951,6 +3991,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "rusqlite", "serde_json", "thiserror", "tokio", @@ -3977,7 +4018,7 @@ dependencies = [ [[package]] name = "tysel-scheduler" -version = "0.2.0" +version = "0.3.0" dependencies = [ "thiserror", "tysel-task", @@ -3985,7 +4026,7 @@ dependencies = [ [[package]] name = "tysel-task" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde_json", "thiserror", @@ -3993,7 +4034,7 @@ dependencies = [ [[package]] name = "tysel-task-rpc" -version = "0.2.0" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -4003,7 +4044,7 @@ dependencies = [ [[package]] name = "tysel-testkit" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "bytes", @@ -4637,7 +4678,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c65b460..a90e2db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ members = [ ] [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "Apache-2.0" rust-version = "1.95.0" @@ -46,6 +46,8 @@ thiserror = "2" serde = { version = "1", features = ["derive"] } toml = "0.8" clap = { version = "4", features = ["derive"] } +dialoguer = { version = "0.12", default-features = false } +json-strip-comments = "3.1.2" tracing = "0.1" tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros", "net", "io-util", "process", "signal"] } # Candidate QuickJS-NG 0.16.2 adapter. Keep the immutable revision until the diff --git a/README.md b/README.md index c4197f9..6ec7cb3 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Get started · Examples · Documentation · + Changelog · Security

@@ -117,7 +118,7 @@ command. | Toolchain | Linux and macOS, x64 and arm64 | | Windows | WSL; no native Windows archive yet | | `service` profile | Trusted first-party application code | -| `isolated` profile | Separate worker process; Linux is the production security gate | +| `isolated` profile | Packaged apps also require matching `tysel-worker`; Linux is the production security gate | | `component` profile | Experimental Wasm Component tasks with restricted WASI | | Native runtime cross-compilation | Not provided; `build --target` instead packages with a verified same-version official runtime | diff --git a/benchmarks/comparison/runtimes-tysel-workers-2.json b/benchmarks/comparison/runtimes-tysel-workers-2.json index f6040d0..3aad250 100644 --- a/benchmarks/comparison/runtimes-tysel-workers-2.json +++ b/benchmarks/comparison/runtimes-tysel-workers-2.json @@ -8,7 +8,7 @@ }], "runtimes": [{ "id": "tysel", - "expectedVersion": "0.2.0", + "expectedVersion": "0.3.0", "executable": "target/linux-arm64-verify/release/tysel", "args": ["run", "--manifest", "benchmarks/comparison/adapters/tysel/tysel-workers-2.toml"], "readinessPrefix": "tysel listen ", diff --git a/benchmarks/comparison/runtimes-tysel-workers-4.json b/benchmarks/comparison/runtimes-tysel-workers-4.json index 0a315c9..a5a720d 100644 --- a/benchmarks/comparison/runtimes-tysel-workers-4.json +++ b/benchmarks/comparison/runtimes-tysel-workers-4.json @@ -8,7 +8,7 @@ }], "runtimes": [{ "id": "tysel", - "expectedVersion": "0.2.0", + "expectedVersion": "0.3.0", "executable": "target/linux-arm64-verify/release/tysel", "args": ["run", "--manifest", "benchmarks/comparison/adapters/tysel/tysel-workers-4.toml"], "readinessPrefix": "tysel listen ", diff --git a/benchmarks/comparison/runtimes.lock.json b/benchmarks/comparison/runtimes.lock.json index c34d5ec..a865209 100644 --- a/benchmarks/comparison/runtimes.lock.json +++ b/benchmarks/comparison/runtimes.lock.json @@ -19,7 +19,7 @@ "runtimes": [ { "id": "tysel", - "expectedVersion": "0.2.0", + "expectedVersion": "0.3.0", "executable": "target/release/tysel", "args": [ "run", diff --git a/brand/README.md b/brand/README.md index abb6a33..85a6b81 100644 --- a/brand/README.md +++ b/brand/README.md @@ -46,6 +46,11 @@ The secondary `ty` mark is a direct crop of the same outlined wordmark. It is re ## GitHub surfaces +[`github/tysel-org-avatar.png`](./github/tysel-org-avatar.png) is the upload-ready +1024×1024 organization profile picture. It keeps the original `ty` mark inside a +circle-safe area, using Tysel Blue on a solid Runtime Mist field. The editable +[`SVG source`](./github/tysel-org-avatar.svg) is included alongside it. + [`github/tysel-github-banner.png`](./github/tysel-github-banner.png) is the upload-ready 1280×640 social preview. Its editable [`SVG source`](./github/tysel-github-banner.svg) is included for future size diff --git a/brand/github/tysel-org-avatar.png b/brand/github/tysel-org-avatar.png new file mode 100644 index 0000000..d30419e Binary files /dev/null and b/brand/github/tysel-org-avatar.png differ diff --git a/brand/github/tysel-org-avatar.svg b/brand/github/tysel-org-avatar.svg new file mode 100644 index 0000000..326805b --- /dev/null +++ b/brand/github/tysel-org-avatar.svg @@ -0,0 +1,9 @@ + + Tysel organization avatar + The Tysel Blue ty mark centered on a Runtime Mist field. + + + + + + diff --git a/crates/tysel-build/src/bundle.rs b/crates/tysel-build/src/bundle.rs index 00b7ac1..a6b4717 100644 --- a/crates/tysel-build/src/bundle.rs +++ b/crates/tysel-build/src/bundle.rs @@ -116,7 +116,7 @@ pub fn bundle(entry: &Path) -> Result<(Vec, Vec)> { let mut resolved = HashMap::new(); for specifier in &analysis.specifiers { - let resolved_path = resolve_specifier(&resolver, &path, specifier)?; + let resolved_path = resolve_specifier(&resolver, &path, specifier, &source)?; resolved.insert(specifier.clone(), module_id(&resolved_path)); pending.push_back(resolved_path); } @@ -158,6 +158,7 @@ struct OrigPos { struct Analysis { has_module_syntax: bool, specifiers: Vec, + specifier_spans: HashMap, dynamic_imports: Vec, } @@ -174,13 +175,37 @@ fn analyze_source(path: &Path, source: &str) -> Result { ))); } let mut specifiers = Vec::new(); + let mut specifier_spans = HashMap::new(); let mut seen = HashSet::new(); + let mut type_only_statements = HashMap::new(); + for (start, is_type) in parsed + .module_record + .import_entries + .iter() + .map(|entry| (entry.statement_span.start, entry.is_type)) + .chain( + parsed + .module_record + .indirect_export_entries + .iter() + .map(|entry| (entry.statement_span.start, entry.is_type)), + ) + { + type_only_statements + .entry(start) + .and_modify(|all: &mut bool| *all &= is_type) + .or_insert(is_type); + } for (specifier, requests) in parsed.module_record.requested_modules.iter() { - if requests.iter().all(|request| request.is_type) { + let Some(request) = requests.iter().find(|request| { + !request.is_type + && type_only_statements.get(&request.statement_span.start) != Some(&true) + }) else { continue; - } + }; let specifier = specifier.as_str().to_string(); if seen.insert(specifier.clone()) { + specifier_spans.insert(specifier.clone(), request.span); specifiers.push(specifier); } } @@ -198,12 +223,14 @@ fn analyze_source(path: &Path, source: &str) -> Result { let Some(literal) = call.common_js_require() else { continue }; let specifier = literal.value.as_str().to_owned(); if seen.insert(specifier.clone()) { + specifier_spans.insert(specifier.clone(), literal.span); specifiers.push(specifier); } } Ok(Analysis { has_module_syntax: parsed.module_record.has_module_syntax, specifiers, + specifier_spans, dynamic_imports: parsed .module_record .dynamic_imports @@ -441,15 +468,45 @@ fn collect_binding(pattern: &BindingPattern<'_>, names: &mut Vec) { } } -fn resolve_specifier(resolver: &Resolver, importer: &Path, specifier: &str) -> Result { +fn resolve_specifier( + resolver: &Resolver, + importer: &Path, + specifier: &str, + source: &str, +) -> Result { let directory = importer.parent().unwrap_or(importer); match resolver.resolve(directory, specifier) { - Ok(resolution) => canonicalize_existing(&resolution.full_path()), - Err(ResolveError::Builtin { resolved, .. }) => { - Err(anyhow!("Node builtin '{resolved}' is not available in Tysel")) + Ok(resolution) if !specifier.starts_with("node:") => { + canonicalize_existing(&resolution.full_path()) } - Err(err) => { - Err(anyhow!("cannot resolve '{}' from {}: {err}", specifier, importer.display())) + result => { + let (code, message) = match result { + Err(ResolveError::Builtin { resolved, .. }) => ( + "TYSEL_NODE_BUILTIN_UNSUPPORTED", + format!("Node builtin '{resolved}' is not available in Tysel"), + ), + _ if specifier.starts_with("node:") => ( + "TYSEL_NODE_BUILTIN_UNSUPPORTED", + format!("Node builtin '{specifier}' is not available in Tysel"), + ), + Err(error) => ( + "TYSEL_IMPORT_UNRESOLVED", + format!("cannot resolve '{specifier}' from {}: {error}", importer.display()), + ), + Ok(_) => unreachable!(), + }; + // Analyze the original source only on failure: successful builds do + // not pay for a second parse just to retain diagnostic positions. + let original = analyze_source(importer, source)?; + let span = original.specifier_spans.get(specifier).copied(); + Err(anyhow!(crate::BuildDiagnostics::new(vec![crate::BuildDiagnostic::at_source( + code, + "resolve", + message, + importer, + source, + span.map(|span| span.start as usize..span.end as usize), + )]))) } } } diff --git a/crates/tysel-build/src/diagnostic.rs b/crates/tysel-build/src/diagnostic.rs index 01e6422..609d1d4 100644 --- a/crates/tysel-build/src/diagnostic.rs +++ b/crates/tysel-build/src/diagnostic.rs @@ -18,6 +18,35 @@ pub struct BuildDiagnostic { pub end: Option, } +impl BuildDiagnostic { + /// Create a diagnostic from an original-source byte range, if one exists. + pub fn at_source( + code: impl Into, + phase: impl Into, + message: impl Into, + path: &Path, + source: &str, + range: Option>, + ) -> Self { + let index = LineIndex::new(source); + let positions = range.map(|range| { + ( + index.position(source, range.start.min(source.len()) as u32), + index.position(source, range.end.min(source.len()) as u32), + ) + }); + Self { + code: code.into(), + message: message.into(), + severity: DiagnosticSeverity::Error, + phase: phase.into(), + file: path.to_string_lossy().into_owned(), + start: positions.map(|(start, _)| start), + end: positions.map(|(_, end)| end), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum DiagnosticSeverity { diff --git a/crates/tysel-build/src/lib.rs b/crates/tysel-build/src/lib.rs index 8336fbd..ba2a5c7 100644 --- a/crates/tysel-build/src/lib.rs +++ b/crates/tysel-build/src/lib.rs @@ -693,6 +693,48 @@ export default { assert!(!js.contains("module.exports = {")); } + #[test] + fn nested_import_errors_use_original_typescript_ranges() { + let dir = std::env::temp_dir() + .join(format!("tysel-nested-import-diagnostic-{}", std::process::id())); + fs::create_dir_all(dir.join("src")).unwrap(); + let entry = dir.join("src/index.ts"); + fs::write(&entry, "export { default } from './nested.js';\n").unwrap(); + let nested = dir.join("src/nested.ts"); + for specifier in ["node:fs", "node:future_builtin", "./missing.js"] { + // Type erasure changes the generated line/column before this import. + let source = format!( + "type Name = string;\nconst name: Name = '王😀'; import '{specifier}';\nexport default name;\n" + ); + fs::write(&nested, &source).unwrap(); + let error = read_bundle(&entry).unwrap_err(); + let diagnostics = error.downcast_ref::().unwrap(); + let diagnostic = &diagnostics.diagnostics()[0]; + assert_eq!( + diagnostic.code, + if specifier.starts_with("node:") { + "TYSEL_NODE_BUILTIN_UNSUPPORTED" + } else { + "TYSEL_IMPORT_UNRESOLVED" + } + ); + assert_eq!(Path::new(&diagnostic.file), fs::canonicalize(&nested).unwrap()); + let start = diagnostic.start.unwrap(); + let end = diagnostic.end.unwrap(); + assert_eq!(start.line, 2); + let selected = &source[start.byte_offset as usize..end.byte_offset as usize]; + assert!(selected.contains(specifier), "{selected:?}"); + let line_start = source.find('\n').unwrap() + 1; + assert_eq!( + start.column as usize, + source[line_start..start.byte_offset as usize].chars().count() + 1 + ); + } + fs::write(&nested, "import type { Stats } from 'node:fs';\nimport { type Missing } from './not-installed.js';\nexport default {};\n").unwrap(); + assert!(read_bundle(&entry).is_ok(), "erased imports must not be resolved"); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn node_builtins_are_rejected() { let dir = std::env::temp_dir().join(format!("tysel-build-fs-{}", std::process::id())); diff --git a/crates/tysel-build/src/runtime-components.json b/crates/tysel-build/src/runtime-components.json index 3f2d027..624e49b 100644 --- a/crates/tysel-build/src/runtime-components.json +++ b/crates/tysel-build/src/runtime-components.json @@ -1,6 +1,6 @@ { "inventory_version": 1, - "cargo_lock_sha256": "1f7d8b76e2f5e4330473ff013afae3520afafec95f30ec42185bcf76fe6a99dd", + "cargo_lock_sha256": "001add1e9594e03c8b8d168ca98df5d5eaf323b5c8be6f012accfecb9b382bde", "roots": [ "tysel-cli", "tysel-isolate", @@ -391,6 +391,14 @@ "source": "registry+https://github.com/rust-lang/crates.io-index", "checksum": "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" }, + { + "name": "console", + "version": "0.16.4", + "license": "MIT", + "purl": "pkg:cargo/console@0.16.4", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "checksum": "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" + }, { "name": "const-oid", "version": "0.10.2", @@ -615,6 +623,14 @@ "source": "registry+https://github.com/rust-lang/crates.io-index", "checksum": "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" }, + { + "name": "dialoguer", + "version": "0.12.0", + "license": "MIT", + "purl": "pkg:cargo/dialoguer@0.12.0", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "checksum": "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" + }, { "name": "digest", "version": "0.10.7", @@ -687,6 +703,14 @@ "source": "registry+https://github.com/rust-lang/crates.io-index", "checksum": "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" }, + { + "name": "encode_unicode", + "version": "1.0.0", + "license": "Apache-2.0 OR MIT", + "purl": "pkg:cargo/encode_unicode@1.0.0", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "checksum": "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + }, { "name": "encoding_rs", "version": "0.8.35", @@ -2460,6 +2484,14 @@ "source": "registry+https://github.com/rust-lang/crates.io-index", "checksum": "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" }, + { + "name": "shell-words", + "version": "1.1.1", + "license": "MIT OR Apache-2.0", + "purl": "pkg:cargo/shell-words@1.1.1", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "checksum": "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + }, { "name": "shlex", "version": "2.0.1", @@ -2862,184 +2894,184 @@ }, { "name": "tysel-build", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-build@0.2.0", + "purl": "pkg:cargo/tysel-build@0.3.0", "source": "workspace" }, { "name": "tysel-cap-fs", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-cap-fs@0.2.0", + "purl": "pkg:cargo/tysel-cap-fs@0.3.0", "source": "workspace" }, { "name": "tysel-cap-llm", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-cap-llm@0.2.0", + "purl": "pkg:cargo/tysel-cap-llm@0.3.0", "source": "workspace" }, { "name": "tysel-cap-mcp", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-cap-mcp@0.2.0", + "purl": "pkg:cargo/tysel-cap-mcp@0.3.0", "source": "workspace" }, { "name": "tysel-cap-postgres", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-cap-postgres@0.2.0", + "purl": "pkg:cargo/tysel-cap-postgres@0.3.0", "source": "workspace" }, { "name": "tysel-cap-redis", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-cap-redis@0.2.0", + "purl": "pkg:cargo/tysel-cap-redis@0.3.0", "source": "workspace" }, { "name": "tysel-cap-sqlite", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-cap-sqlite@0.2.0", + "purl": "pkg:cargo/tysel-cap-sqlite@0.3.0", "source": "workspace" }, { "name": "tysel-capability", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-capability@0.2.0", + "purl": "pkg:cargo/tysel-capability@0.3.0", "source": "workspace" }, { "name": "tysel-cli", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-cli@0.2.0", + "purl": "pkg:cargo/tysel-cli@0.3.0", "source": "workspace" }, { "name": "tysel-distribution", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-distribution@0.2.0", + "purl": "pkg:cargo/tysel-distribution@0.3.0", "source": "workspace" }, { "name": "tysel-durable", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-durable@0.2.0", + "purl": "pkg:cargo/tysel-durable@0.3.0", "source": "workspace" }, { "name": "tysel-engine-qjs", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-engine-qjs@0.2.0", + "purl": "pkg:cargo/tysel-engine-qjs@0.3.0", "source": "workspace" }, { "name": "tysel-engine-wasm", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-engine-wasm@0.2.0", + "purl": "pkg:cargo/tysel-engine-wasm@0.3.0", "source": "workspace" }, { "name": "tysel-engine", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-engine@0.2.0", + "purl": "pkg:cargo/tysel-engine@0.3.0", "source": "workspace" }, { "name": "tysel-ipc", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-ipc@0.2.0", + "purl": "pkg:cargo/tysel-ipc@0.3.0", "source": "workspace" }, { "name": "tysel-isolate", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-isolate@0.2.0", + "purl": "pkg:cargo/tysel-isolate@0.3.0", "source": "workspace" }, { "name": "tysel-manifest", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-manifest@0.2.0", + "purl": "pkg:cargo/tysel-manifest@0.3.0", "source": "workspace" }, { "name": "tysel-observability", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-observability@0.2.0", + "purl": "pkg:cargo/tysel-observability@0.3.0", "source": "workspace" }, { "name": "tysel-package", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-package@0.2.0", + "purl": "pkg:cargo/tysel-package@0.3.0", "source": "workspace" }, { "name": "tysel-policy", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-policy@0.2.0", + "purl": "pkg:cargo/tysel-policy@0.3.0", "source": "workspace" }, { "name": "tysel-release-signing", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-release-signing@0.2.0", + "purl": "pkg:cargo/tysel-release-signing@0.3.0", "source": "workspace" }, { "name": "tysel-runtime", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-runtime@0.2.0", + "purl": "pkg:cargo/tysel-runtime@0.3.0", "source": "workspace" }, { "name": "tysel-scheduler", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-scheduler@0.2.0", + "purl": "pkg:cargo/tysel-scheduler@0.3.0", "source": "workspace" }, { "name": "tysel-task-rpc", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-task-rpc@0.2.0", + "purl": "pkg:cargo/tysel-task-rpc@0.3.0", "source": "workspace" }, { "name": "tysel-task", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-task@0.2.0", + "purl": "pkg:cargo/tysel-task@0.3.0", "source": "workspace" }, { "name": "tysel-testkit", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", - "purl": "pkg:cargo/tysel-testkit@0.2.0", + "purl": "pkg:cargo/tysel-testkit@0.3.0", "source": "workspace" }, { diff --git a/crates/tysel-cli/Cargo.toml b/crates/tysel-cli/Cargo.toml index 3b22c20..ebabec5 100644 --- a/crates/tysel-cli/Cargo.toml +++ b/crates/tysel-cli/Cargo.toml @@ -18,6 +18,8 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true clap.workspace = true +dialoguer.workspace = true +json-strip-comments.workspace = true tokio.workspace = true tysel-distribution.workspace = true tysel-manifest.workspace = true @@ -29,6 +31,7 @@ tysel-observability.workspace = true tysel-capability.workspace = true tysel-cap-mcp.workspace = true tysel-task-rpc.workspace = true +tysel-task.workspace = true tysel-package.workspace = true tysel-testkit = { path = "../tysel-testkit" } notify = "6" @@ -40,4 +43,5 @@ libc.workspace = true reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] } [dev-dependencies] +tysel-durable = { path = "../tysel-durable" } wat = "1" diff --git a/crates/tysel-cli/src/build.rs b/crates/tysel-cli/src/build.rs index f100b21..a4c9825 100644 --- a/crates/tysel-cli/src/build.rs +++ b/crates/tysel-cli/src/build.rs @@ -98,6 +98,11 @@ pub fn run(manifest_path: PathBuf, options: Options) -> Result<()> { println!("{payload_label:<16} {}", format_bytes(payload_bytes as u64)); println!("Capabilities {}", capability_summary(&manifest)); println!("Runtime {}", manifest.app.profile); + if manifest.app.profile == "isolated" { + println!( + "Worker matching tysel-worker required beside executable (or TYSEL_WORKER)" + ); + } println!("Executable {}", format_bytes(executable)); println!("Target {}", target.canonical()); println!("Output {}", output.display()); diff --git a/crates/tysel-cli/src/check.rs b/crates/tysel-cli/src/check.rs index 0245df7..e45f160 100644 --- a/crates/tysel-cli/src/check.rs +++ b/crates/tysel-cli/src/check.rs @@ -5,6 +5,14 @@ use anyhow::{Context, Result, anyhow}; use tysel_manifest::Manifest; pub fn run(manifest_path: &Path) -> Result<()> { + run_with_options(manifest_path, false) +} + +pub(crate) fn run_requiring_types(manifest_path: &Path) -> Result<()> { + run_with_options(manifest_path, true) +} + +fn run_with_options(manifest_path: &Path, require_types: bool) -> Result<()> { let manifest = Manifest::from_path(manifest_path) .with_context(|| format!("failed to read {}", manifest_path.display()))?; let root = manifest_path.parent().unwrap_or(Path::new(".")); @@ -33,21 +41,20 @@ pub fn run(manifest_path: &Path) -> Result<()> { Typecheck::Skipped(reason) => println!(" types skipped ({reason})"), Typecheck::Failed(_) => println!(" types fail"), } - if let Typecheck::Failed(output) = types { - eprint!("{output}"); - return Err(anyhow!("TypeScript check failed")); + match types { + Typecheck::Failed(output) => { + eprint!("{output}"); + return Err(anyhow!("TypeScript check failed")); + } + Typecheck::Skipped(reason) if require_types => { + return Err(anyhow!("TypeScript check was skipped: {reason}")); + } + Typecheck::Ok | Typecheck::Skipped(_) => {} } if !is_component { - let specifiers = crate::node_scan::scan_file(&entry)?; - if specifiers.is_empty() { - println!(" node ok"); - } else { - println!(" node fail"); - for specifier in &specifiers { - eprintln!("unsupported Node builtin '{specifier}'"); - } - return Err(anyhow!("Node builtins are not available in Tysel")); - } + // The build resolver validates runtime imports across the entire graph. + // Re-scanning raw TS here would incorrectly reject erased type imports. + println!(" node ok"); } Ok(()) } @@ -99,3 +106,35 @@ fn find_tsc(start: &Path) -> Option { } None } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn strict_check_rejects_a_skipped_typecheck() { + let root = std::env::temp_dir().join(format!( + "tysel-strict-check-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("tysel.toml"), + "[app]\nname = \"strict-check\"\nentry = \"src/index.ts\"\n", + ) + .unwrap(); + fs::write( + root.join("src/index.ts"), + "export default { fetch() { return new Response('ok'); } };\n", + ) + .unwrap(); + fs::write(root.join("tsconfig.json"), "{}\n").unwrap(); + + let error = run_requiring_types(&root.join("tysel.toml")).unwrap_err(); + assert!(error.to_string().contains("TypeScript check was skipped")); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/tysel-cli/src/dev.rs b/crates/tysel-cli/src/dev.rs index b2b1313..457dc9d 100644 --- a/crates/tysel-cli/src/dev.rs +++ b/crates/tysel-cli/src/dev.rs @@ -18,8 +18,8 @@ use tysel_package::SourceMap; #[cfg(unix)] use tysel_runtime::ModuleTaskService; use tysel_runtime::{ - AppIsolate, DurablePlane, HttpLimits, RuntimeDiagnostic, SharedPool, handle_stream, - spawn_app_isolate, + AppIsolate, DurablePlane, HttpLimits, HttpShutdown, RuntimeDiagnostic, SharedPool, + handle_stream, spawn_app_isolate, }; use tysel_task_rpc::TaskOutcome; @@ -214,17 +214,23 @@ async fn serve( reload: bool, error_format: ErrorFormat, ) -> Result<()> { - let loaded = load(&manifest_path, entry.as_deref())?; - let pool = SharedPool::with_server_limits( + let loaded = load_for_serve(&manifest_path, entry.as_deref(), reload)?; + let shutdown = HttpShutdown::default(); + let mut grace = loaded.isolate.request_timeout().saturating_add(Duration::from_secs(1)); + let pool = SharedPool::with_http_limits( loaded.isolate, - loaded.max_request_bytes, - loaded.max_in_flight, + HttpLimits { + max_request_bytes: loaded.max_request_bytes, + max_response_bytes: loaded.max_response_bytes, + max_in_flight: loaded.max_in_flight, + }, loaded.websocket, loaded.http1, loaded.http2, - Some(loaded.source_map), - ); - if error_format == ErrorFormat::Json { + reload.then_some(loaded.source_map), + ) + .with_shutdown(shutdown.clone()); + if reload && error_format == ErrorFormat::Json { attach_runtime_diagnostics(&pool)?; } let listener = @@ -238,21 +244,22 @@ async fn serve( } print!("{}", listen_announcement(bound)); io::stdout().flush()?; - if reload { + let result = if reload { report_diagnostics_clear(error_format, 0); let mut changes = watch(manifest_path.parent().unwrap_or(Path::new(".")))?; let mut diagnostic_generation = 0u64; loop { tokio::select! { - _ = tokio::signal::ctrl_c() => break, + signal = shutdown_signal() => break signal.map_err(Into::into), + error = durable_service_failure(durable.as_ref()) => { + break Err(error); + } error = task_service_failure(task_service.as_ref()) => { - shutdown_durable(durable.take()).await?; - shutdown_task_service(task_service).await?; - return Err(error); + break Err(error); } _ = wait_change(&mut changes.rx) => { diagnostic_generation = diagnostic_generation.saturating_add(1); - match load(&manifest_path, entry.as_deref()) { + match load_for_serve(&manifest_path, entry.as_deref(), true) { Ok(next) => { task_generation = task_generation.saturating_add(1); match start_task_service(next.task.clone(), task_generation).await { @@ -263,6 +270,7 @@ async fn serve( if error_format == ErrorFormat::Human { eprintln!("tysel reload"); } + grace = grace.max(next.isolate.request_timeout().saturating_add(Duration::from_secs(1))); pool.replace_with_server_limits( next.isolate, HttpLimits { @@ -289,32 +297,53 @@ async fn serve( Err(err) => report_dev_error(error_format, diagnostic_generation, &err), }}, accepted = listener.accept() => { - let (stream, _) = accepted.context("accept")?; + let (stream, _) = match accepted { Ok(value) => value, Err(error) => break Err(error.into()) }; handle_stream(stream, pool.clone()); } } } - shutdown_durable(durable.take()).await?; - shutdown_task_service(task_service).await?; - return Ok(()); - } - loop { - tokio::select! { - _ = tokio::signal::ctrl_c() => break, - error = task_service_failure(task_service.as_ref()) => { - shutdown_durable(durable.take()).await?; - shutdown_task_service(task_service).await?; - return Err(error); - } - accepted = listener.accept() => { - let (stream, _) = accepted.context("accept")?; - handle_stream(stream, pool.clone()); + } else { + loop { + tokio::select! { + signal = shutdown_signal() => break signal.map_err(Into::into), + error = durable_service_failure(durable.as_ref()) => { + break Err(error); + } + error = task_service_failure(task_service.as_ref()) => { + break Err(error); + } + accepted = listener.accept() => { + let (stream, _) = match accepted { Ok(value) => value, Err(error) => break Err(error.into()) }; + handle_stream(stream, pool.clone()); + } } } + }; + drop(listener); + shutdown.cancel(); + if let Some(plane) = &durable { + plane.stop_claiming(); } - shutdown_durable(durable.take()).await?; - shutdown_task_service(task_service).await?; - Ok(()) + shutdown.drain(grace).await; + let durable_result = shutdown_durable(durable.take()).await; + let task_result = shutdown_task_service(task_service).await; + result?; + durable_result?; + task_result +} + +#[cfg(unix)] +async fn shutdown_signal() -> io::Result<()> { + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + tokio::select! { + result = tokio::signal::ctrl_c() => result, + _ = terminate.recv() => Ok(()), + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() -> io::Result<()> { + tokio::signal::ctrl_c().await } fn attach_runtime_diagnostics(pool: &SharedPool) -> Result<()> { @@ -456,9 +485,27 @@ fn listen_announcement(bound: std::net::SocketAddr) -> String { format!("tysel listen {bound}\ntysel url http://{bound}\n") } +fn load_for_serve(manifest_path: &Path, entry: Option<&Path>, sync_types: bool) -> Result { + let manifest = Manifest::from_path(manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))?; + if sync_types { + crate::typegen::sync( + manifest_path.parent().unwrap_or(Path::new(".")), + &manifest, + None, + false, + )?; + } + load_manifest(manifest_path, entry, manifest) +} + fn load(manifest_path: &Path, entry: Option<&Path>) -> Result { let manifest = Manifest::from_path(manifest_path) .with_context(|| format!("failed to read {}", manifest_path.display()))?; + load_manifest(manifest_path, entry, manifest) +} + +fn load_manifest(manifest_path: &Path, entry: Option<&Path>, manifest: Manifest) -> Result { let root = manifest_path.parent().unwrap_or(Path::new(".")); let entry = match entry { Some(path) => path.to_path_buf(), @@ -608,28 +655,42 @@ async fn shutdown_task_service(_service: Option<()>) -> Result<()> { } async fn start_dev_durable(spec: Option) -> Result>> { - let Some(spec) = spec else { - return Ok(None); - }; - if spec.execution_profile.eq_ignore_ascii_case("isolated") { - return Ok(None); - } - if !DurablePlane::requested(&spec.sqlite_path, Some(&spec.root), &spec.source, spec.config)? { - return Ok(None); - } - let Some(store) = DurablePlane::open_store(&spec.sqlite_path, Some(&spec.root))? else { - return Ok(None); - }; - if !DurablePlane::should_start(store.as_ref(), &spec.source, spec.config)? { - return Ok(None); + tokio::task::spawn_blocking(move || { + let Some(spec) = spec else { + return Ok(None); + }; + if spec.execution_profile.eq_ignore_ascii_case("isolated") { + return Ok(None); + } + if !DurablePlane::requested(&spec.sqlite_path, Some(&spec.root), &spec.source, spec.config)? + { + return Ok(None); + } + let Some(store) = DurablePlane::open_store(&spec.sqlite_path, Some(&spec.root))? else { + return Ok(None); + }; + if !DurablePlane::should_start(store.as_ref(), &spec.source, spec.config)? { + return Ok(None); + } + let owner = format!("tysel-dev-{}", std::process::id()); + Ok(Some(DurablePlane::start(store, spec.source, spec.config, owner)?)) + }) + .await? +} + +async fn durable_service_failure(plane: Option<&Arc>) -> anyhow::Error { + match plane { + Some(plane) => plane.failed().await.into(), + None => std::future::pending().await, } - let owner = format!("tysel-dev-{}", std::process::id()); - Ok(Some(DurablePlane::start(store, spec.source, spec.config, owner)?)) } async fn shutdown_durable(plane: Option>) -> Result<()> { if let Some(plane) = plane { - plane.shutdown().await?; + let result = plane.shutdown().await; + // The synchronous PostgreSQL client also drives its runtime on drop. + tokio::task::spawn_blocking(move || drop(plane)).await?; + result?; } Ok(()) } @@ -701,6 +762,9 @@ fn relevant(event: notify::Result) -> bool { } fn is_watched(path: &Path) -> bool { + if path.file_name().and_then(|name| name.to_str()) == Some("tysel-env.d.ts") { + return false; + } if ignored(path) { return false; } @@ -743,6 +807,7 @@ mod tests { fn watches_dotenv_and_source_but_not_ignored_paths() { assert!(is_watched(Path::new("/app/.env"))); assert!(is_watched(Path::new("/app/src/index.ts"))); + assert!(!is_watched(Path::new("/app/tysel-env.d.ts"))); assert!(is_watched(Path::new("/app/tysel.toml"))); assert!(!is_watched(Path::new("/app/README.md"))); assert!(!is_watched(Path::new("/app/node_modules/pkg/.env"))); diff --git a/crates/tysel-cli/src/durable_admin.rs b/crates/tysel-cli/src/durable_admin.rs new file mode 100644 index 0000000..5a99ec6 --- /dev/null +++ b/crates/tysel-cli/src/durable_admin.rs @@ -0,0 +1,54 @@ +use anyhow::{Context, Result, bail}; +use clap::Subcommand; +use serde_json::json; +use tysel_runtime::DurablePlane; +use tysel_task::TaskId; + +#[derive(Subcommand)] +pub enum DurableCommand { + /// Read a retained completion without executing the application. + Result { task_id: String }, + /// Delete a bounded batch of explicitly completed tasks and their retained history. + Prune { + /// Retain at least this many seconds of completed history. + #[arg(long, default_value_t = 604_800)] + older_than_secs: u64, + /// Maximum tasks in one batch (1..=100); active tasks are never removed. + #[arg(long, default_value_t = 32, value_parser = clap::value_parser!(u32).range(1..=100))] + limit: u32, + }, +} + +pub fn run(command: DurableCommand, project: &crate::project::ProjectContext) -> Result<()> { + let path = if project.manifest.durable.store == "sqlite" { + &project.manifest.durable.path + } else { + "" + }; + if let Some(path) = DurablePlane::event_log_path(path, Some(&project.root)) + && !path.is_file() + { + bail!("durable event store does not exist"); + } + let store = DurablePlane::open_store(path, Some(&project.root))? + .context("durable event store is not configured")?; + let output = match command { + DurableCommand::Result { task_id } => { + let id = TaskId(u128::from_str_radix(&task_id, 16).context("invalid durable task ID")?); + let completion = store.completion(id)?; + json!({"taskId": id.to_string(), "completion": completion.map(|value| json!({ + "status": "completed", "value": value.value, "completedAtMs": value.completed_at_ms, + }))}) + } + DurableCommand::Prune { older_than_secs, limit } => { + let now_ms = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_millis(); + let retention_ms = u128::from(older_than_secs) * 1_000; + let before_ms = u64::try_from(now_ms.saturating_sub(retention_ms))?; + let deleted = store.prune_completed(before_ms, limit as usize)?; + json!({"deleted": deleted, "beforeMs": before_ms, "limit": limit}) + } + }; + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} diff --git a/crates/tysel-cli/src/init.rs b/crates/tysel-cli/src/init.rs index 3a31ec4..19af678 100644 --- a/crates/tysel-cli/src/init.rs +++ b/crates/tysel-cli/src/init.rs @@ -1,5 +1,6 @@ use std::io::IsTerminal; use std::path::{Path, PathBuf}; +use std::process::Command; use anyhow::{Context, Result}; use tysel_manifest::ManifestFormat; @@ -10,7 +11,7 @@ mod prompt; mod templates; mod transaction; -use prompt::{configure, prompt_yes_no}; +use prompt::{configure, configure_tty, confirm_tty}; use transaction::ProjectTransaction; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -29,15 +30,86 @@ pub enum Template { Minimal, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PackageManager { + Npm, + Pnpm, + Yarn, + Bun, +} + +impl PackageManager { + fn detect(root: &Path) -> Self { + let mut cursor = if root.is_dir() { Some(root) } else { root.parent() }; + while let Some(directory) = cursor { + for (lockfile, manager) in [ + ("pnpm-lock.yaml", Self::Pnpm), + ("bun.lock", Self::Bun), + ("bun.lockb", Self::Bun), + ("yarn.lock", Self::Yarn), + ("package-lock.json", Self::Npm), + ] { + if directory.join(lockfile).is_file() { + return manager; + } + } + cursor = directory.parent(); + } + Self::Npm + } + + fn command(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::Pnpm => "pnpm", + Self::Yarn => "yarn", + Self::Bun => "bun", + } + } + + fn is_available(self) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path) + .any(|directory| command_is_executable(&directory, self.command())) + } +} + +#[cfg(unix)] +fn command_is_executable(directory: &Path, command: &str) -> bool { + use std::os::unix::fs::PermissionsExt; + + std::fs::metadata(directory.join(command)) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +#[cfg(windows)] +fn command_is_executable(directory: &Path, command: &str) -> bool { + ["exe", "cmd", "bat", "com"] + .iter() + .any(|extension| directory.join(command).with_extension(extension).is_file()) +} + +#[cfg(not(any(unix, windows)))] +fn command_is_executable(directory: &Path, command: &str) -> bool { + directory.join(command).is_file() +} + pub struct Request { - pub root: PathBuf, + pub root: Option, pub template: Option