diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b014f844..a659b7c5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,9 @@ jobs: - name: Run codegen run: ./scripts/codegen.sh + - name: Type-check headless host scripts + run: node scripts/check-host-script-types.mjs + - name: Check Rust/TS wire table parity run: TRUAPI_REQUIRE_GENERATED_TS=1 cargo test -p truapi-server --test wire_table_ts_parity @@ -155,6 +158,7 @@ jobs: js/packages/truapi/src/explorer/versions.ts js/packages/truapi-host/src/generated playground/test/generated + rust/crates/truapi-host-cli/js/script-types.d.ts rust/crates/truapi-server/src/generated rust/crates/truapi-server/src/wasm diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 9cfd86085..541701a2c 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -78,8 +78,13 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: runner-bundle - path: target/dist/runner.js + name: cli-build-inputs + path: | + target/dist/runner.js + target/dist/script-types.d.ts + rust/crates/truapi-host-cli/js/script-types.d.ts + rust/crates/truapi-server/src/generated + rust/crates/truapi-server/src/wasm/generated_bridge.rs if-no-files-found: error build: @@ -117,12 +122,10 @@ jobs: if: endsWith(matrix.target, '-musl') run: sudo apt-get update && sudo apt-get install --no-install-recommends -y musl-tools - # `make cli-dist` treats target/dist/runner.js as a file target, so - # dropping it here means the archive reuses it instead of rebuilding. + # Each target needs the generated Rust sources as well as the runner artifacts. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: runner-bundle - path: target/dist + name: cli-build-inputs - name: Verify the crate version matches the release env: @@ -152,9 +155,11 @@ jobs: reported="$(./target/${{ matrix.target }}/release/truapi-host --version)" [ "${reported}" = "truapi-host ${VERSION}" ] \ || { echo "::error::binary reports '${reported}'"; exit 1; } - tar -tzf "target/dist/truapi-host-${VERSION}-${{ matrix.target }}.tar.gz" \ - | grep -qx runner.js \ - || { echo "::error::archive is missing the product-script runner"; exit 1; } + contents="$(tar -tzf "target/dist/truapi-host-${VERSION}-${{ matrix.target }}.tar.gz")" + for required in runner.js script-types.d.ts; do + grep -qx "${required}" <<< "${contents}" \ + || { echo "::error::archive is missing ${required}"; exit 1; } + done - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.gitignore b/.gitignore index 158fd9382..481190219 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,7 @@ rust/crates/truapi-server/pkg/ # truapi-codegen Rust outputs rust/crates/truapi-server/src/generated/ rust/crates/truapi-server/src/wasm/generated_bridge.rs +rust/crates/truapi-host-cli/js/script-types.d.ts js/packages/truapi/src/generated/ js/packages/truapi/dist/generated/ js/packages/truapi-host/src/generated/ diff --git a/.prettierignore b/.prettierignore index 57b549185..b6793aecc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,5 +9,6 @@ playground/next-env.d.ts playground/tsconfig.tsbuildinfo js/packages/truapi/dist/ js/packages/truapi/node_modules/ +rust/crates/truapi-host-cli/js/script-types.d.ts REVIEW_TODO*.md hosts/android/ diff --git a/Makefile b/Makefile index 9d8acc2d5..6801762ba 100644 --- a/Makefile +++ b/Makefile @@ -97,6 +97,8 @@ CLI_TARGET ?= $(shell rustc -vV | sed -n 's/^host: //p' | sed 's/-linux-gnu$$/-l CLI_VERSION ?= $(shell awk -F'"' '/^version = /{print $$2; exit}' rust/crates/truapi-host-cli/Cargo.toml) CLI_ARCHIVE := truapi-host-$(CLI_VERSION)-$(CLI_TARGET).tar.gz CLI_RUNNER := $(CLI_DIST_DIR)/runner.js +CLI_SCRIPT_TYPES_SOURCE := rust/crates/truapi-host-cli/js/script-types.d.ts +CLI_SCRIPT_TYPES := $(CLI_DIST_DIR)/script-types.d.ts CLI_STAGE := $(CLI_DIST_DIR)/$(CLI_TARGET) # macOS ships shasum, most Linux images ship only sha256sum. SHA256 := $(shell command -v sha256sum >/dev/null 2>&1 && echo "sha256sum" || echo "shasum -a 256") @@ -110,15 +112,20 @@ $(CLI_RUNNER): mkdir -p $(CLI_DIST_DIR) bun build rust/crates/truapi-host-cli/js/runner.ts --target=bun --outfile $@ -cli-runner: $(CLI_RUNNER) ## Bundle the self-contained product-script runner into target/dist. +$(CLI_SCRIPT_TYPES): $(CLI_SCRIPT_TYPES_SOURCE) + mkdir -p $(CLI_DIST_DIR) + cp $< $@ + +cli-runner: $(CLI_RUNNER) $(CLI_SCRIPT_TYPES) ## Bundle the product-script runner and its self-contained types into target/dist. + node scripts/check-host-script-types.mjs -cli-dist: check-generated $(CLI_RUNNER) ## Package truapi-host for CLI_TARGET into target/dist in the release artifact layout. +cli-dist: check-generated $(CLI_RUNNER) $(CLI_SCRIPT_TYPES) ## Package truapi-host for CLI_TARGET into target/dist in the release artifact layout. rustup target add $(CLI_TARGET) $(CARGO) build -p truapi-host-cli --release --target $(CLI_TARGET) rm -rf $(CLI_STAGE) mkdir -p $(CLI_STAGE) - cp target/$(CLI_TARGET)/release/truapi-host $(CLI_RUNNER) $(CLI_STAGE)/ - tar -czf $(CLI_DIST_DIR)/$(CLI_ARCHIVE) -C $(CLI_STAGE) truapi-host runner.js + cp target/$(CLI_TARGET)/release/truapi-host $(CLI_RUNNER) $(CLI_SCRIPT_TYPES) $(CLI_STAGE)/ + tar -czf $(CLI_DIST_DIR)/$(CLI_ARCHIVE) -C $(CLI_STAGE) truapi-host runner.js script-types.d.ts cd $(CLI_DIST_DIR) && $(SHA256) $(CLI_ARCHIVE) > $(CLI_ARCHIVE).sha256 @echo "packaged $(CLI_DIST_DIR)/$(CLI_ARCHIVE)" diff --git a/README.md b/README.md index a6a340bc6..c986f1cd5 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The interactive playground lets you browse every method, edit request payloads, curl -fsSL https://raw.githubusercontent.com/paritytech/host-rust-core/main/scripts/truapi-host-installer.sh | bash ``` -Prebuilt for macOS on Apple silicon and Linux on x86_64 and arm64. No Rust toolchain or checkout needed, and it keeps itself up to date. See the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for the commands, the terminal UI, and product scripts. +Prebuilt for macOS on Apple silicon and Linux on x86_64 and arm64. No Rust toolchain or checkout needed, and it keeps itself up to date. Scratch scripts created by `/script` include generated editor types for the injected `truapi`, `host`, and `assert` globals without a local npm package. See the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for the commands, the terminal UI, and product scripts. ## Usage diff --git a/js/packages/truapi/scripts/ensure-generated.sh b/js/packages/truapi/scripts/ensure-generated.sh index 32e7218e5..2a33a29be 100755 --- a/js/packages/truapi/scripts/ensure-generated.sh +++ b/js/packages/truapi/scripts/ensure-generated.sh @@ -19,6 +19,7 @@ codegen_required=( "rust/crates/truapi-server/src/wasm/generated_bridge.rs" ) truapi_dts="js/packages/truapi/src/playground/codegen/truapi-dts.ts" +host_script_dts="rust/crates/truapi-host-cli/js/script-types.d.ts" missing=0 for path in "${codegen_required[@]}"; do @@ -40,7 +41,7 @@ if [ "$missing" -eq 1 ] || [ -z "$example_file" ]; then TRUAPI_SKIP_PACKAGE_BUILD=1 ./scripts/codegen.sh fi -if [ -f "$truapi_dts" ]; then +if [ -f "$truapi_dts" ] && [ -f "$host_script_dts" ]; then exit 0 fi diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index 0cc6e29a0..65ea43a32 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -4,6 +4,7 @@ version = "0.14.0" edition.workspace = true description = "Headless TrUAPI hosts: a signing-host companion and a pairing host that pair over the real People-chain statement store, for end-to-end testing without an external signer service" license = "MIT" +include = ["src/**", "js/**", "README.md", "SPEC.md"] [[bin]] name = "truapi-host" diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index b948ca9a8..de925e9ce 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -52,8 +52,9 @@ moves that one link. | `TRUAPI_HOST_BIN_DIR` | Directory the `PATH` symlink goes in, default `~/.local/bin`. | Product scripts (`--script`, `/script`) work from an installed binary: the -archive ships a `runner.js` with the `@parity/truapi` client bundled in. You -still need `bun` on `PATH`, since it executes the runner and your script. +archive ships a `runner.js` with the `@parity/truapi` client bundled in and a +self-contained `script-types.d.ts` for the globals it injects. You still need +`bun` on `PATH`, since it executes the runner and your script. Product frames use a private, per-process WebSocket-over-Unix-domain-socket by default, so starting either host does not reserve a TCP port. Pass @@ -322,8 +323,10 @@ including a path previously selected with `/script `. If that file is missing or the session has no script yet, it creates a durable Bun TypeScript file under the active host state's `scripts/` directory. The dependency-free starter calls `truapi.account.getUserId()` and prints the returned user id. -Scripts opened from an npm project can import packages installed by that -project. +The generated file references an adjacent declaration bundle, so the editor +provides completion and type checking for `truapi`, `host`, and `assert` +without requiring `@parity/truapi` in a parent npm project. Scripts opened +from an npm project can still import packages installed by that project. The TUI temporarily yields the terminal to `$VISUAL`, then `$EDITOR`, or `vi` when neither is set. After the editor exits successfully, the TUI is restored and the saved script runs through the public frame endpoint. Editor diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index fa45c9c83..b4a43469e 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -168,6 +168,8 @@ release pointer, downloads the archive for the detected target ``` $XDG_DATA_HOME/truapi-host/versions//truapi-host +$XDG_DATA_HOME/truapi-host/versions//runner.js +$XDG_DATA_HOME/truapi-host/versions//script-types.d.ts $XDG_DATA_HOME/truapi-host/current -> versions/ ~/.local/bin/truapi-host -> $XDG_DATA_HOME/truapi-host/current/truapi-host ``` @@ -201,10 +203,17 @@ The runner is resolved in this order: `TRUAPI_HOST_RUNNER`, then `runner.js` next to the running binary, then `js/runner.ts` in the source checkout (compiled from `CARGO_MANIFEST_DIR`). -A release archive ships `runner.js` beside the binary, with `@parity/truapi` -bundled in, so an installed copy runs product scripts with no source tree. A -source build has no bundle and falls back to the checkout copy, whose relative -`@parity/truapi` import means it only works from a built tree. +A release archive ships `runner.js` and `script-types.d.ts` beside the binary. +The runner has `@parity/truapi` bundled in, and the declaration file contains +the matching generated client and injected-global types, so an installed copy +runs and edits product scripts with no source tree or npm package. A source +build has no runner bundle and falls back to the checkout copies, whose +relative `@parity/truapi` import means the runner only works from a built +tree. + +A `TRUAPI_HOST_RUNNER` override must provide a compatible +`script-types.d.ts` beside the selected runner when bare `/script` needs to +create an editor scratch file. `bun` is required either way, since the runner and user scripts are executed by it. @@ -832,8 +841,8 @@ These variables are runner internals, not CLI configuration inputs. - A thrown error or rejected promise is printed as `[script error] ...` and exits `1`. - Failure to open the product socket within 15 seconds exits `2`. -- Failure to locate the runner, canonicalize the script, or spawn Bun is a CLI - error. +- Failure to locate the runner or its declaration bundle, canonicalize the + script, or spawn Bun is a CLI error. The CLI emits `Script running` before Bun starts and `Script finished` or `Script failed` afterward. @@ -879,7 +888,14 @@ by a new scratch file. The default scratch file is a dependency-free Bun script that calls `truapi.account.getUserId()` and prints `user id` followed by the returned -value. It does not emit terminal styling. +value. A matching `.types.d.ts` file is copied beside it from the selected +runner's `script-types.d.ts`. The script imports its types and declares the +injected names within its own module, so multiple scripts can be checked +together. Editors therefore resolve the matching generated types for +`truapi`, `host`, and `assert` without a checkout or npm package. Keeping the +declaration beside the scratch file preserves its types across session +promotion and removal of older installed binary versions. The script does not +emit terminal styling. Mnemonic-backed ephemeral signing sessions remember a path only for the current process and create scratch files under the system temporary diff --git a/rust/crates/truapi-host-cli/js/runner-types.fixture.ts b/rust/crates/truapi-host-cli/js/runner-types.fixture.ts new file mode 100644 index 000000000..1fa708ee3 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/runner-types.fixture.ts @@ -0,0 +1,49 @@ +import type { + TrUApiClient, + HostContext, + ScriptAssert, +} from "./script-types.d.ts"; + +declare const truapi: TrUApiClient; +declare const host: HostContext; +declare const assert: ScriptAssert; + +const productContext = await truapi.system.getProductContext(); +assert(productContext.isOk(), "getProductContext failed", productContext); +const productId: string = productContext.value.productId; +assert(productId.length > 0); + +// @ts-expect-error Product context does not contain the signed-in user. +productContext.value.userId; + +const subscription = truapi.locale.subscribe().subscribe({ + next(locale) { + const languageTag: string = locale.languageTag; + assert(languageTag.length > 0); + + // @ts-expect-error Locale updates contain a language tag, not a product id. + locale.productId; + }, + error(error) { + if (error.reason?.tag === "HostFailure") { + const reason: string = error.reason.value.reason; + assert(reason.length > 0); + } + }, +}); +const subscriptionId: string = subscription.subscriptionId; +assert(subscriptionId.length > 0); +subscription.unsubscribe(); + +const account = host.productAccount(0); +const accountProductId: string = account.dotNsIdentifier; +assert(accountProductId.length > 0); + +// @ts-expect-error Product accounts have no user-facing username. +account.username; + +// @ts-expect-error Derivation indices are numeric. +host.productAccount("0"); + +// @ts-expect-error The generated client rejects unknown services. +truapi.unknownService; diff --git a/rust/crates/truapi-host-cli/js/scratch.ts b/rust/crates/truapi-host-cli/js/scratch.ts new file mode 100644 index 000000000..b4dcff2c9 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scratch.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env bun + +import type { + TrUApiClient, + HostContext, + ScriptAssert, +} from "./__TRUAPI_TYPES__"; + +declare const truapi: TrUApiClient; +declare const host: HostContext; +declare const assert: ScriptAssert; + +// Scripts can use packages installed next to the script or in a parent project. + +const result = await truapi.account.getUserId(); +if (!result.isOk()) { + throw new Error(`getUserId failed: ${JSON.stringify(result.error)}`); +} + +console.log("user id", result.value); diff --git a/rust/crates/truapi-host-cli/js/tsconfig.json b/rust/crates/truapi-host-cli/js/tsconfig.json new file mode 100644 index 000000000..dfa3a22ca --- /dev/null +++ b/rust/crates/truapi-host-cli/js/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "strict": true, + "target": "ES2022", + "types": [] + }, + "files": ["runner-types.fixture.ts"] +} diff --git a/rust/crates/truapi-host-cli/src/script_runner.rs b/rust/crates/truapi-host-cli/src/script_runner.rs index d38a22bea..09003a4a3 100644 --- a/rust/crates/truapi-host-cli/src/script_runner.rs +++ b/rust/crates/truapi-host-cli/src/script_runner.rs @@ -37,23 +37,21 @@ impl ScriptHostRole { } } -const SCRATCH_TEMPLATE: &str = r#"#!/usr/bin/env bun - -// Scripts can use packages installed next to the script or in a parent project. - -const result = await truapi.account.getUserId(); -if (!result.isOk()) { - throw new Error(`getUserId failed: ${JSON.stringify(result.error)}`); -} - -console.log('user id', result.value); -"#; +const SCRATCH_TEMPLATE: &str = include_str!("../js/scratch.ts"); /// Runner bundle shipped next to the binary in a release archive. It has /// `@parity/truapi` compiled in, so a downloaded install runs product scripts /// without a source checkout. const PACKAGED_RUNNER: &str = "runner.js"; +/// Self-contained injected-global declarations shipped with the runner. +const PACKAGED_SCRIPT_TYPES: &str = "script-types.d.ts"; + +/// Declaration bundle matching the selected host-script runner. +fn runner_types_path(runner: &Path) -> PathBuf { + runner.with_file_name(PACKAGED_SCRIPT_TYPES) +} + /// Locate the host-script runner. fn runner_path() -> PathBuf { resolve_runner( @@ -98,17 +96,24 @@ fn packaged_runner(executable: &Path) -> Option { /// Create a durable, uniquely-named TypeScript scratch file seeded with the /// public TrUAPI example. pub fn create_scratch_script(directory: &Path) -> Result { + create_scratch_script_for_runner(directory, &runner_path()) +} + +fn create_scratch_script_for_runner(directory: &Path, runner: &Path) -> Result { fs::create_dir_all(directory) .with_context(|| format!("create script directory {}", directory.display()))?; + let runner_types = runner_types_path(runner); + let runner_types = fs::read(&runner_types) + .with_context(|| format!("read host-script types {}", runner_types.display()))?; let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); for sequence in 0..100 { - let path = directory.join(format!( - "script-{timestamp}-{}-{sequence}.ts", - std::process::id() - )); + let name = format!("script-{timestamp}-{}-{sequence}", std::process::id()); + let path = directory.join(format!("{name}.ts")); + let types_name = format!("{name}.types.d.ts"); + let types_path = directory.join(&types_name); let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, @@ -117,7 +122,27 @@ pub fn create_scratch_script(directory: &Path) -> Result { .with_context(|| format!("create scratch script {}", path.display())); } }; - file.write_all(SCRATCH_TEMPLATE.as_bytes()) + let mut types_file = match OpenOptions::new() + .write(true) + .create_new(true) + .open(&types_path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + drop(file); + let _ = fs::remove_file(&path); + continue; + } + Err(error) => { + return Err(error) + .with_context(|| format!("create script types {}", types_path.display())); + } + }; + types_file + .write_all(&runner_types) + .with_context(|| format!("write script types {}", types_path.display()))?; + let contents = SCRATCH_TEMPLATE.replace("__TRUAPI_TYPES__", &types_name); + file.write_all(contents.as_bytes()) .with_context(|| format!("write scratch script {}", path.display()))?; return Ok(path); } @@ -352,25 +377,99 @@ mod tests { let temporary = tempfile::tempdir()?; let script = create_scratch_script(temporary.path())?; - let contents = fs::read_to_string(script)?; + let contents = fs::read_to_string(&script)?; + let script_types = script.with_extension("types.d.ts"); + let script_types_name = script_types.file_name().unwrap().to_string_lossy(); assert_eq!( contents, - r#"#!/usr/bin/env bun + format!( + r#"#!/usr/bin/env bun + +import type {{ + TrUApiClient, + HostContext, + ScriptAssert, +}} from "./{script_types_name}"; + +declare const truapi: TrUApiClient; +declare const host: HostContext; +declare const assert: ScriptAssert; // Scripts can use packages installed next to the script or in a parent project. const result = await truapi.account.getUserId(); -if (!result.isOk()) { - throw new Error(`getUserId failed: ${JSON.stringify(result.error)}`); -} +if (!result.isOk()) {{ + throw new Error(`getUserId failed: ${{JSON.stringify(result.error)}}`); +}} -console.log('user id', result.value); +console.log("user id", result.value); "# + ) + ); + assert_eq!( + fs::read(script_types)?, + fs::read(runner_types_path(&runner_path()))? ); Ok(()) } + #[test] + fn runner_types_follow_the_selected_runner() { + assert_eq!( + [ + runner_types_path(Path::new("/checkout/js/runner.ts")), + runner_types_path(Path::new("/release/runner.js")), + ], + [ + PathBuf::from("/checkout/js/script-types.d.ts"), + PathBuf::from("/release/script-types.d.ts"), + ] + ); + } + + /// A downloaded binary has no checkout or npm package to supply editor + /// declarations, so the scratch file has to retain the shipped bundle. + #[test] + fn packaged_runner_types_are_copied_beside_the_scratch_script() -> Result<()> { + let install = tempfile::tempdir()?; + let runner = install.path().join(PACKAGED_RUNNER); + fs::write(&runner, "packaged runner")?; + fs::write( + install.path().join(PACKAGED_SCRIPT_TYPES), + "declare const packaged: true;\n", + )?; + let scripts = tempfile::tempdir()?; + + let script = create_scratch_script_for_runner(scripts.path(), &runner)?; + + assert_eq!( + fs::read_to_string(script.with_extension("types.d.ts"))?, + "declare const packaged: true;\n" + ); + Ok(()) + } + + /// Opening an untyped scratch file recreates the original failure, so a + /// broken install must fail before launching the editor. + #[test] + fn scratch_creation_rejects_a_runner_without_types() { + let install = tempfile::tempdir().unwrap(); + let runner = install.path().join(PACKAGED_RUNNER); + fs::write(&runner, "packaged runner").unwrap(); + let scripts = tempfile::tempdir().unwrap(); + + let error = create_scratch_script_for_runner(scripts.path(), &runner).unwrap_err(); + + assert_eq!( + error.to_string(), + format!( + "read host-script types {}", + install.path().join(PACKAGED_SCRIPT_TYPES).display() + ) + ); + } + #[test] fn host_scripts_are_run_by_bun() -> Result<()> { let temporary = tempfile::tempdir()?; diff --git a/scripts/bundle-truapi-dts.mjs b/scripts/bundle-truapi-dts.mjs index 4615ea983..56815597f 100644 --- a/scripts/bundle-truapi-dts.mjs +++ b/scripts/bundle-truapi-dts.mjs @@ -7,6 +7,10 @@ const ROOT = fileURLToPath(new URL("..", import.meta.url)); const DIST = join(ROOT, "js/packages/truapi/dist"); const OUT_DIR = join(ROOT, "js/packages/truapi/src/playground/codegen"); const OUT = join(OUT_DIR, "truapi-dts.ts"); +const HOST_SCRIPT_DTS = join( + ROOT, + "rust/crates/truapi-host-cli/js/script-types.d.ts", +); // neverthrow is hoisted to the workspace root in npm workspaces; fall back to // the per-package node_modules for non-workspace setups. async function resolveNeverthrow() { @@ -52,7 +56,10 @@ const NEVERTHROW_IMPORT_RE = /^(?:import|export)[^;]*?from\s+["']neverthrow["'];?\s*\n?/gm; function stripImports(text) { - return text.replace(RELATIVE_IMPORT_RE, "").replace(NEVERTHROW_IMPORT_RE, ""); + return text + .replace(/\r\n?/g, "\n") + .replace(RELATIVE_IMPORT_RE, "") + .replace(NEVERTHROW_IMPORT_RE, ""); } // The generated client uses `T.` and `S.` because @@ -106,6 +113,21 @@ const namespaceTReExports = tExports .map((name) => `export import ${name} = T.${name};`) .join("\n"); +async function readHostFile(relPath) { + const path = typeFiles.get(relPath); + if (!path) throw new Error(`bundle: missing ${relPath}`); + return stripImports(await readFile(path, "utf8")); +} + +const STANDALONE_EXPORT_RE = /^export(?:\s+type)?\s+\{[^}]*\};?\s*\n?/gm; +const hostTransport = (await readHostFile("transport.d.ts")).replace( + STANDALONE_EXPORT_RE, + "", +); +const hostClient = (await readHostFile("generated/client.d.ts")) + .replace(STANDALONE_EXPORT_RE, "") + .replace(/\bS\.([A-Za-z_$][\w$]*)/g, "$1"); + const chunks = []; for (const [rel, path] of typeFiles) { const text = stripImports(await readFile(path, "utf8")); @@ -115,10 +137,9 @@ for (const [rel, path] of typeFiles) { // Strip the trailing `export { ... }` from neverthrow's d.ts since we're // inlining it inside `declare module "@parity/truapi"` — re-exporting names // at the bottom isn't meaningful in that context. -const neverthrow = (await readFile(NEVERTHROW_DTS, "utf8")).replace( - /^export\s+\{[^}]*\};?\s*$/gm, - "", -); +const neverthrow = (await readFile(NEVERTHROW_DTS, "utf8")) + .replace(/\r\n?/g, "\n") + .replace(/^export\s+\{[^}]*\};?\s*$/gm, ""); const bundled = [ "// neverthrow (inlined)", @@ -132,6 +153,51 @@ const bundled = [ ...chunks, ].join("\n\n"); +const hostNeverthrow = neverthrow + .replace(/A extends readonly any\[\]/g, "A extends readonly unknown[]") + .replace( + /Fn extends \(\.\.\.args: readonly any\[\]\) => any/g, + "Fn extends (...args: never[]) => unknown", + ); +const hostScriptDts = + `// Auto-generated by scripts/bundle-truapi-dts.mjs. Do not edit. + +${hostNeverthrow} + +type Encoder = (value: T) => Uint8Array; +type Decoder = (value: Uint8Array | ArrayBuffer | string) => T; +type Codec = [Encoder, Decoder] & { + enc: Encoder; + dec: Decoder; +}; +type ResultPayload = + | { success: true; value: Ok } + | { success: false; value: Err }; +type HexString = \`0x\${string}\`; +type CallErrorValue = + | { tag: "Domain"; value: DomainError } + | { tag: "Denied"; value?: undefined } + | { tag: "Unsupported"; value?: undefined } + | { tag: "MalformedFrame"; value: { reason: string } } + | { tag: "HostFailure"; value: { reason: string } }; + +${namespaceT} + +${hostTransport} + +${hostClient} + +/** Context injected alongside \`truapi\` by the headless host runner. */ +export interface HostContext { + /** Product id served by the host. */ + productId: string; + /** Product account for \`derivationIndex\`, which defaults to zero. */ + productAccount(index?: number): T.ProductAccountId; +} + +export type ScriptAssert = (condition: unknown, ...message: unknown[]) => asserts condition; +`.replace(/[ \t]+$/gm, ""); + function asTemplateLiteral(s) { return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); } @@ -142,7 +208,8 @@ await writeFile( `// Auto-generated by scripts/bundle-truapi-dts.mjs. Do not edit.\n` + `export const truapiDts = \`${asTemplateLiteral(bundled)}\`;\n`, ); +await writeFile(HOST_SCRIPT_DTS, hostScriptDts); console.log( - `wrote ${relative(ROOT, OUT)} (${bundled.length} chars, neverthrow inlined)`, + `wrote ${relative(ROOT, OUT)} and ${relative(ROOT, HOST_SCRIPT_DTS)} (${bundled.length} chars, neverthrow inlined)`, ); diff --git a/scripts/check-host-script-types.mjs b/scripts/check-host-script-types.mjs new file mode 100644 index 000000000..ad7359e7d --- /dev/null +++ b/scripts/check-host-script-types.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const runnerDirectory = fileURLToPath( + new URL("../rust/crates/truapi-host-cli/js/", import.meta.url), +); +const config = ts.readConfigFile( + join(runnerDirectory, "tsconfig.json"), + ts.sys.readFile, +); +if (config.error) + throw new Error( + ts.flattenDiagnosticMessageText(config.error.messageText, "\n"), + ); +const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + runnerDirectory, +); +const [template, declarations] = await Promise.all([ + readFile(join(runnerDirectory, "scratch.ts"), "utf8"), + readFile(join(runnerDirectory, "script-types.d.ts"), "utf8"), +]); +const directory = await mkdtemp(join(tmpdir(), "truapi-script-types-")); + +try { + const scripts = []; + for (const name of ["first", "second"]) { + const typesName = `${name}.types.d.ts`; + const script = join(directory, `${name}.ts`); + await writeFile(join(directory, typesName), declarations); + await writeFile(script, template.replace("__TRUAPI_TYPES__", typesName)); + scripts.push(script); + } + const program = ts.createProgram( + [...parsed.fileNames, ...scripts], + parsed.options, + ); + const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)]; + if (diagnostics.length) { + console.error( + ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (filename) => filename, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => "\n", + }), + ); + process.exitCode = 1; + } else { + console.log( + "Host script type fixture and independent scratch scripts passed.", + ); + } +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/scripts/e2e-cli-update.mjs b/scripts/e2e-cli-update.mjs index c81c32f54..96a717836 100755 --- a/scripts/e2e-cli-update.mjs +++ b/scripts/e2e-cli-update.mjs @@ -109,6 +109,14 @@ async function main() { existsSync(join(root, `versions/${version}/runner.js`)), true, ); + check( + "the matching product-script types ship beside the runner", + readFileSync(join(root, `versions/${version}/script-types.d.ts`), "utf8"), + readFileSync( + join(repoRoot, "rust/crates/truapi-host-cli/js/script-types.d.ts"), + "utf8", + ), + ); // The checkout's runner imports @parity/truapi by relative path. Running the // packaged one from an unrelated directory proves the client is bundled in, // so a downloaded install needs no source tree: it reaches its own env check