diff --git a/.cargo/config.toml b/.cargo/config.toml
deleted file mode 100644
index 4da77139..00000000
--- a/.cargo/config.toml
+++ /dev/null
@@ -1,16 +0,0 @@
-[alias]
-# Install development tools
-install-just = "install just"
-install-dev-tools = "install just cargo-watch"
-
-# Individual CI commands
-fmt-check = "fmt --all -- --check"
-lint = "clippy --all-targets --all-features -- -D warnings"
-test-all = "test --all-features"
-
-# Development helpers
-fix-clippy = "clippy --all-targets --all-features --fix --allow-dirty --allow-staged"
-fix-fmt = "fmt --all"
-
-# Build commands
-build-wasm = "build --package ftl-sdk-rs --target wasm32-wasip1"
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..b2b4a03d
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,30 @@
+version: 2
+updates:
+ # Enable version updates for Rust dependencies
+ - package-ecosystem: "cargo"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
+ groups:
+ cargo-dependencies:
+ patterns:
+ - "*"
+
+ # Enable version updates for npm dependencies (TypeScript SDK)
+ - package-ecosystem: "npm"
+ directory: "/packages/ftl-sdk-typescript"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
+ groups:
+ npm-dependencies:
+ patterns:
+ - "*"
+
+ # Enable version updates for GitHub Actions
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
\ No newline at end of file
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
new file mode 100644
index 00000000..3596d0f5
--- /dev/null
+++ b/.github/workflows/README.md
@@ -0,0 +1,50 @@
+# GitHub Workflows
+
+This directory contains the CI/CD workflows for the FTL project.
+
+## Workflows
+
+### Core Workflows
+
+- **[ci.yml](./ci.yml)** - Main CI pipeline that runs on every push and PR
+ - Linting (rustfmt, clippy)
+ - Building and testing the CLI
+ - Testing TypeScript and Rust SDKs
+ - Security audit
+
+- **[release.yml](./release.yml)** - Release pipeline triggered by version tags
+ - Builds binaries for Linux, macOS (x86_64 and aarch64)
+ - Creates GitHub releases
+ - Publishes to crates.io and npm
+
+### Quality Checks
+
+- **[test-templates.yml](./test-templates.yml)** - Tests the project templates
+ - Creates projects with `ftl init`
+ - Adds components with `ftl add` for all languages
+ - Builds and tests each component type
+
+- **[test-e2e.yml](./test-e2e.yml)** - End-to-end tests for each language
+ - Tests full ftl workflow: init, add, build, test
+ - Runs for Rust, TypeScript, and JavaScript
+ - Ensures templates work with actual ftl commands
+
+- **[check-sdk-versions.yml](./check-sdk-versions.yml)** - Ensures SDK versions match templates
+ - Verifies Rust SDK version in Rust template
+ - Verifies TypeScript SDK version in TypeScript/JavaScript templates
+
+- **[check-versions.yml](./check-versions.yml)** - Checks version consistency
+ - Ensures workspace and CLI versions match
+ - Warns about SDK version mismatches in templates
+
+- **[check-docs.yml](./check-docs.yml)** - Documentation checks
+ - Verifies README links are valid
+ - Ensures CLI help matches documentation
+ - Checks template READMEs exist
+
+## Dependabot
+
+The [dependabot.yml](../dependabot.yml) configuration keeps dependencies updated:
+- Cargo dependencies (weekly)
+- npm dependencies for TypeScript SDK (weekly)
+- GitHub Actions (weekly)
\ No newline at end of file
diff --git a/.github/workflows/check-sdk-versions.yml b/.github/workflows/check-sdk-versions.yml
new file mode 100644
index 00000000..dcf06946
--- /dev/null
+++ b/.github/workflows/check-sdk-versions.yml
@@ -0,0 +1,75 @@
+name: Check SDK Versions
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'packages/ftl-sdk-*/**'
+ - 'packages/ftl-cli/src/templates/**'
+ pull_request:
+ branches: [ main ]
+ paths:
+ - 'packages/ftl-sdk-*/**'
+ - 'packages/ftl-cli/src/templates/**'
+
+jobs:
+ check-versions:
+ name: Check SDK Version Consistency
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Check Rust SDK version in template
+ run: |
+ SDK_VERSION=$(grep '^version' packages/ftl-sdk-rust/Cargo.toml | cut -d'"' -f2)
+ TEMPLATE_VERSION=$(grep 'ftl-sdk' packages/ftl-cli/src/templates/rust/content/handler/Cargo.toml | cut -d'"' -f2)
+
+ echo "Rust SDK version: $SDK_VERSION"
+ echo "Rust template SDK version: $TEMPLATE_VERSION"
+
+ if [ "$SDK_VERSION" != "$TEMPLATE_VERSION" ]; then
+ echo "Error: Rust SDK version ($SDK_VERSION) does not match template version ($TEMPLATE_VERSION)"
+ echo "Please update packages/ftl-cli/src/templates/rust/content/handler/Cargo.toml"
+ exit 1
+ fi
+
+ - name: Check TypeScript SDK version in TypeScript template
+ run: |
+ SDK_VERSION=$(node -p "require('./packages/ftl-sdk-typescript/package.json').version")
+ TEMPLATE_VERSION=$(node -p "require('./packages/ftl-cli/src/templates/typescript/content/handler/package.json').dependencies['@fastertools/ftl-sdk']")
+
+ # Remove any ^ or ~ prefix from template version
+ TEMPLATE_VERSION=${TEMPLATE_VERSION#^}
+ TEMPLATE_VERSION=${TEMPLATE_VERSION#~}
+
+ echo "TypeScript SDK version: $SDK_VERSION"
+ echo "TypeScript template SDK version: $TEMPLATE_VERSION"
+
+ if [ "$SDK_VERSION" != "$TEMPLATE_VERSION" ]; then
+ echo "Error: TypeScript SDK version ($SDK_VERSION) does not match template version ($TEMPLATE_VERSION)"
+ echo "Please update packages/ftl-cli/src/templates/typescript/content/handler/package.json"
+ exit 1
+ fi
+
+ - name: Check TypeScript SDK version in JavaScript template
+ run: |
+ SDK_VERSION=$(node -p "require('./packages/ftl-sdk-typescript/package.json').version")
+ TEMPLATE_VERSION=$(node -p "require('./packages/ftl-cli/src/templates/javascript/content/handler/package.json').dependencies['@fastertools/ftl-sdk']")
+
+ # Remove any ^ or ~ prefix from template version
+ TEMPLATE_VERSION=${TEMPLATE_VERSION#^}
+ TEMPLATE_VERSION=${TEMPLATE_VERSION#~}
+
+ echo "TypeScript SDK version: $SDK_VERSION"
+ echo "JavaScript template SDK version: $TEMPLATE_VERSION"
+
+ if [ "$SDK_VERSION" != "$TEMPLATE_VERSION" ]; then
+ echo "Error: TypeScript SDK version ($SDK_VERSION) does not match JavaScript template version ($TEMPLATE_VERSION)"
+ echo "Please update packages/ftl-cli/src/templates/javascript/content/handler/package.json"
+ exit 1
+ fi
\ No newline at end of file
diff --git a/.github/workflows/check-versions.yml b/.github/workflows/check-versions.yml
new file mode 100644
index 00000000..11dd57e3
--- /dev/null
+++ b/.github/workflows/check-versions.yml
@@ -0,0 +1,66 @@
+name: Check Version Consistency
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ check-versions:
+ name: Check Version Consistency
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Check Cargo.toml versions match
+ run: |
+ # Get workspace version
+ WORKSPACE_VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2)
+ echo "Workspace version: $WORKSPACE_VERSION"
+
+ # Check ftl-cli version
+ CLI_VERSION_LINE=$(grep '^version' packages/ftl-cli/Cargo.toml | head -1)
+
+ # Check if CLI uses workspace version
+ if echo "$CLI_VERSION_LINE" | grep -q "workspace = true"; then
+ echo "CLI version: inherited from workspace ($WORKSPACE_VERSION)"
+ else
+ CLI_VERSION=$(echo "$CLI_VERSION_LINE" | cut -d'"' -f2)
+ echo "CLI version: $CLI_VERSION"
+
+ if [ "$WORKSPACE_VERSION" != "$CLI_VERSION" ]; then
+ echo "Error: Workspace version ($WORKSPACE_VERSION) does not match CLI version ($CLI_VERSION)"
+ exit 1
+ fi
+ fi
+
+ - name: Check SDK dependency versions in templates
+ run: |
+ # Check Rust SDK version
+ RUST_SDK_VERSION=$(grep '^version' packages/ftl-sdk-rust/Cargo.toml | cut -d'"' -f2)
+ RUST_TEMPLATE_VERSION=$(grep 'ftl-sdk' packages/ftl-cli/src/templates/rust/content/handler/Cargo.toml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
+
+ echo "Rust SDK version: $RUST_SDK_VERSION"
+ echo "Rust template SDK version: $RUST_TEMPLATE_VERSION"
+
+ if [ "$RUST_SDK_VERSION" != "$RUST_TEMPLATE_VERSION" ]; then
+ echo "Warning: Rust SDK version mismatch"
+ fi
+
+ # Check TypeScript SDK version
+ TS_SDK_VERSION=$(node -p "require('./packages/ftl-sdk-typescript/package.json').version")
+ TS_TEMPLATE_VERSION=$(node -p "require('./packages/ftl-cli/src/templates/typescript/content/handler/package.json').dependencies['@fastertools/ftl-sdk']" | sed 's/[\^~]//')
+ JS_TEMPLATE_VERSION=$(node -p "require('./packages/ftl-cli/src/templates/javascript/content/handler/package.json').dependencies['@fastertools/ftl-sdk']" | sed 's/[\^~]//')
+
+ echo "TypeScript SDK version: $TS_SDK_VERSION"
+ echo "TypeScript template SDK version: $TS_TEMPLATE_VERSION"
+ echo "JavaScript template SDK version: $JS_TEMPLATE_VERSION"
+
+ if [ "$TS_SDK_VERSION" != "$TS_TEMPLATE_VERSION" ]; then
+ echo "Warning: TypeScript SDK version mismatch in TypeScript template"
+ fi
+
+ if [ "$TS_SDK_VERSION" != "$JS_TEMPLATE_VERSION" ]; then
+ echo "Warning: TypeScript SDK version mismatch in JavaScript template"
+ fi
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1620e277..f5fde1c4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,19 +23,19 @@ jobs:
components: rustfmt, clippy
- name: Cache cargo registry
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
@@ -44,7 +44,7 @@ jobs:
run: cargo fmt --all -- --check
- name: Run clippy
- run: cargo clippy --all-targets --all-features -- -D warnings
+ run: cargo clippy --all -- -D warnings
test:
name: Test
@@ -59,19 +59,19 @@ jobs:
run: rustup target add wasm32-wasip1
- name: Cache cargo registry
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
- uses: actions/cache@v3
+ uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
@@ -82,12 +82,14 @@ jobs:
- name: Run tests
run: cargo test --all-features
- - name: Build WASM targets
+ - name: Test build templates
run: |
- cargo build --package ftl-sdk-rs --target wasm32-wasip1
+ # Test that the embedded templates are valid
+ cd packages/ftl-cli
+ cargo build --release
- test-typescript-sdk:
- name: Test TypeScript SDK
+ test-sdks:
+ name: Test SDKs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -97,20 +99,23 @@ jobs:
with:
node-version: '20'
- - name: Install dependencies
+ - name: Test TypeScript SDK
run: |
- cd packages/ftl-sdk-ts
+ cd packages/ftl-sdk-typescript
npm ci
-
- - name: Build TypeScript SDK
- run: |
- cd packages/ftl-sdk-ts
npm run build
+ npm test
- - name: Run tests
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-wasip1
+
+ - name: Test Rust SDK
run: |
- cd packages/ftl-sdk-ts
- npm test
+ cd packages/ftl-sdk-rust
+ cargo test
+ cargo build --target wasm32-wasip1
security:
name: Security Audit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 31d4207c..9c2bc433 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -31,6 +31,10 @@ jobs:
target: aarch64-apple-darwin
binary: ftl
asset_name: ftl-aarch64-apple-darwin
+ - os: windows-latest
+ target: x86_64-pc-windows-msvc
+ binary: ftl.exe
+ asset_name: ftl-x86_64-pc-windows-msvc.exe
steps:
- uses: actions/checkout@v4
@@ -42,9 +46,10 @@ jobs:
- name: Validate version matches Cargo.toml
run: |
+ # Since ftl-cli uses workspace version, check the root Cargo.toml
CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2)
if [ "$CARGO_VERSION" != "${{ steps.version.outputs.VERSION }}" ]; then
- echo "Error: Tag version (${{ steps.version.outputs.VERSION }}) does not match Cargo.toml version ($CARGO_VERSION)"
+ echo "Error: Tag version (${{ steps.version.outputs.VERSION }}) does not match workspace version ($CARGO_VERSION)"
exit 1
fi
shell: bash
@@ -84,9 +89,11 @@ jobs:
run: |
mkdir -p release-assets
for dir in binaries/*/; do
+ asset_name=$(basename "$dir")
if [ -f "${dir}ftl" ]; then
- asset_name=$(basename "$dir")
cp "${dir}ftl" "release-assets/${asset_name}"
+ elif [ -f "${dir}ftl.exe" ]; then
+ cp "${dir}ftl.exe" "release-assets/${asset_name}"
fi
done
ls -la release-assets/
@@ -112,19 +119,32 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- - name: Publish ftl-sdk-rs
- if: steps.check-sdk.outputs.skip != 'true'
+ - name: Publish ftl-sdk
run: |
- cd packages/ftl-sdk-rs
- cargo publish
+ cd packages/ftl-sdk-rust
+ # Check if this version is already published
+ if cargo search ftl-sdk --limit 1 | grep -q "^ftl-sdk = \"$(grep '^version' Cargo.toml | cut -d'"' -f2)\""; then
+ echo "ftl-sdk version already published, skipping"
+ else
+ cargo publish
+ fi
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+ - name: Wait for ftl-sdk to be available
+ run: sleep 30
+
- name: Publish ftl-cli
- if: steps.check-cli.outputs.skip != 'true'
run: |
cd packages/ftl-cli
- cargo publish
+ # Check if this version is already published
+ # Since ftl-cli uses workspace version, get version from root
+ WORKSPACE_VERSION=$(grep '^version' ../../Cargo.toml | head -1 | cut -d'"' -f2)
+ if cargo search ftl-cli --limit 1 | grep -q "^ftl-cli = \"$WORKSPACE_VERSION\""; then
+ echo "ftl-cli version already published, skipping"
+ else
+ cargo publish
+ fi
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
@@ -144,7 +164,7 @@ jobs:
- name: Validate version matches
run: |
- PACKAGE_VERSION=$(node -p "require('./packages/ftl-sdk-ts/package.json').version")
+ PACKAGE_VERSION=$(node -p "require('./packages/ftl-sdk-typescript/package.json').version")
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
echo "Error: Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)"
@@ -153,22 +173,22 @@ jobs:
- name: Install dependencies
run: |
- cd packages/ftl-sdk-ts
+ cd packages/ftl-sdk-typescript
npm ci
- name: Build TypeScript SDK
run: |
- cd packages/ftl-sdk-ts
+ cd packages/ftl-sdk-typescript
npm run build
- name: Run tests
run: |
- cd packages/ftl-sdk-ts
+ cd packages/ftl-sdk-typescript
npm test
- name: Publish to npm
run: |
- cd packages/ftl-sdk-ts
- npm publish
+ cd packages/ftl-sdk-typescript
+ npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
\ No newline at end of file
diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml
new file mode 100644
index 00000000..dbc53791
--- /dev/null
+++ b/.github/workflows/test-cli.yml
@@ -0,0 +1,167 @@
+name: CLI Tests
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ test-rust-e2e:
+ name: CLI Test - Rust
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-wasip1
+
+ - name: Install cargo-binstall
+ uses: cargo-bins/cargo-binstall@main
+
+ - name: Install ftl CLI
+ run: |
+ cargo install --path packages/ftl-cli
+
+ - name: Setup Spin
+ uses: fermyon/actions/spin/setup@v1
+ with:
+ version: v3.3.1
+
+ - name: Install Spin templates
+ run: |
+ spin templates install --git https://github.com/fermyon/spin --upgrade
+
+ - name: Setup ftl templates
+ run: |
+ ftl setup templates --force
+
+ - name: Test Rust workflow
+ run: |
+ # Create a test project
+ WORK_DIR=$(mktemp -d)
+ cd $WORK_DIR
+ ftl init test-project
+ cd test-project
+
+ # Add a Rust component
+ ftl add test-rust --language rust --description "Test Rust component" --route /test-rust/mcp
+
+ # Build the component
+ cd test-rust
+ make build
+
+ # Run tests
+ make test
+
+ # Build at project level
+ cd ..
+ ftl build
+
+ test-typescript-e2e:
+ name: CLI Test - TypeScript
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install ftl CLI
+ run: |
+ cargo install --path packages/ftl-cli
+
+ - name: Setup Spin
+ uses: fermyon/actions/spin/setup@v1
+ with:
+ version: v3.3.1
+
+ - name: Install Spin templates
+ run: |
+ spin templates install --git https://github.com/fermyon/spin --upgrade
+
+ - name: Setup ftl templates
+ run: |
+ ftl setup templates --force
+
+ - name: Test TypeScript workflow
+ run: |
+ # Create a test project
+ WORK_DIR=$(mktemp -d)
+ cd $WORK_DIR
+ ftl init test-project
+ cd test-project
+
+ # Add a TypeScript component
+ ftl add test-ts --language typescript --description "Test TypeScript component" --route /test-ts/mcp
+
+ # Build the component
+ cd test-ts
+ make build
+
+ # Run tests
+ make test
+
+ # Build at project level
+ cd ..
+ ftl build
+
+ test-javascript-e2e:
+ name: CLI Test - JavaScript
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install ftl CLI
+ run: |
+ cargo install --path packages/ftl-cli
+
+ - name: Setup Spin
+ uses: fermyon/actions/spin/setup@v1
+ with:
+ version: v3.3.1
+
+ - name: Install Spin templates
+ run: |
+ spin templates install --git https://github.com/fermyon/spin --upgrade
+
+ - name: Setup ftl templates
+ run: |
+ ftl setup templates --force
+
+ - name: Test JavaScript workflow
+ run: |
+ # Create a test project
+ WORK_DIR=$(mktemp -d)
+ cd $WORK_DIR
+ ftl init test-project
+ cd test-project
+
+ # Add a JavaScript component
+ ftl add test-js --language javascript --description "Test JavaScript component" --route /test-js/mcp
+
+ # Build the component
+ cd test-js
+ make build
+
+ # Run tests
+ make test
+
+ # Build at project level
+ cd ..
+ ftl build
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index e38ca868..7403516c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,4 +62,6 @@ flamegraph.svg
# Release artifacts
dist/
-release/
\ No newline at end of file
+release/
+
+.claude/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aac3c84b..2d6ca526 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- Support for Spin template options in `ftl init` and `ftl setup templates` commands
+ - `--git`: Use a Git repository as the template source
+ - `--branch`: Specify Git branch (requires `--git`)
+ - `--dir`: Use a local directory as the template source
+ - `--tar`: Use a tarball as the template source
+
+### Fixed
+- Routes specified in `ftl add` now correctly appear in spin.toml (using --value instead of env vars)
+- HTTP routes now automatically end with `/mcp` and use kebab-case
+- `ftl build` now shows verbose build output like `ftl up --build` does
+
+### Changed
+- Removed `ftl project` subcommand entirely - all commands are now at root level
+ - `ftl project init` β `ftl init` (already existed)
+ - `ftl project serve` β `ftl up` (already existed)
+ - `ftl project deploy` β `ftl deploy` (newly added)
+ - Removed `ftl project add` (use `ftl add` instead)
+- Restructured component creation workflow
+ - `ftl init` now creates a project (http-empty container) instead of a single component
+ - Added new `ftl add` command for adding components to projects
+ - Removed `--language` option from `ftl init` (moved to `ftl add`)
+ - This ensures consistent workflow for single and multi-component projects
+- Simplified ftl.toml to only contain component metadata
+ - Removed unused `[build]`, `[optimization]`, and `[runtime]` sections
+ - Build configuration is handled by Makefiles and language-specific tools
+ - Runtime configuration belongs in spin.toml
- Initial release of FTL CLI framework
- Core CLI commands: `new`, `build`, `serve`, `deploy`
- Tool management commands: `test`, `watch`, `validate`, `size`
diff --git a/Cargo.lock b/Cargo.lock
index 7d24df26..3acf1dbb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -37,21 +37,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "android-tzdata"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
-
-[[package]]
-name = "android_system_properties"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "anstream"
version = "0.6.19"
@@ -117,17 +102,6 @@ dependencies = [
"derive_arbitrary",
]
-[[package]]
-name = "async-trait"
-version = "0.1.88"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.104",
-]
-
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -135,10 +109,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
-name = "autocfg"
-version = "1.5.0"
+name = "atty"
+version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "winapi",
+]
[[package]]
name = "backtrace"
@@ -253,9 +232,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.27"
+version = "1.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc"
+checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362"
dependencies = [
"jobserver",
"libc",
@@ -268,20 +247,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
-[[package]]
-name = "chrono"
-version = "0.4.41"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
-dependencies = [
- "android-tzdata",
- "iana-time-zone",
- "js-sys",
- "num-traits",
- "wasm-bindgen",
- "windows-link",
-]
-
[[package]]
name = "cipher"
version = "0.4.4"
@@ -320,10 +285,10 @@ version = "4.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
dependencies = [
- "heck 0.5.0",
+ "heck",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -351,19 +316,6 @@ dependencies = [
"windows-sys 0.59.0",
]
-[[package]]
-name = "console"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d"
-dependencies = [
- "encode_unicode",
- "libc",
- "once_cell",
- "unicode-width",
- "windows-sys 0.60.2",
-]
-
[[package]]
name = "constant_time_eq"
version = "0.3.1"
@@ -465,7 +417,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -476,7 +428,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -502,7 +454,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -523,7 +475,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -533,7 +485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -542,7 +494,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
dependencies = [
- "console 0.15.11",
+ "console",
"shell-words",
"tempfile",
"thiserror 1.0.69",
@@ -589,7 +541,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -701,9 +653,10 @@ name = "ftl-cli"
version = "0.0.18"
dependencies = [
"anyhow",
+ "atty",
"cargo_metadata",
"clap",
- "console 0.15.11",
+ "console",
"dialoguer",
"dirs",
"flate2",
@@ -728,33 +681,6 @@ dependencies = [
"zip",
]
-[[package]]
-name = "ftl-sdk-rs"
-version = "0.0.18"
-dependencies = [
- "serde",
- "serde_json",
- "spin-sdk",
- "talc",
- "thiserror 1.0.69",
- "tokio",
-]
-
-[[package]]
-name = "futures"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
-dependencies = [
- "futures-channel",
- "futures-core",
- "futures-executor",
- "futures-io",
- "futures-sink",
- "futures-task",
- "futures-util",
-]
-
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -762,7 +688,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
- "futures-sink",
]
[[package]]
@@ -771,17 +696,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
-[[package]]
-name = "futures-executor"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
-dependencies = [
- "futures-core",
- "futures-task",
- "futures-util",
-]
-
[[package]]
name = "futures-io"
version = "0.3.31"
@@ -796,7 +710,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -817,7 +731,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
- "futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -913,18 +826,18 @@ checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
[[package]]
name = "heck"
-version = "0.4.1"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
-dependencies = [
- "unicode-segmentation",
-]
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
-name = "heck"
-version = "0.5.0"
+name = "hermit-abi"
+version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
[[package]]
name = "hmac"
@@ -1062,30 +975,6 @@ dependencies = [
"windows-registry",
]
-[[package]]
-name = "iana-time-zone"
-version = "0.1.63"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
-dependencies = [
- "android_system_properties",
- "core-foundation-sys",
- "iana-time-zone-haiku",
- "js-sys",
- "log",
- "wasm-bindgen",
- "windows-core",
-]
-
-[[package]]
-name = "iana-time-zone-haiku"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
-dependencies = [
- "cc",
-]
-
[[package]]
name = "icu_collections"
version = "2.0.0"
@@ -1172,12 +1061,6 @@ dependencies = [
"zerovec",
]
-[[package]]
-name = "id-arena"
-version = "2.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25a2bc672d1148e28034f176e01fffebb08b35768468cc954630da77a1449005"
-
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -1232,19 +1115,18 @@ checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
"equivalent",
"hashbrown",
- "serde",
]
[[package]]
name = "indicatif"
-version = "0.17.12"
+version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4adb2ee6ad319a912210a36e56e3623555817bcc877a7e6e8802d1d69c4d8056"
+checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
- "console 0.16.0",
+ "console",
+ "number_prefix",
"portable-atomic",
"unicode-width",
- "unit-prefix",
"web-time",
]
@@ -1277,6 +1159,17 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "io-uring"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
+dependencies = [
+ "bitflags 2.9.1",
+ "cfg-if",
+ "libc",
+]
+
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -1351,12 +1244,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
-[[package]]
-name = "leb128"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
-
[[package]]
name = "libc"
version = "0.2.174"
@@ -1392,16 +1279,6 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
-[[package]]
-name = "lock_api"
-version = "0.4.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
-dependencies = [
- "autocfg",
- "scopeguard",
-]
-
[[package]]
name = "log"
version = "0.4.27"
@@ -1550,13 +1427,10 @@ dependencies = [
]
[[package]]
-name = "num-traits"
-version = "0.2.19"
+name = "number_prefix"
+version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
-dependencies = [
- "autocfg",
-]
+checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "object"
@@ -1602,7 +1476,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -1682,7 +1556,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -1879,16 +1753,6 @@ dependencies = [
"windows-sys 0.52.0",
]
-[[package]]
-name = "routefinder"
-version = "0.5.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0971d3c8943a6267d6bd0d782fdc4afa7593e7381a92a3df950ff58897e066b5"
-dependencies = [
- "smartcow",
- "smartstring",
-]
-
[[package]]
name = "rustc-demangle"
version = "0.1.25"
@@ -1984,12 +1848,6 @@ dependencies = [
"windows-sys 0.59.0",
]
-[[package]]
-name = "scopeguard"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -2039,7 +1897,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -2145,26 +2003,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
-[[package]]
-name = "smartcow"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "656fcb1c1fca8c4655372134ce87d8afdf5ec5949ebabe8d314be0141d8b5da2"
-dependencies = [
- "smartstring",
-]
-
-[[package]]
-name = "smartstring"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
-dependencies = [
- "autocfg",
- "static_assertions",
- "version_check",
-]
-
[[package]]
name = "socket2"
version = "0.5.10"
@@ -2175,74 +2013,12 @@ dependencies = [
"windows-sys 0.52.0",
]
-[[package]]
-name = "spdx"
-version = "0.10.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58b69356da67e2fc1f542c71ea7e654a361a79c938e4424392ecf4fa065d2193"
-dependencies = [
- "smallvec",
-]
-
-[[package]]
-name = "spin-executor"
-version = "3.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d11baf86ca52100e8742ea43d2c342cf4d75b94f8a85454cf44fd108cdd71d5"
-dependencies = [
- "futures",
- "once_cell",
- "wit-bindgen",
-]
-
-[[package]]
-name = "spin-macro"
-version = "3.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "988ffe27470862bf28fe9b4f0268361040d4732cd86bcaebe45aa3d3b3e3d896"
-dependencies = [
- "anyhow",
- "bytes",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "spin-sdk"
-version = "3.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f845e889d8431740806e04704ac5aa619466dfaef626f3c15952ecf823913e01"
-dependencies = [
- "anyhow",
- "async-trait",
- "bytes",
- "chrono",
- "form_urlencoded",
- "futures",
- "http",
- "once_cell",
- "routefinder",
- "serde",
- "serde_json",
- "spin-executor",
- "spin-macro",
- "thiserror 1.0.69",
- "wit-bindgen",
-]
-
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
-[[package]]
-name = "static_assertions"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
-
[[package]]
name = "strsim"
version = "0.11.1"
@@ -2255,17 +2031,6 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
-[[package]]
-name = "syn"
-version = "1.0.109"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
[[package]]
name = "syn"
version = "2.0.104"
@@ -2294,7 +2059,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -2318,15 +2083,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "talc"
-version = "4.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3ae828aa394de34c7de08f522d1b86bd1c182c668d27da69caadda00590f26d"
-dependencies = [
- "lock_api",
-]
-
[[package]]
name = "tar"
version = "0.4.44"
@@ -2377,7 +2133,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -2388,7 +2144,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -2431,16 +2187,18 @@ dependencies = [
[[package]]
name = "tokio"
-version = "1.45.1"
+version = "1.46.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
+checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
dependencies = [
"backtrace",
"bytes",
+ "io-uring",
"libc",
"mio 1.0.4",
"pin-project-lite",
"signal-hook-registry",
+ "slab",
"socket2",
"tokio-macros",
"windows-sys 0.52.0",
@@ -2454,7 +2212,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -2595,7 +2353,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -2661,30 +2419,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
-[[package]]
-name = "unicode-segmentation"
-version = "1.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
-
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
-[[package]]
-name = "unicode-xid"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
-
-[[package]]
-name = "unit-prefix"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817"
-
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2788,7 +2528,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
"wasm-bindgen-shared",
]
@@ -2823,7 +2563,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -2837,40 +2577,6 @@ dependencies = [
"unicode-ident",
]
-[[package]]
-name = "wasm-encoder"
-version = "0.38.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ad2b51884de9c7f4fe2fd1043fccb8dcad4b1e29558146ee57a144d15779f3f"
-dependencies = [
- "leb128",
-]
-
-[[package]]
-name = "wasm-encoder"
-version = "0.41.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "972f97a5d8318f908dded23594188a90bcd09365986b1163e66d70170e5287ae"
-dependencies = [
- "leb128",
-]
-
-[[package]]
-name = "wasm-metadata"
-version = "0.10.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18ebaa7bd0f9e7a5e5dd29b9a998acf21c4abed74265524dd7e85934597bfb10"
-dependencies = [
- "anyhow",
- "indexmap",
- "serde",
- "serde_derive",
- "serde_json",
- "spdx",
- "wasm-encoder 0.41.2",
- "wasmparser 0.121.2",
-]
-
[[package]]
name = "wasm-streams"
version = "0.4.2"
@@ -2884,27 +2590,6 @@ dependencies = [
"web-sys",
]
-[[package]]
-name = "wasmparser"
-version = "0.118.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77f1154f1ab868e2a01d9834a805faca7bf8b50d041b4ca714d005d0dab1c50c"
-dependencies = [
- "indexmap",
- "semver",
-]
-
-[[package]]
-name = "wasmparser"
-version = "0.121.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9dbe55c8f9d0dbd25d9447a5a889ff90c0cc3feaa7395310d3d826b2c703eaab"
-dependencies = [
- "bitflags 2.9.1",
- "indexmap",
- "semver",
-]
-
[[package]]
name = "web-sys"
version = "0.3.77"
@@ -2968,41 +2653,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-[[package]]
-name = "windows-core"
-version = "0.61.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
-dependencies = [
- "windows-implement",
- "windows-interface",
- "windows-link",
- "windows-result",
- "windows-strings",
-]
-
-[[package]]
-name = "windows-implement"
-version = "0.60.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.104",
-]
-
-[[package]]
-name = "windows-interface"
-version = "0.59.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.104",
-]
-
[[package]]
name = "windows-link"
version = "0.1.3"
@@ -3274,27 +2924,6 @@ version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
-[[package]]
-name = "wit-bindgen"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b76f1d099678b4f69402a421e888bbe71bf20320c2f3f3565d0e7484dbe5bc20"
-dependencies = [
- "bitflags 2.9.1",
- "wit-bindgen-rust-macro",
-]
-
-[[package]]
-name = "wit-bindgen-core"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75d55e1a488af2981fb0edac80d8d20a51ac36897a1bdef4abde33c29c1b6d0d"
-dependencies = [
- "anyhow",
- "wit-component",
- "wit-parser",
-]
-
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
@@ -3304,70 +2933,6 @@ dependencies = [
"bitflags 2.9.1",
]
-[[package]]
-name = "wit-bindgen-rust"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a01ff9cae7bf5736750d94d91eb8a49f5e3a04aff1d1a3218287d9b2964510f8"
-dependencies = [
- "anyhow",
- "heck 0.4.1",
- "wasm-metadata",
- "wit-bindgen-core",
- "wit-component",
-]
-
-[[package]]
-name = "wit-bindgen-rust-macro"
-version = "0.16.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "804a98e2538393d47aa7da65a7348116d6ff403b426665152b70a168c0146d49"
-dependencies = [
- "anyhow",
- "proc-macro2",
- "quote",
- "syn 2.0.104",
- "wit-bindgen-core",
- "wit-bindgen-rust",
- "wit-component",
-]
-
-[[package]]
-name = "wit-component"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b8a35a2a9992898c9d27f1664001860595a4bc99d32dd3599d547412e17d7e2"
-dependencies = [
- "anyhow",
- "bitflags 2.9.1",
- "indexmap",
- "log",
- "serde",
- "serde_derive",
- "serde_json",
- "wasm-encoder 0.38.1",
- "wasm-metadata",
- "wasmparser 0.118.2",
- "wit-parser",
-]
-
-[[package]]
-name = "wit-parser"
-version = "0.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "316b36a9f0005f5aa4b03c39bc3728d045df136f8c13a73b7db4510dec725e08"
-dependencies = [
- "anyhow",
- "id-arena",
- "indexmap",
- "log",
- "semver",
- "serde",
- "serde_derive",
- "serde_json",
- "unicode-xid",
-]
-
[[package]]
name = "writeable"
version = "0.6.1"
@@ -3413,7 +2978,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
"synstructure",
]
@@ -3434,7 +2999,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
"synstructure",
]
@@ -3455,7 +3020,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
@@ -3488,7 +3053,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 79b9b510..16289ed4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[workspace]
-members = ["packages/ftl-cli", "packages/ftl-sdk-rs"]
-exclude = ["demo_hash", "toolkit-*"]
+members = ["packages/ftl-cli"]
+exclude = ["demo_hash", "toolkit-*", "packages/ftl-cli/src/templates/**", "packages/ftl-sdk-rust"]
resolver = "2"
# Don't build CLI for wasm target
diff --git a/README.md b/README.md
index 6c9cc5a3..6b62a6bf 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
# `ftl`
-Fast tools for AI agents
+Build and deploy Model Context Protocol (MCP) servers on WebAssembly
-[](https://github.com/fastertools/core/actions/workflows/ci.yml)
+[](https://github.com/fastertools/ftl-cli/actions/workflows/ci.yml)
[](LICENSE)
[](https://www.rust-lang.org)
[](https://webassembly.org/)
@@ -13,296 +13,237 @@ Fast tools for AI agents
-FTL is a platform for developing and deploying fast, edge-hosted [Model Context Protocol](https://modelcontextprotocol.io/introduction) tools for AI agents.
+FTL is a developer platform for building and deploying [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers as WebAssembly components. It provides a complete workflow for creating, testing, composing, and deploying MCP components using the [Fermyon Spin](https://www.fermyon.com/spin) platform.
-This repository contains the `ftl` command-line interface, which is the primary entry point.
-
-## Getting Started
-
-### Installation
+## Quick Start
```bash
+# Install FTL
cargo install ftl-cli
-```
-### Create a New Tool
+# Create a new project
+ftl init my-assistant
+cd my-assistant
-
-π¦ Rust
+# Add a component
+ftl add weather-tool --language typescript
-```bash
-ftl new my-tool --language rust
+# Start development server with auto-rebuild
+ftl watch
+
+# Run tests
+ftl test
+
+# Build and deploy
+ftl build --release
+ftl publish
```
-This creates a new directory with:
-- `ftl.toml` - Tool manifest
-- `Cargo.toml` - Rust dependencies
-- `src/lib.rs` - Tool implementation
+## Key Features
-```rust
-use ftl_sdk_rs::prelude::*;
-
-#[derive(Clone)]
-struct MyTool;
-
-impl Tool for MyTool {
- fn name(&self) -> &'static str { "my-tool" }
- fn description(&self) -> &'static str { "My tool description" }
-
- fn input_schema(&self) -> serde_json::Value {
- json!({
- "type": "object",
- "properties": {
- "input": {"type": "string"}
- },
- "required": ["input"]
- })
- }
-
- fn call(&self, args: &serde_json::Value) -> Result {
- let input = args["input"].as_str()
- .ok_or(ToolError::InvalidArguments("input required".into()))?;
-
- Ok(ToolResult::text(format!("Processed: {}", input)))
- }
-}
+- **Component-First Architecture**: Build MCP servers as reusable WebAssembly components
+- **Multi-Language Support**: Write components in Rust, TypeScript, or JavaScript
+- **Registry Publishing**: Share components via OCI registries (GitHub, Docker Hub)
+- **Project Composition**: Combine multiple MCP components into a single deployable unit
+- **Automatic Dependency Management**: Tools like cargo-component installed on-demand
+- **Hot Reload Development**: Auto-rebuild on file changes with `ftl watch`
+- **Edge Deployment**: Deploy anywhere Spin runs
-ftl_sdk_rs::ftl_mcp_server!(MyTool);
-```
-
+## Creating MCP Projects
-
-π· TypeScript
+### TypeScript Example
```bash
-ftl new my-tool --language typescript
+# Create project and add TypeScript component
+ftl init my-project
+cd my-project
+ftl add my-tool --language typescript
```
-This creates a new directory with:
-- `ftl.toml` - Tool manifest
-- `package.json` - Node dependencies
-- `tsconfig.json` - TypeScript configuration
-- `src/index.ts` - Tool implementation
-
```typescript
-import { Tool, ToolResult, ToolError } from '@fastertools/ftl-sdk-ts';
-
-export default class MyTool extends Tool {
- get name(): string { return 'my-tool'; }
- get description(): string { return 'My tool description'; }
-
- get inputSchema() {
- return {
- type: 'object',
- properties: {
- input: { type: 'string' }
- },
- required: ['input']
- };
- }
-
- execute(args: { input: string }): ToolResult {
- const { input } = args;
-
- if (!input) {
- throw ToolError.invalidArguments('input required');
- }
-
- return ToolResult.text(`Processed: ${input}`);
- }
-}
-```
-
-
+// my-tool/src/index.ts
+import { createHandler } from '@fastertools/ftl-sdk';
+import { tools, resources, prompts } from './features.js';
-
+export const handler = createHandler({
+ tools, // Your MCP tools
+ resources, // Your MCP resources
+ prompts // Your MCP prompts
+});
+```
-
-π¨ JavaScript
+### Rust Example
```bash
-ftl new my-tool --language javascript
+# Create project and add Rust component
+ftl init my-project
+cd my-project
+ftl add my-tool --language rust
```
-This creates a new directory with:
-- `ftl.toml` - Tool manifest
-- `package.json` - Node dependencies
-- `src/index.js` - Tool implementation
-
-```javascript
-import { Tool, ToolResult, ToolError } from '@fastertools/ftl-sdk-ts';
-
-export default class MyTool extends Tool {
- get name() { return 'my-tool'; }
- get description() { return 'My tool description'; }
-
- get inputSchema() {
- return {
- type: 'object',
- properties: {
- input: { type: 'string' }
- },
- required: ['input']
- };
- }
-
- execute(args) {
- const { input } = args;
-
- if (!input) {
- throw ToolError.invalidArguments('input required');
- }
-
- return ToolResult.text(`Processed: ${input}`);
- }
+```rust
+// my-tool/src/lib.rs
+use ftl_sdk::*;
+
+create_handler!(
+ tools: get_tools,
+ resources: get_resources,
+ prompts: get_prompts
+);
+
+fn get_tools() -> Vec {
+ vec![
+ tool!("my_tool", "Tool description", schema, execute_tool)
+ ]
}
```
-
-
-### Serve Locally
+## Component Development Workflow
+### 1. Development
```bash
-ftl serve
-```
+# From component directory
+ftl build # Build the component
+ftl test # Run component tests
+ftl watch # Auto-rebuild on changes
-This will start a local development server with hot reloading. You can test your tool by sending it a JSON-RPC request:
-
-```bash
-curl -X POST http://localhost:3000/mcp \
- -H "Content-Type: application/json" \
- -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"my-tool","arguments":{"input":"test"}},"id":1}'
+# From project root (with spin.toml)
+ftl build # Build all components
+ftl up --port 3000 # Run the composed application
```
-Each FTL tool is a complete MCP server that exposes a single tool. When you deploy an individual tool, you're deploying a standalone MCP server. Toolkits (described below) bundle multiple tools together with a gateway that acts as a unified MCP server over multiple tools.
-
-### Deploy to FTL Edge
-
+### 2. Publishing
```bash
-ftl deploy
-```
-
-This will deploy your tool to FTL Edge, where it can be called by your AI agents.
+# Publish to GitHub Container Registry
+ftl publish --tag v1.0.0
-## Toolkits
+# Publish to Docker Hub
+ftl publish --registry docker.io --tag latest
+```
-FTL supports bundling multiple tools together as a toolkit, providing a powerful way to create comprehensive agent capabilities. Toolkits leverage the WebAssembly component model to enable secure, high-performance composition of tools.
+### 3. Composition
+```bash
+# Create a project composed of multiple components
+ftl init my-assistant
+cd my-assistant
-### Architecture
+# Add components with custom routes
+ftl add weather-tool --language typescript --route /weather
+ftl add github-tool --language rust --route /github
+ftl add calculator --language javascript --route /calc
-Each FTL tool is a self-contained WebAssembly component that implements its own MCP server exposing a single tool. Toolkits take this further by:
+# Each component gets its own MCP endpoint
+# /weather/mcp - Weather tool MCP endpoint
+# /github/mcp - GitHub tool MCP endpoint
+# /calc/mcp - Calculator MCP endpoint
-- **Component Composition**: Multiple WebAssembly components (tools) are bundled together using the component model.
-- **Automatic Gateway**: FTL generates a gateway component that acts as a logical MCP server over each tool in the toolkit. The hop between the gateway component and the tool component happens fast, in memory.
-- **Language Agnostic**: Each tool can be written in a different language (Rust, JavaScript, etc.), allowing you to mix languages within a single toolkit / MCP server.
-- **Fast Tool Chaining**: Tools within a toolkit can be chained directly via instant in-memory calls.
-- **Local Development**: Toolkits work seamlessly both locally and when deployed to the edge.
+# Run the composed project
+ftl watch # Development with auto-rebuild
+ftl up # Production mode
+```
-### How It Works
+### 4. Deployment
+```bash
+# Deploy to FTL
+ftl deploy
-```mermaid
-graph TD
- Client[AI agent] -->|tools/call| Gateway
- Gateway["Gateway component (/mcp)"]
- Gateway -->|/rs-tool/mcp| Rust["Rust tool server"]
- Gateway -->|/js-tool/mcp| JS["JavaScript tool server"]
- Gateway -->|/ts-tool/mcp| TS["TypeScript tool server"]
+# Or use Spin directly
+spin deploy
```
-The gateway component:
-- Exposes a Streamable HTTP /mcp endpoint that exposes an MCP server over all individual tool components
-- Routes `tools/call` requests to the appropriate tool component
-- Maintains protocol compatibility across all tools
-- The request is passed from the gateway component to the tool in memory without leaving the host process. This is fast.
+## Architecture
-### Create a Toolkit
+FTL leverages the WebAssembly component model and Spin platform:
-```bash
-# Build individual tools (can be different languages)
-ftl new rust-analyzer # Rust tool
-ftl new js-formatter # JavaScript tool
-ftl new ts-linter # TypeScript tool
-ftl new data-processor # Another Rust tool
-
-# Bundle them as a toolkit
-ftl toolkit build --name dev-toolkit rust-analyzer js-formatter ts-linter data-processor
```
-
-### Serve a Toolkit Locally
-
-```bash
-ftl toolkit serve dev-toolkit
+βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
+β MCP Client ββββββΆβ Spin Runtime ββββββΆβ MCP Component β
+β (AI Agent) β β (HTTP Router) β β (WASM Module) β
+βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
+ β
+ βββ /weather/mcp βββΆ Weather Component (TypeScript)
+ βββ /github/mcp βββΆ GitHub Component (Rust)
+ βββ /calc/mcp βββΆ Calculator Component (JavaScript)
```
-This starts a local server with:
-- `/mcp` - Unified endpoint that aggregates all tools
-- `/rust-analyzer/mcp` - Direct access to individual tool
-- `/js-formatter/mcp` - Direct access to individual tool
-- `/ts-linter/mcp` - Direct access to individual tool
-- `/data-processor/mcp` - Direct access to individual tool
+### Project Structure
-### Deploy a Toolkit
-
-```bash
-ftl toolkit deploy dev-toolkit
+```
+my-assistant/
+βββ spin.toml # Spin manifest (project root)
+βββ weather-tool/ # TypeScript component
+β βββ ftl.toml # Component metadata
+β βββ Makefile # Build automation
+β βββ handler/ # Component source
+β βββ package.json
+β βββ src/
+βββ github-tool/ # Rust component
+β βββ ftl.toml
+β βββ Makefile
+β βββ handler/
+β βββ Cargo.toml
+β βββ src/
+βββ calculator/ # JavaScript component
+ βββ ftl.toml
+ βββ Makefile
+ βββ handler/
```
-### Benefits
+Each component:
+- Is a standalone WebAssembly module
+- Implements the MCP protocol
+- Can be developed and tested independently
+- Can be composed with other components
+- Runs in a secure sandbox
-- **Single Integration Point**: AI agents connect to one MCP endpoint to access all tools
-- **Mixed Language Support**: Combine Rust tools for performance-critical operations with JavaScript/TypeScript tools for rapid development
-- **Component Isolation**: Each tool runs in its own sandboxed WebAssembly module
-- **Local-First Development**: Test complete toolkits locally before deployment
-- **Dynamic Composition**: Add or remove tools without changing agent configurations
+## Prerequisites
+
+- **Rust toolchain** (for FTL CLI)
+- **Language-specific requirements**:
+ - Rust: cargo with wasm32-wasip1 target (cargo-component auto-installed)
+ - TypeScript/JavaScript: Node.js 20+
+- **Optional**:
+ - wkg for publishing ([install](https://github.com/bytecodealliance/wasm-pkg-tools))
+ - cargo-binstall for faster tool installation
+- **Auto-installed**:
+ - Spin runtime (prompted on first use)
+ - cargo-component (for Rust components)
## Documentation
-For more detailed documentation, please see the [docs](./docs/introduction.md) directory in this repository.
+- [Getting Started Guide](./docs/introduction.md)
+- [CLI Reference](./docs/cli-reference.md)
+- [Component Development](./docs/components.md)
+- [Publishing Components](./docs/publishing.md)
+- [Project Composition](./docs/composition.md)
+- [SDK Reference](./docs/sdk-reference.md)
## Development
### Running CI Checks Locally
-This project uses [just](https://github.com/casey/just) for task automation. Install it with:
+This project uses [just](https://github.com/casey/just) for task automation:
```bash
-cargo install-just
-# or install all dev tools:
-cargo install-dev-tools
-```
+# Install just
+cargo install just
-Then you can run:
-
-```bash
-# Run all CI checks (same as CI)
+# Run all CI checks
just ci
-# Individual checks
-just fmt-check # Check formatting
-just lint # Run clippy with CI settings
-just test-all # Run all tests
-
-# Development helpers
-just fix # Fix formatting and clippy warnings
-just fix-fmt # Auto-fix formatting only
-just fix-clippy # Auto-fix clippy warnings only
-
-# Quick checks
-just dev # Format and lint (quick check)
-just pre-push # Fix and test before pushing
-
-# Other tasks
-just build-wasm # Build the SDK for WASM target
-just spin-install # Install FTL-managed Spin
+# Development workflow
+just dev # Format and lint
+just test-all # Run all tests
+just pre-push # Full check before pushing
-# See all available commands
+# See all commands
just --list
```
## Contributing
-Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) for more information.
+Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
## License
-This project is licensed under the Apache-2.0 License.
+Apache-2.0
\ No newline at end of file
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..f3daccfb
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,103 @@
+# FTL Documentation
+
+Welcome to the FTL documentation! This directory contains comprehensive guides for building and deploying MCP servers with FTL.
+
+## π Documentation Structure
+
+### Getting Started
+- **[Introduction](./introduction.md)** - Overview of FTL and core concepts
+- **[Quick Start Guide](./quickstart.md)** - Get up and running in 5 minutes
+- **[CLI Reference](./cli-reference.md)** - Complete command documentation
+
+### Development Guides
+- **[Component Development](./components.md)** - Deep dive into building MCP components
+- **[SDK Reference](./sdk-reference.md)** - TypeScript/JavaScript and Rust SDK APIs
+- **[Publishing Components](./publishing.md)** - Share components via OCI registries
+
+### Deployment & Operations
+- **[Deployment Guide](./deployment.md)** - Deploy to various environments
+- **[Project Composition](./composition.md)** - Combine multiple components
+
+### Additional Resources
+- **[API Reference](./api.md)** - MCP protocol implementation details
+- **[Troubleshooting](./troubleshooting.md)** - Common issues and solutions
+
+## π Quick Links
+
+### For Beginners
+1. Start with the [Introduction](./introduction.md) to understand FTL
+2. Follow the [Quick Start Guide](./quickstart.md) to build your first component
+3. Explore the [CLI Reference](./cli-reference.md) for available commands
+
+### For Component Developers
+1. Read [Component Development](./components.md) for best practices
+2. Reference the [SDK documentation](./sdk-reference.md) for your language
+3. Learn to [publish components](./publishing.md) for sharing
+
+### For DevOps/Deployment
+1. Check the [Deployment Guide](./deployment.md) for platform options
+2. Learn about [monitoring and operations](./deployment.md#monitoring--logging)
+3. Review [security best practices](./deployment.md#security-best-practices)
+
+## π Documentation Conventions
+
+### Code Examples
+
+We provide examples in multiple languages:
+
+**TypeScript:**
+```typescript
+import { createTool } from '@fastertools/ftl-sdk';
+```
+
+**Rust:**
+```rust
+use ftl_sdk::*;
+```
+
+**Bash:**
+```bash
+ftl init my-project
+```
+
+### Icons Used
+
+- π Documentation/Learning
+- π Getting Started/Quick Actions
+- π‘ Tips and Best Practices
+- β οΈ Warnings and Important Notes
+- π§ Configuration and Setup
+- π Security-related Information
+
+## π€ Contributing
+
+We welcome contributions to improve our documentation!
+
+### How to Contribute
+
+1. **Find an issue**: Check for documentation issues or create one
+2. **Make changes**: Edit the relevant `.md` files
+3. **Test examples**: Ensure code examples work correctly
+4. **Submit PR**: Create a pull request with your changes
+
+### Documentation Style Guide
+
+- Use clear, concise language
+- Include practical examples
+- Provide both TypeScript and Rust examples where applicable
+- Test all code examples
+- Keep formatting consistent
+
+## π Getting Help
+
+- **GitHub Issues**: Report bugs or request features
+- **Discussions**: Ask questions and share ideas
+- **Discord**: Join our community for real-time help
+
+## π Version Compatibility
+
+This documentation is for FTL version 0.0.18 and above. For older versions, check the version tags in the repository.
+
+---
+
+Happy building with FTL! π
\ No newline at end of file
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index 3c4ff5af..113073f6 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -1,140 +1,285 @@
# CLI Reference
-The `ftl` command-line interface is the primary entry point for developers using the FTL platform. It provides a number of commands for creating, testing, and deploying tools.
+The `ftl` command-line interface provides commands for creating, building, testing, and deploying MCP components.
## Global Options
-- `-v, --verbose`: Increase logging verbosity.
+- `-v, --verbose`: Increase logging verbosity (can be used multiple times)
+- `--help`: Show help information
+- `--version`: Show version information
-## `ftl new`
+## Project & Component Commands
-Create a new tool from a template.
+### `ftl init`
+
+Create a new MCP project for composing components.
```bash
-ftl new [OPTIONS]
+ftl init [name] [OPTIONS]
```
-### Arguments
+**Arguments:**
+- `[name]`: Project name (optional, will prompt if not provided)
+
+**Options:**
+- `--here`: Initialize in current directory
-- ``: The name of the tool.
+**Examples:**
+```bash
+# Create a new project
+ftl init my-assistant
-### Options
+# Initialize in current directory
+ftl init my-project --here
-- `-d, --description `: The description of the tool.
+# Interactive mode
+ftl init
+```
-## `ftl build`
+### `ftl add`
-Build a tool.
+Add a new MCP component to the current project.
```bash
-ftl build [name] [OPTIONS]
+ftl add [name] [OPTIONS]
```
-### Arguments
+**Arguments:**
+- `[name]`: Component name (optional, will prompt if not provided)
-- `[name]`: The name of the tool to build (defaults to the current directory).
+**Options:**
+- `-l, --language `: Language to use (`rust`, `typescript`, `javascript`)
+- `-d, --description `: Component description
+- `-r, --route `: HTTP route for the component (default: `/[name]`)
+- `--git `: Use a Git repository as the template source
+- `--branch `: Git branch to use (requires `--git`)
+- `--dir `: Use a local directory as the template source
+- `--tar `: Use a tarball as the template source
-### Options
+**Examples:**
+```bash
+# Add a component with specified language
+ftl add weather-api --language typescript --description "Weather data for AI agents"
+
+# Add a component with custom route
+ftl add calculator --language rust --route /calc
-- `-p, --profile `: The build profile to use (`dev`, `release`, or `tiny`).
-- `-s, --serve`: Start a local development server after the build completes.
+# Using a custom Git template
+ftl add my-component --git https://github.com/user/template --branch main
+
+# Interactive mode
+ftl add
+```
-## `ftl serve`
+### `ftl build`
-Serve a tool locally.
+Build the component or project in the current directory.
```bash
-ftl serve [name] [OPTIONS]
+ftl build [OPTIONS]
```
-### Arguments
+**Options:**
+- `-r, --release`: Build in release mode
+- `-p, --path `: Path to component or project directory (default: current)
-- `[name]`: The name of the tool to serve (defaults to the current directory).
+**Examples:**
+```bash
+# Build entire project (from project root with spin.toml)
+ftl build
-### Options
+# Build specific component
+cd math-tools
+ftl build --release
-- `-p, --port `: The port to serve on (defaults to 3000).
-- `-b, --build`: Build the tool before serving.
+# Build specific component from project root
+ftl build --path math-tools
+```
-## `ftl test`
+### `ftl up`
-Run tests for a tool.
+Run the component locally for development.
```bash
-ftl test [name]
+ftl up [OPTIONS]
```
-### Arguments
+**Options:**
+- `--build`: Build before running
+- `-p, --port `: Port to serve on (default: 3000)
+- `--path `: Path to component directory
-- `[name]`: The name of the tool to test (defaults to the current directory).
+**Example:**
+```bash
+ftl up --port 8080
+```
-## `ftl deploy`
+### `ftl watch`
-Deploy a tool to the FTL Edge.
+Build and run the component, automatically rebuilding when files change.
```bash
-ftl deploy [name]
+ftl watch [OPTIONS]
```
-### Arguments
+**Options:**
+- `-p, --port `: Port to serve on (default: 3000)
+- `--path `: Path to component directory
-- `[name]`: The name of the tool to deploy (defaults to the current directory).
+**Example:**
+```bash
+ftl watch --port 8080
+```
-## `ftl toolkit`
+### `ftl test`
-Manage toolkits (collections of tools).
+Run component tests.
-### `ftl toolkit build`
+```bash
+ftl test [OPTIONS]
+```
-Build a toolkit from multiple tools. This command:
-- Builds each specified tool in release mode
-- Bundles all tool WebAssembly modules together
-- Automatically generates a gateway component that provides a unified MCP endpoint
-- Creates a deployable toolkit directory with all components
+**Options:**
+- `-p, --path `: Path to component directory
+**Example:**
```bash
-ftl toolkit build --name
+ftl test
```
-#### Options
+### `ftl publish`
+
+Publish component to an OCI registry.
+
+```bash
+ftl publish [OPTIONS]
+```
-- `--name `: The name of the toolkit.
+**Options:**
+- `-r, --registry `: Registry URL (default: ghcr.io)
+- `-t, --tag `: Version tag to publish
+- `--path `: Path to component directory
-#### Arguments
+**Example:**
+```bash
+ftl publish --tag v1.0.0
+ftl publish --registry docker.io --tag latest
+```
-- ``: The tools to include in the toolkit. Each tool must exist as a directory in the current working directory.
-### `ftl toolkit serve`
-Serve a toolkit locally. This starts a development server with:
-- `/mcp` - Unified MCP endpoint that aggregates all tools in the toolkit
-- `//mcp` - Individual endpoints for each tool (e.g., `/tool1/mcp`, `/tool2/mcp`)
+### `ftl deploy`
-The gateway endpoint supports all standard MCP operations:
-- `initialize` - Initialize the connection
-- `tools/list` - List all available tools across the toolkit
-- `tools/call` - Call any tool in the toolkit
+Deploy the project to FTL.
```bash
-ftl toolkit serve [OPTIONS]
+ftl deploy [OPTIONS]
```
-#### Arguments
+**Options:**
+- `-e, --environment `: Target environment
-- ``: The name of the toolkit directory.
+**Example:**
+```bash
+ftl deploy --environment production
+```
+
+## Configuration Commands
-#### Options
+### `ftl setup templates`
-- `-p, --port `: The port to serve on (defaults to 3000).
+Install or update FTL component templates.
-### `ftl toolkit deploy`
+```bash
+ftl setup templates [OPTIONS]
+```
-Deploy a toolkit to the FTL Edge.
+**Options:**
+- `--force`: Force reinstall even if already installed
+- `--git `: Install templates from a Git repository
+- `--branch `: Git branch to use (requires `--git`)
+- `--dir `: Install templates from a local directory
+- `--tar `: Install templates from a tarball
+**Examples:**
```bash
-ftl toolkit deploy
+# Install default FTL templates
+ftl setup templates
+
+# Install templates from a Git repository
+ftl setup templates --git https://github.com/user/ftl-templates --branch main
+
+# Install templates from a local directory
+ftl setup templates --dir ./my-templates
+
+# Install templates from a tarball
+ftl setup templates --tar ./templates.tar.gz
+
+# Force reinstall templates
+ftl setup templates --force
+```
+
+### `ftl setup info`
+
+Show FTL configuration and status.
+
+```bash
+ftl setup info
```
-#### Arguments
+Displays:
+- FTL CLI version
+- Spin installation status
+- Template installation status
+- cargo-component installation status
+- wkg availability
+
+## Registry Commands
+
+### `ftl registry list`
+
+List available components (coming soon).
+
+```bash
+ftl registry list [OPTIONS]
+```
+
+**Options:**
+- `-r, --registry `: Registry to list from
+
+### `ftl registry search`
+
+Search for components (coming soon).
+
+```bash
+ftl registry search [OPTIONS]
+```
+
+**Arguments:**
+- ``: Search query
+
+**Options:**
+- `-r, --registry `: Registry to search in
+
+### `ftl registry info`
+
+Show component details (coming soon).
+
+```bash
+ftl registry info
+```
+
+**Arguments:**
+- ``: Component name or URL
+
+## Environment Variables
+
+- `FTL_AUTO_INSTALL`: Set to `true` to auto-install Spin without prompting
+- `RUST_LOG`: Control logging verbosity (e.g., `info`, `debug`, `trace`)
+
+## Exit Codes
-- ``: The name of the toolkit.
+- `0`: Success
+- `1`: General error
+- `2`: Invalid arguments
+- `127`: Command not found
\ No newline at end of file
diff --git a/docs/components.md b/docs/components.md
new file mode 100644
index 00000000..251413a5
--- /dev/null
+++ b/docs/components.md
@@ -0,0 +1,509 @@
+# Component Development
+
+This guide covers everything you need to know about developing MCP components with FTL.
+
+## Component Structure
+
+Every FTL component follows a consistent structure:
+
+```
+my-component/
+βββ ftl.toml # Component metadata
+βββ Makefile # Build automation
+βββ handler/ # Component source code
+β βββ [package.json | Cargo.toml] # Language-specific manifest
+β βββ src/ # Source files
+β βββ test/ # Test files
+βββ [.wit/] # WebAssembly Interface Types (generated)
+```
+
+### ftl.toml
+
+The component metadata file:
+
+```toml
+name = "my-component"
+version = "0.1.0"
+description = "My awesome MCP component"
+route = "/my-component"
+```
+
+### Makefile
+
+Standard targets for all components:
+- `make build` - Build the component
+- `make test` - Run tests
+- `make clean` - Clean build artifacts
+
+## Language-Specific Development
+
+### TypeScript Components
+
+#### Project Setup
+
+```bash
+ftl add my-tool --language typescript
+cd my-tool/handler
+```
+
+#### Implementation
+
+```typescript
+// src/features.ts
+import { createTool, createResource, createPrompt } from '@fastertools/ftl-sdk';
+
+// Define tools
+export const tools = [
+ createTool({
+ name: 'get_weather',
+ description: 'Get weather for a location',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ location: { type: 'string', description: 'City name' },
+ units: { type: 'string', enum: ['celsius', 'fahrenheit'] }
+ },
+ required: ['location']
+ },
+ execute: async (args) => {
+ // Implementation
+ return `Weather in ${args.location}: 72Β°F`;
+ }
+ })
+];
+
+// Define resources
+export const resources = [
+ createResource({
+ uri: 'weather://current',
+ name: 'Current Weather Data',
+ description: 'Real-time weather information',
+ mimeType: 'application/json',
+ read: async () => {
+ return JSON.stringify({ temp: 72, conditions: 'sunny' });
+ }
+ })
+];
+
+// Define prompts
+export const prompts = [
+ createPrompt({
+ name: 'weather_report',
+ description: 'Generate a weather report',
+ arguments: [
+ { name: 'location', description: 'Location for weather', required: true }
+ ],
+ resolve: async (args) => {
+ return [
+ { role: 'user', content: `What's the weather in ${args.location}?` },
+ { role: 'assistant', content: `I'll check the weather for ${args.location}.` }
+ ];
+ }
+ })
+];
+```
+
+#### Testing
+
+```typescript
+// test/weather.test.ts
+import { describe, it, expect } from 'vitest';
+import { tools } from '../src/features';
+
+describe('Weather Tool', () => {
+ it('should return weather data', async () => {
+ const weatherTool = tools.find(t => t.name === 'get_weather');
+ const result = await weatherTool?.execute({
+ location: 'San Francisco'
+ });
+ expect(result).toContain('San Francisco');
+ });
+});
+```
+
+### Rust Components
+
+#### Project Setup
+
+```bash
+ftl add my-tool --language rust
+cd my-tool/handler
+```
+
+#### Implementation
+
+```rust
+// src/lib.rs
+use ftl_sdk::*;
+use serde::{Deserialize, Serialize};
+
+// Define handler
+create_handler!(
+ tools: get_tools,
+ resources: get_resources,
+ prompts: get_prompts
+);
+
+// Tool implementation
+#[derive(Deserialize)]
+struct WeatherArgs {
+ location: String,
+ units: Option,
+}
+
+fn get_weather(args: WeatherArgs) -> Result {
+ Ok(format!("Weather in {}: 72Β°F", args.location))
+}
+
+// Export tools
+fn get_tools() -> Vec {
+ vec![
+ tool!(
+ "get_weather",
+ "Get weather for a location",
+ json!({
+ "type": "object",
+ "properties": {
+ "location": { "type": "string" },
+ "units": { "type": "string" }
+ },
+ "required": ["location"]
+ }),
+ get_weather
+ )
+ ]
+}
+
+// Export resources
+fn get_resources() -> Vec {
+ vec![
+ resource!(
+ "weather://current",
+ "Current Weather Data",
+ "application/json",
+ || Ok(r#"{"temp": 72, "conditions": "sunny"}"#.to_string())
+ )
+ ]
+}
+
+// Export prompts
+fn get_prompts() -> Vec {
+ vec![]
+}
+```
+
+#### Testing
+
+```rust
+// src/lib.rs (test module)
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_weather_tool() {
+ let args = WeatherArgs {
+ location: "San Francisco".to_string(),
+ units: None,
+ };
+ let result = get_weather(args).unwrap();
+ assert!(result.contains("San Francisco"));
+ }
+}
+```
+
+### JavaScript Components
+
+JavaScript components follow the same pattern as TypeScript but without type annotations:
+
+```javascript
+// src/features.js
+import { createTool } from '@fastertools/ftl-sdk';
+
+export const tools = [
+ createTool({
+ name: 'calculate',
+ description: 'Perform calculations',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ expression: { type: 'string' }
+ },
+ required: ['expression']
+ },
+ execute: async (args) => {
+ // Simple example - in production use a safe parser
+ try {
+ const result = eval(args.expression);
+ return `Result: ${result}`;
+ } catch (error) {
+ return `Error: Invalid expression`;
+ }
+ }
+ })
+];
+```
+
+## Component Dependencies
+
+### Rust Components
+
+Add dependencies to `handler/Cargo.toml`:
+
+```toml
+[dependencies]
+ftl-sdk = "0.2.0"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+reqwest = { version = "0.11", features = ["json"] }
+tokio = { version = "1", features = ["full"] }
+```
+
+Note: cargo-component is automatically installed when building Rust components.
+
+### TypeScript/JavaScript Components
+
+Add dependencies to `handler/package.json`:
+
+```json
+{
+ "dependencies": {
+ "@fastertools/ftl-sdk": "^0.1.0",
+ "axios": "^1.6.0",
+ "dotenv": "^16.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "typescript": "^5.0.0",
+ "vitest": "^1.0.0"
+ }
+}
+```
+
+## Build Process
+
+### Development Builds
+
+```bash
+# Build a single component
+cd my-component
+make build
+
+# Build all components (from project root)
+ftl build
+```
+
+### Production Builds
+
+```bash
+# Build with optimizations
+ftl build --release
+```
+
+### Automatic Rebuilds
+
+```bash
+# Watch for changes and rebuild
+ftl watch
+```
+
+## Testing
+
+### Unit Tests
+
+Each language has its own test runner:
+
+```bash
+# Run tests for a component
+cd my-component
+make test
+
+# Run all tests (from project root)
+ftl test
+```
+
+### Integration Testing
+
+Test your component with MCP clients:
+
+```javascript
+// test-client.js
+import { Client } from '@modelcontextprotocol/sdk';
+
+const client = new Client({
+ url: 'http://localhost:3000/my-component/mcp'
+});
+
+// List tools
+const tools = await client.listTools();
+console.log(tools);
+
+// Call a tool
+const result = await client.callTool('my_tool', {
+ input: 'test data'
+});
+console.log(result);
+```
+
+## Best Practices
+
+### 1. Error Handling
+
+Always handle errors gracefully:
+
+```typescript
+execute: async (args) => {
+ try {
+ const result = await someOperation(args);
+ return JSON.stringify(result);
+ } catch (error) {
+ return JSON.stringify({
+ error: error.message,
+ code: 'OPERATION_FAILED'
+ });
+ }
+}
+```
+
+### 2. Input Validation
+
+Use JSON Schema for comprehensive validation:
+
+```typescript
+inputSchema: {
+ type: 'object',
+ properties: {
+ email: {
+ type: 'string',
+ format: 'email',
+ description: 'User email address'
+ },
+ age: {
+ type: 'integer',
+ minimum: 0,
+ maximum: 150
+ }
+ },
+ required: ['email']
+}
+```
+
+### 3. Async Operations
+
+Handle async operations properly:
+
+```rust
+async fn fetch_data(url: String) -> Result> {
+ let response = reqwest::get(&url).await?;
+ let body = response.text().await?;
+ Ok(body)
+}
+```
+
+### 4. Resource Management
+
+Clean up resources properly:
+
+```typescript
+let connection;
+try {
+ connection = await createConnection();
+ return await connection.query(args.query);
+} finally {
+ if (connection) {
+ await connection.close();
+ }
+}
+```
+
+### 5. Documentation
+
+Document your tools thoroughly:
+
+```typescript
+createTool({
+ name: 'analyze_data',
+ description: 'Analyze data using various statistical methods. ' +
+ 'Supports CSV, JSON, and Excel formats. ' +
+ 'Returns summary statistics and visualizations.',
+ // ...
+})
+```
+
+## Advanced Topics
+
+### WebAssembly Interface Types
+
+FTL generates WIT files for language interop:
+
+```wit
+// Generated .wit/mcp.wit
+interface mcp-handler {
+ record tool {
+ name: string,
+ description: string,
+ input-schema: string,
+ }
+
+ list-tools: func() -> list
+ call-tool: func(name: string, args: string) -> result
+}
+```
+
+### Component Composition
+
+Combine multiple components in `spin.toml`:
+
+```toml
+[[component]]
+id = "weather"
+route = "/weather/..."
+source = "weather-tool/handler/target/wasm32-wasip1/release/handler.wasm"
+
+[[component]]
+id = "news"
+route = "/news/..."
+source = "news-tool/handler/dist/handler.wasm"
+```
+
+### Performance Optimization
+
+1. **Minimize dependencies**: Only include what you need
+2. **Use streaming**: For large responses, consider streaming
+3. **Cache results**: Implement caching for expensive operations
+4. **Profile your code**: Use language-specific profiling tools
+
+## Troubleshooting
+
+### Build Errors
+
+```bash
+# Clean and rebuild
+make clean
+make build
+
+# Check for missing dependencies
+npm install # for JS/TS
+cargo check # for Rust
+```
+
+### Runtime Errors
+
+Check the Spin logs:
+```bash
+ftl up --follow
+```
+
+### Test Failures
+
+Run tests with verbose output:
+```bash
+npm test -- --reporter=verbose # JS/TS
+cargo test -- --nocapture # Rust
+```
+
+## Next Steps
+
+- [Publishing Components](./publishing.md) - Share your components
+- [SDK Reference](./sdk-reference.md) - Detailed API documentation
+- [Deployment Guide](./deployment.md) - Deploy to production
\ No newline at end of file
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 00000000..6262e77a
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,466 @@
+# Deployment Guide
+
+This guide covers deploying FTL projects to various environments.
+
+## Deployment Options
+
+FTL projects can be deployed anywhere Spin runs:
+
+1. **Fermyon Cloud** - Managed Spin platform
+2. **Kubernetes** - Using Spin Operator
+3. **Self-hosted** - On your own infrastructure
+4. **Edge platforms** - Cloudflare, Fastly, etc.
+
+## Local Development
+
+### Development Server
+
+```bash
+# Start with auto-rebuild
+ftl watch
+
+# Start on specific port
+ftl up --port 8080
+
+# Build before running
+ftl up --build
+```
+
+### Production Mode
+
+```bash
+# Build with optimizations
+ftl build --release
+
+# Run in production mode
+ftl up --port 80
+```
+
+## Fermyon Cloud Deployment
+
+### Prerequisites
+
+1. Install Spin CLI:
+ ```bash
+ curl -fsSL https://developer.fermyon.com/downloads/install.sh | bash
+ ```
+
+2. Login to Fermyon Cloud:
+ ```bash
+ spin login
+ ```
+
+### Deploy
+
+```bash
+# From project root (with spin.toml)
+spin deploy
+
+# Custom app name
+spin deploy --app-name my-mcp-server
+
+# Deploy to specific environment
+spin deploy --environment production
+```
+
+### Environment Variables
+
+Set environment variables:
+
+```bash
+spin cloud variables set API_KEY="your-key"
+spin cloud variables set DATABASE_URL="postgres://..."
+```
+
+Or use `.env` file:
+
+```bash
+spin deploy --variables-file .env.production
+```
+
+### Custom Domains
+
+```bash
+# Add custom domain
+spin cloud domain add api.example.com
+
+# List domains
+spin cloud domain list
+```
+
+## Kubernetes Deployment
+
+### Using Spin Operator
+
+1. Install Spin Operator:
+ ```bash
+ kubectl apply -f https://github.com/fermyon/spin-operator/releases/download/v0.1.0/spin-operator.yaml
+ ```
+
+2. Create SpinApp resource:
+ ```yaml
+ # spinapp.yaml
+ apiVersion: core.spinoperator.dev/v1alpha1
+ kind: SpinApp
+ metadata:
+ name: my-mcp-server
+ spec:
+ image: "ghcr.io/username/my-mcp-server:latest"
+ replicas: 3
+ variables:
+ - name: API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: api-secrets
+ key: api-key
+ ```
+
+3. Deploy:
+ ```bash
+ kubectl apply -f spinapp.yaml
+ ```
+
+### Using Docker
+
+Build Docker image with Spin:
+
+```dockerfile
+# Dockerfile
+FROM scratch
+COPY spin.toml .
+COPY weather-tool/handler/target/wasm32-wasip1/release/handler.wasm ./weather-tool/handler.wasm
+COPY github-tool/handler/target/wasm32-wasip1/release/handler.wasm ./github-tool/handler.wasm
+```
+
+```bash
+# Build and push
+docker build -t myregistry/my-mcp-server:latest .
+docker push myregistry/my-mcp-server:latest
+```
+
+## Self-Hosted Deployment
+
+### Systemd Service
+
+Create service file:
+
+```ini
+# /etc/systemd/system/mcp-server.service
+[Unit]
+Description=MCP Server
+After=network.target
+
+[Service]
+Type=simple
+User=www-data
+WorkingDirectory=/opt/mcp-server
+ExecStart=/usr/local/bin/spin up --port 3000
+Restart=always
+Environment="RUST_LOG=info"
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Enable and start:
+
+```bash
+sudo systemctl enable mcp-server
+sudo systemctl start mcp-server
+```
+
+### Docker Compose
+
+```yaml
+# docker-compose.yml
+version: '3.8'
+
+services:
+ mcp-server:
+ image: ghcr.io/fermyon/spin:latest
+ command: up --from ghcr.io/username/my-mcp-server:latest
+ ports:
+ - "3000:3000"
+ environment:
+ - API_KEY=${API_KEY}
+ restart: unless-stopped
+```
+
+### Reverse Proxy
+
+Nginx configuration:
+
+```nginx
+server {
+ listen 80;
+ server_name api.example.com;
+
+ location / {
+ proxy_pass http://localhost:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ }
+}
+```
+
+## Environment Configuration
+
+### Using spin.toml
+
+```toml
+# spin.toml
+[variables]
+api_endpoint = { default = "https://api.example.com" }
+log_level = { default = "info", secret = false }
+api_key = { required = true, secret = true }
+
+[[component]]
+id = "weather"
+environment = {
+ API_ENDPOINT = "{{ api_endpoint }}",
+ LOG_LEVEL = "{{ log_level }}",
+ API_KEY = "{{ api_key }}"
+}
+```
+
+### Runtime Variables
+
+Set at deployment:
+
+```bash
+# Fermyon Cloud
+spin deploy --variable api_key="secret-key"
+
+# Self-hosted
+API_KEY="secret-key" spin up
+```
+
+## Monitoring & Logging
+
+### Application Logs
+
+```bash
+# Fermyon Cloud
+spin cloud logs --follow
+
+# Self-hosted with systemd
+journalctl -u mcp-server -f
+
+# Docker
+docker logs -f mcp-server
+```
+
+### Health Checks
+
+Add health endpoint:
+
+```typescript
+// TypeScript component
+export const tools = [
+ createTool({
+ name: '_health',
+ description: 'Health check endpoint',
+ inputSchema: { type: 'object' },
+ execute: async () => {
+ return JSON.stringify({
+ status: 'healthy',
+ timestamp: new Date().toISOString()
+ });
+ }
+ })
+];
+```
+
+### Metrics
+
+Use OpenTelemetry:
+
+```rust
+// Rust component
+use opentelemetry::{global, metrics::*};
+
+let meter = global::meter("mcp-component");
+let counter = meter
+ .u64_counter("requests_total")
+ .with_description("Total requests")
+ .init();
+
+counter.add(1, &[KeyValue::new("method", "tool_call")]);
+```
+
+## Security Best Practices
+
+### 1. HTTPS/TLS
+
+Always use HTTPS in production:
+
+```nginx
+server {
+ listen 443 ssl http2;
+ ssl_certificate /etc/ssl/certs/cert.pem;
+ ssl_certificate_key /etc/ssl/private/key.pem;
+ # ... rest of config
+}
+```
+
+### 2. Authentication
+
+Implement authentication for MCP endpoints:
+
+```typescript
+execute: async (args, context) => {
+ const authHeader = context.headers['authorization'];
+ if (!isValidAuth(authHeader)) {
+ throw new Error('Unauthorized');
+ }
+ // ... tool logic
+}
+```
+
+### 3. Rate Limiting
+
+Protect against abuse:
+
+```typescript
+const rateLimiter = new Map();
+
+execute: async (args, context) => {
+ const clientId = context.clientId;
+ if (isRateLimited(clientId)) {
+ throw new Error('Rate limit exceeded');
+ }
+ // ... tool logic
+}
+```
+
+### 4. Secrets Management
+
+Never hardcode secrets:
+
+```bash
+# Use environment variables
+export API_KEY="secret-key"
+
+# Use secret management services
+vault kv get secret/mcp/api-key
+
+# Use Kubernetes secrets
+kubectl create secret generic api-secrets --from-literal=api-key=secret-key
+```
+
+## Scaling
+
+### Horizontal Scaling
+
+```yaml
+# Kubernetes
+spec:
+ replicas: 5
+
+# Docker Swarm
+docker service scale mcp-server=5
+
+# Fermyon Cloud
+spin cloud app scale 5
+```
+
+### Load Balancing
+
+```nginx
+upstream mcp_servers {
+ server 127.0.0.1:3001;
+ server 127.0.0.1:3002;
+ server 127.0.0.1:3003;
+}
+
+server {
+ location / {
+ proxy_pass http://mcp_servers;
+ }
+}
+```
+
+## CI/CD Integration
+
+### GitHub Actions
+
+```yaml
+name: Deploy
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install FTL
+ run: cargo install ftl-cli
+
+ - name: Build
+ run: ftl build --release
+
+ - name: Deploy to Fermyon Cloud
+ env:
+ SPIN_AUTH_TOKEN: ${{ secrets.SPIN_AUTH_TOKEN }}
+ run: spin deploy --app-name production-mcp
+```
+
+### GitLab CI
+
+```yaml
+deploy:
+ stage: deploy
+ script:
+ - cargo install ftl-cli
+ - ftl build --release
+ - spin deploy
+ only:
+ - main
+```
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Port already in use**
+ ```bash
+ # Find process using port
+ lsof -i :3000
+ # Kill process
+ kill -9
+ ```
+
+2. **Component crashes**
+ - Check logs for errors
+ - Verify environment variables
+ - Test locally first
+
+3. **Performance issues**
+ - Monitor resource usage
+ - Optimize component code
+ - Scale horizontally
+
+### Debug Mode
+
+Enable debug logging:
+
+```bash
+# Local
+RUST_LOG=debug ftl up
+
+# Fermyon Cloud
+spin cloud variables set RUST_LOG=debug
+```
+
+## Next Steps
+
+- [Monitoring Guide](./monitoring.md) - Set up observability
+- [Security Guide](./security.md) - Secure your deployment
+- [Performance Guide](./performance.md) - Optimize for production
\ No newline at end of file
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 1299677b..57bf5391 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,87 +1,176 @@
# Getting Started
-This guide will walk you through the process of creating, building, and deploying your first FTL tool.
+This guide will walk you through creating, building, and deploying your first MCP component with FTL.
## Prerequisites
-- [Rust](https://www.rust-lang.org/tools/install)
-- [Docker](https://docs.docker.com/get-docker/) (for the local development server)
+- [Rust](https://www.rust-lang.org/tools/install) (for the FTL CLI)
+- Language-specific requirements:
+ - **Rust**: cargo with wasm32-wasip1 target
+ - **TypeScript/JavaScript**: Node.js 20+
+- [wkg](https://github.com/bytecodealliance/wasm-pkg-tools) (for publishing)
## 1. Install the FTL CLI
-The FTL command-line interface is the primary entry point for developers using the FTL platform. You can install it using `cargo`:
+```bash
+cargo install ftl-cli
+```
+
+## 2. Create a New Project
+
+Start by creating a new MCP project:
```bash
-cargo install ftl
+ftl init my-assistant
+cd my-assistant
```
-## 2. Create a New Tool
+This creates an empty Spin project ready for adding components.
-The `ftl new` command will create a new directory with a simple `ftl.toml` manifest, a `Cargo.toml` file, and a `src/lib.rs` file with a boilerplate tool implementation.
+## 3. Add Your First Component
+
+Now add a component to your project:
```bash
-ftl new my-tool --description "A new tool for my agent"
-cd my-tool
+ftl add weather-tool --language typescript --description "Weather information for AI agents"
```
-## 3. Implement Your Tool
+This creates:
+- `weather-tool/` - Component directory
+- `weather-tool/ftl.toml` - Component configuration
+- `weather-tool/Makefile` - Build automation
+- `weather-tool/src/` - Component source code
+- Updates `spin.toml` to include the component
-Open `src/lib.rs` in your favorite editor and implement the `ftl_sdk_rs::Tool` trait. This trait defines the name, description, input schema, and `call` method for your tool.
+## 4. Implement Your Component
-```rust
-use ftl_sdk_rs::prelude::*;
-
-#[derive(Clone)]
-struct MyTool;
-
-impl Tool for MyTool {
- fn name(&self) -> &'static str { "my-tool" }
- fn description(&self) -> &'static str { "My tool description" }
-
- fn input_schema(&self) -> serde_json::Value {
- json!({
- "type": "object",
- "properties": {
- "input": {"type": "string"}
+Edit the component implementation in `weather-tool/src/`:
+
+### TypeScript Example
+
+```typescript
+// weather-tool/src/features.ts
+import { createTool } from '@fastertools/ftl-sdk';
+
+export const tools = [
+ createTool({
+ name: 'get_weather',
+ description: 'Get current weather for a location',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ location: { type: 'string', description: 'City name' }
},
- "required": ["input"]
- })
- }
-
- fn call(&self, args: &serde_json::Value) -> Result {
- let input = args["input"].as_str()
- .ok_or(ToolError::InvalidArguments("input required".into()))?;
-
- Ok(ToolResult::text(format!("Processed: {}", input)))
- }
+ required: ['location']
+ },
+ async execute(args) {
+ return `The weather in ${args.location} is sunny and 72Β°F`;
+ }
+ })
+];
+
+export const resources = [];
+export const prompts = [];
+```
+
+### Rust Example
+
+```rust
+// weather-tool/src/features.rs
+use ftl_sdk::*;
+
+pub fn get_tools() -> Vec {
+ vec![
+ create_tool(
+ "get_weather",
+ "Get current weather for a location",
+ json!({
+ "type": "object",
+ "properties": {
+ "location": { "type": "string", "description": "City name" }
+ },
+ "required": ["location"]
+ }),
+ |args| {
+ let location = args["location"].as_str().unwrap_or("unknown");
+ Ok(format!("The weather in {} is sunny and 72Β°F", location))
+ }
+ )
+ ]
}
+```
+
+## 5. Build Your Components
-ftl_sdk_rs::ftl_mcp_server!(MyTool);
+```bash
+ftl build
```
-## 4. Serve Locally
+This compiles all components in your project into optimized WebAssembly modules.
+
+## 6. Test Locally
-The `ftl serve` command will start a local development server with hot reloading. This allows you to test your tool without having to deploy it to the FTL Edge.
+Run your project locally with automatic rebuilds:
```bash
-ftl serve
+ftl watch
```
-You can test your tool by sending it a JSON-RPC request:
+Test it with a curl request:
```bash
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
- -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"my-tool","arguments":{"input":"test"}},"id":1}'
+ -d '{
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {
+ "name": "get_weather",
+ "arguments": {"location": "San Francisco"}
+ },
+ "id": 1
+ }'
+```
+
+## 7. Publish Your Components
+
+Publish components to a container registry:
+
+```bash
+# Publish to GitHub Container Registry (default)
+ftl publish --tag v1.0.0
+
+# Or publish to Docker Hub
+ftl publish --registry docker.io --tag latest
+```
+
+Your components are now available at:
+- `ghcr.io/[username]/weather-tool:v1.0.0`
+
+## 8. Add More Components
+
+Add additional components to your project:
+
+```bash
+# Add more components
+ftl add news-tool --language typescript
+ftl add calculator --language rust
+
+# Run the project with all components
+ftl watch
```
-## 5. Deploy to FTL Edge
+## 9. Deploy to Production
-The `ftl deploy` command will deploy your tool to the FTL Edge, where it can be called by your AI agents.
+Deploy your project to FTL:
```bash
ftl deploy
```
-You will be prompted to log in to your FTL account. Once you are logged in, your tool will be deployed and you will be given a URL that you can use to call it.
+## Next Steps
+- Read the [Component Development Guide](./developing-tools.md)
+- Learn about [Publishing Components](./publishing.md)
+- Explore [Project Composition](./composition.md)
+- Check the [CLI Reference](./cli-reference.md)
\ No newline at end of file
diff --git a/docs/introduction.md b/docs/introduction.md
index dea406da..0cbe390d 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -1,37 +1,122 @@
# Introduction
-Welcome to the FTL documentation! This document provides a comprehensive overview of the FTL project, its goals, and its core concepts.
+Welcome to FTL! This guide will help you understand what FTL is, how it works, and how to get started building MCP servers as WebAssembly components.
## What is FTL?
-FTL is a framework for building and deploying high-performance, low-latency tools for AI agents. It is designed to solve the "Action Latency Bottleneck," which is the problem of slow tool execution that can limit the performance of AI agents in real-time applications.
+FTL (Faster Tools Library) is a developer platform for building and deploying Model Context Protocol (MCP) servers as WebAssembly components. It provides a complete workflow for creating, testing, composing, and deploying MCP components that can be used by AI agents and assistants.
-FTL provides a complete developer experience for the entire lifecycle of creating, testing, and deploying WebAssembly-based tools. It is built on a foundation of Rust, WebAssembly, and the Model Context Protocol (MCP), and it is designed to be fast, secure, and portable.
+FTL solves several key challenges in MCP development:
+- **Multi-language Support**: Write MCP servers in Rust, TypeScript, or JavaScript
+- **Component Composition**: Combine multiple MCP servers into a single deployable unit
+- **Edge Deployment**: Deploy your MCP servers anywhere using Spin's WebAssembly runtime
+- **Developer Experience**: Hot reload, automatic dependency management, and intuitive CLI
## Core Concepts
-### Tools
+### MCP Components
+
+An **MCP component** is a WebAssembly module that implements the Model Context Protocol. Each component can expose:
+- **Tools**: Functions that AI agents can call to perform actions
+- **Resources**: Data sources that AI agents can read
+- **Prompts**: Reusable prompt templates for AI interactions
+
+### Projects
+
+A **project** is a collection of MCP components that are deployed together. Projects use Spin's manifest format to define routing and configuration for each component.
+
+### Component Development
+
+FTL provides SDKs for building MCP components in multiple languages:
+
+**TypeScript/JavaScript:**
+```typescript
+import { createHandler } from '@fastertools/ftl-sdk';
+
+export const handler = createHandler({
+ tools: [...], // Your MCP tools
+ resources: [...], // Your MCP resources
+ prompts: [...] // Your MCP prompts
+});
+```
+
+**Rust:**
+```rust
+use ftl_sdk::*;
+
+create_handler!(
+ tools: get_tools,
+ resources: get_resources,
+ prompts: get_prompts
+);
+```
+
+### WebAssembly Runtime
+
+FTL uses [Spin](https://www.fermyon.com/spin) as its WebAssembly runtime, providing:
+- Secure sandboxing for each component
+- HTTP routing between components
+- Fast cold starts and execution
+- Deploy anywhere Spin runs
+
+## Architecture Overview
+
+```
+βββββββββββββββββββ
+β AI Agent β
+β (Claude, GPT-4) β
+ββββββββββ¬βββββββββ
+ β MCP Protocol
+ βΌ
+βββββββββββββββββββ
+β Spin Runtime β
+β (HTTP Router) β
+ββββββββββ¬βββββββββ
+ β
+ ββββββ΄βββββ¬ββββββββββ¬ββββββββββ
+ βΌ βΌ βΌ βΌ
+ββββββββββββββββββββββββββββββββββββββββ
+βWeather ββGitHub ββDatabaseββCustom β
+βTool ββTool ββTool ββTool β
+β(TS) ββ(Rust) ββ(JS) ββ(Any) β
+ββββββββββββββββββββββββββββββββββββββββ
+```
+
+Each component:
+- Runs in its own WebAssembly sandbox
+- Has its own HTTP route (e.g., `/weather/mcp`)
+- Can be developed and tested independently
+- Can be published and shared via OCI registries
-A **tool** is a self-contained piece of code that performs a specific task. Tools are implemented in Rust by implementing the `ftl_sdk_rs::Tool` trait. They are compiled to WebAssembly and can be deployed to any Wasm-compliant runtime.
+## Why FTL?
-### Toolkits
+### For MCP Developers
-A **toolkit** is a collection of tools that are deployed together as a single unit. This allows you to create more complex agent capabilities by composing multiple tools.
+- **Language Choice**: Use your preferred language (Rust, TypeScript, JavaScript)
+- **Fast Iteration**: Hot reload with `ftl watch` for rapid development
+- **Easy Testing**: Built-in test runners for each language
+- **Simple Deployment**: One command to build and deploy
-### FTL Core
+### For AI Applications
-**FTL Core** is an open-source library of composable, low-level utilities for performance-sensitive AI agents. It provides the building blocks for creating tools, as well as a standard library of pre-built tools for common tasks.
+- **Composability**: Mix and match components from different sources
+- **Performance**: WebAssembly provides near-native execution speed
+- **Security**: Components run in isolated sandboxes
+- **Portability**: Deploy anywhere - edge, cloud, or on-premise
-### FTL Edge
+### For Teams
-**FTL Edge** is a managed platform for deploying and serving tools. It provides a global network of edge servers that can execute tools with sub-millisecond compute overhead.
+- **Component Marketplace**: Share components via OCI registries
+- **Version Control**: Standard Git workflows for collaboration
+- **Independent Development**: Teams can work on components separately
+- **Unified Deployment**: Compose components into cohesive applications
-## Why FTL?
+## Getting Started
+
+Ready to build your first MCP component? Continue to the [Quick Start Guide](./quickstart.md) or dive into the [CLI Reference](./cli-reference.md).
-FTL is designed to be the best way to build and deploy high-performance tools for AI agents. It offers a number of advantages over other tool-building frameworks:
+## Learn More
-- **Performance:** FTL tools are written in Rust and compiled to WebAssembly, which provides near-native performance.
-- **Security:** FTL tools are sandboxed by default using the WebAssembly component model.
-- **Portability:** FTL tools can be deployed to any Wasm-compliant runtime.
-- **Developer Experience:** The `ftl` CLI provides a seamless developer experience for creating, testing, and deploying tools.
-- **Open Core:** FTL is an open-core project, which means that the core technology is open source and available to everyone.
+- [Model Context Protocol](https://modelcontextprotocol.io) - The protocol specification
+- [Spin Documentation](https://developer.fermyon.com/spin) - The WebAssembly runtime
+- [WebAssembly Component Model](https://component-model.bytecodealliance.org/) - The component standard
\ No newline at end of file
diff --git a/docs/manifest.md b/docs/manifest.md
index 0ca644d6..f2d7796b 100644
--- a/docs/manifest.md
+++ b/docs/manifest.md
@@ -1,49 +1,30 @@
# The ftl.toml Manifest
-The `ftl.toml` file is the manifest for your FTL tool. It contains metadata about your tool, as well as configuration for the build process and the runtime environment.
+The `ftl.toml` file is the manifest for your MCP component. It contains metadata about your component.
## Example
```toml
-[tool]
-name = "my-tool"
-version = "0.0.1"
-description = "A new tool for my agent"
-
-[build]
-profile = "release"
-features = []
-
-[optimization]
-flags = ["-O4"]
-
-[runtime]
-allowed_hosts = []
+[component]
+name = "my-component"
+version = "0.1.0"
+description = "An MCP component for AI agents"
```
-## `[tool]`
-
-The `[tool]` section contains metadata about your tool.
-
-- `name`: The name of your tool. This must be unique within your FTL account.
-- `version`: The version of your tool. This should follow the [SemVer](https://semver.org/) specification.
-- `description`: A short description of your tool.
-
-## `[build]`
-
-The `[build]` section contains configuration for the build process.
-
-- `profile`: The build profile to use. This can be `dev`, `release`, or `tiny`.
-- `features`: A list of features to enable when building your tool.
+## `[component]`
-## `[optimization]`
+The `[component]` section contains metadata about your component.
-The `[optimization]` section contains configuration for the `wasm-opt` tool, which is used to optimize the size and performance of your tool's WebAssembly binary.
+- `name`: The name of your component. This should be lowercase with hyphens (e.g., `my-component`).
+- `version`: The version of your component. This should follow the [SemVer](https://semver.org/) specification.
+- `description`: A short description of what your component does.
-- `flags`: A list of flags to pass to `wasm-opt`.
+## Build Configuration
-## `[runtime]`
+Build configuration is handled through:
+- `Makefile` - For custom build commands
+- Language-specific build tools (cargo, npm, etc.)
-The `[runtime]` section contains configuration for the runtime environment in which your tool will be executed.
+## Runtime Configuration
-- `allowed_hosts`: A list of hosts that your tool is allowed to make HTTP requests to. If this list is empty, your tool will not be able to make any external HTTP requests.
+Runtime configuration such as allowed hosts is configured in `spin.toml` rather than `ftl.toml`.
\ No newline at end of file
diff --git a/docs/publishing.md b/docs/publishing.md
new file mode 100644
index 00000000..a628ffe6
--- /dev/null
+++ b/docs/publishing.md
@@ -0,0 +1,356 @@
+# Publishing Components
+
+This guide covers how to publish and share your FTL components via OCI registries.
+
+## Prerequisites
+
+Before publishing, you'll need:
+1. An OCI registry account (GitHub, Docker Hub, etc.)
+2. Authentication configured for your registry
+3. `wkg` tool installed (optional but recommended)
+
+## Registry Setup
+
+### GitHub Container Registry (ghcr.io)
+
+1. Create a Personal Access Token:
+ - Go to GitHub Settings β Developer settings β Personal access tokens
+ - Create a token with `write:packages` permission
+
+2. Login to the registry:
+ ```bash
+ echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
+ ```
+
+### Docker Hub
+
+1. Create an account at [hub.docker.com](https://hub.docker.com)
+
+2. Login to the registry:
+ ```bash
+ docker login
+ ```
+
+## Publishing Workflow
+
+### 1. Build Your Component
+
+Ensure your component builds successfully:
+
+```bash
+cd my-component
+ftl build --release
+```
+
+### 2. Update Metadata
+
+Edit your component's `ftl.toml`:
+
+```toml
+name = "weather-tool"
+version = "1.0.0"
+description = "Real-time weather data for AI agents"
+authors = ["Your Name "]
+license = "MIT"
+repository = "https://github.com/username/weather-tool"
+keywords = ["weather", "mcp", "tool"]
+```
+
+### 3. Test Locally
+
+Run final tests before publishing:
+
+```bash
+ftl test
+ftl up --port 3000
+```
+
+### 4. Publish to Registry
+
+#### Default Registry (ghcr.io)
+
+```bash
+# Publish with a version tag
+ftl publish --tag v1.0.0
+
+# This publishes to: ghcr.io/YOUR_USERNAME/weather-tool:v1.0.0
+```
+
+#### Custom Registry
+
+```bash
+# Publish to Docker Hub
+ftl publish --registry docker.io --tag latest
+
+# Publish to a private registry
+ftl publish --registry registry.company.com --tag v1.0.0
+```
+
+## Version Management
+
+### Semantic Versioning
+
+Follow semantic versioning for your components:
+- **Major** (1.0.0): Breaking changes
+- **Minor** (0.1.0): New features, backward compatible
+- **Patch** (0.0.1): Bug fixes
+
+### Version Tags
+
+```bash
+# Publish specific version
+ftl publish --tag v1.2.3
+
+# Publish latest tag
+ftl publish --tag latest
+
+# Publish with multiple tags
+ftl publish --tag v1.2.3
+ftl publish --tag v1.2
+ftl publish --tag v1
+ftl publish --tag latest
+```
+
+## Using Published Components
+
+### In FTL Projects
+
+Reference published components in your project:
+
+```bash
+# Add a published component to your project
+ftl add weather --from ghcr.io/username/weather-tool:v1.0.0
+```
+
+### Direct Usage with Spin
+
+```toml
+# spin.toml
+[[component]]
+id = "weather"
+source = { registry = "ghcr.io/username/weather-tool:v1.0.0" }
+route = "/weather/..."
+```
+
+## Component Discovery
+
+### Public Registries
+
+Browse public components:
+- GitHub: `https://github.com/orgs/ORG/packages`
+- Docker Hub: `https://hub.docker.com/search`
+
+### Component Metadata
+
+Well-documented components include:
+- Clear descriptions
+- Usage examples
+- API documentation
+- License information
+
+## Best Practices
+
+### 1. Documentation
+
+Include comprehensive documentation:
+
+```markdown
+# Weather Tool
+
+Real-time weather data for AI agents.
+
+## Installation
+
+```bash
+ftl add weather --from ghcr.io/username/weather-tool:latest
+```
+
+## Tools
+
+- `get_weather`: Get current weather for a location
+- `get_forecast`: Get weather forecast
+
+## Usage
+
+```typescript
+const result = await callTool('get_weather', {
+ location: 'San Francisco',
+ units: 'fahrenheit'
+});
+```
+```
+
+### 2. Changelog
+
+Maintain a CHANGELOG.md:
+
+```markdown
+# Changelog
+
+## [1.2.0] - 2024-01-15
+### Added
+- Support for weather alerts
+- Metric units option
+
+### Fixed
+- Timezone handling for forecasts
+
+## [1.1.0] - 2024-01-01
+### Added
+- 7-day forecast capability
+```
+
+### 3. Testing
+
+Include example tests:
+
+```typescript
+// examples/test-weather.ts
+import { Client } from '@modelcontextprotocol/sdk';
+
+const client = new Client({
+ url: 'http://localhost:3000/weather/mcp'
+});
+
+const weather = await client.callTool('get_weather', {
+ location: 'London'
+});
+console.log(weather);
+```
+
+### 4. Security
+
+- Never include secrets in components
+- Use environment variables for configuration
+- Document required permissions
+- Keep dependencies updated
+
+### 5. Component Size
+
+Optimize component size:
+- Minimize dependencies
+- Use tree-shaking for JavaScript
+- Enable release optimizations
+- Consider splitting large components
+
+## Advanced Publishing
+
+### Multi-Architecture Support
+
+Build for multiple architectures:
+
+```bash
+# Build for multiple targets
+ftl build --target wasm32-wasip1
+ftl build --target wasm32-wasip2
+```
+
+### Automated Publishing
+
+GitHub Actions example:
+
+```yaml
+name: Publish Component
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install FTL
+ run: cargo install ftl-cli
+
+ - name: Build component
+ run: ftl build --release
+
+ - name: Login to ghcr.io
+ run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
+
+ - name: Publish
+ run: ftl publish --tag ${GITHUB_REF#refs/tags/}
+```
+
+### Component Signing
+
+Sign your components for security:
+
+```bash
+# Sign with cosign
+cosign sign ghcr.io/username/weather-tool:v1.0.0
+
+# Verify signature
+cosign verify ghcr.io/username/weather-tool:v1.0.0
+```
+
+## Registry Management
+
+### List Published Versions
+
+```bash
+# Using wkg
+wkg list ghcr.io/username/weather-tool
+
+# Using docker
+docker run --rm gcr.io/go-containerregistry/crane ls ghcr.io/username/weather-tool
+```
+
+### Delete Versions
+
+```bash
+# Delete specific version
+wkg delete ghcr.io/username/weather-tool:v0.1.0
+
+# Delete using docker
+docker run --rm gcr.io/go-containerregistry/crane delete ghcr.io/username/weather-tool:v0.1.0
+```
+
+## Troubleshooting
+
+### Authentication Issues
+
+```bash
+# Check current auth
+docker config get-credential-helpers
+
+# Re-authenticate
+docker logout ghcr.io
+docker login ghcr.io
+```
+
+### Publishing Failures
+
+Common issues and solutions:
+
+1. **Permission denied**
+ - Check registry authentication
+ - Verify token permissions
+
+2. **Version already exists**
+ - Use a different version tag
+ - Delete existing version if needed
+
+3. **Size limits**
+ - Optimize component size
+ - Check registry limits
+
+### Registry Debugging
+
+```bash
+# Verbose output
+ftl publish --tag v1.0.0 --verbose
+
+# Check component size
+du -h my-component/handler/target/wasm32-wasip1/release/*.wasm
+```
+
+## Next Steps
+
+- [Component Development](./components.md) - Build better components
+- [Deployment Guide](./deployment.md) - Deploy published components
+- [CLI Reference](./cli-reference.md) - Publishing command options
\ No newline at end of file
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 00000000..c66cf948
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,212 @@
+# Quick Start Guide
+
+This guide will walk you through creating your first MCP component with FTL in under 5 minutes.
+
+## Prerequisites
+
+Before you begin, ensure you have:
+- Rust toolchain installed ([rustup.rs](https://rustup.rs))
+- Node.js 20+ (for TypeScript/JavaScript components)
+
+## Installation
+
+Install the FTL CLI using cargo:
+
+```bash
+cargo install ftl-cli
+```
+
+Or if you have cargo-binstall for faster installation:
+
+```bash
+cargo binstall ftl-cli
+```
+
+## Create Your First Project
+
+### 1. Initialize a Project
+
+```bash
+ftl init my-assistant
+cd my-assistant
+```
+
+This creates a new FTL project with:
+- `spin.toml` - The Spin manifest file
+- `.gitignore` - Git ignore configuration
+
+### 2. Add a Component
+
+Let's add a TypeScript component that provides a simple echo tool:
+
+```bash
+ftl add echo-tool --language typescript --description "A simple echo tool"
+```
+
+FTL will:
+- Create the component directory structure
+- Install the TypeScript SDK
+- Set up the build configuration
+- Add the component to your Spin manifest
+
+### 3. Explore the Component
+
+Navigate to your component:
+
+```bash
+cd echo-tool
+```
+
+The component structure:
+```
+echo-tool/
+βββ ftl.toml # Component metadata
+βββ Makefile # Build commands
+βββ handler/ # Component source
+ βββ package.json # Node dependencies
+ βββ src/
+ β βββ index.ts # Main handler
+ β βββ features.ts # Tools, resources, prompts
+ βββ test/ # Component tests
+```
+
+### 4. Customize Your Tool
+
+Open `handler/src/features.ts` and modify the echo tool:
+
+```typescript
+import { createTool } from '@fastertools/ftl-sdk';
+
+export const tools = [
+ createTool({
+ name: 'echo',
+ description: 'Echo a message with enthusiasm!',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ message: {
+ type: 'string',
+ description: 'Message to echo back'
+ },
+ excitement: {
+ type: 'number',
+ description: 'Excitement level (1-10)',
+ minimum: 1,
+ maximum: 10
+ }
+ },
+ required: ['message']
+ },
+ execute: async (args) => {
+ const message = args.message || 'Hello, world!';
+ const excitement = args.excitement || 5;
+ const exclamation = '!'.repeat(excitement);
+ return `Echo: ${message}${exclamation}`;
+ }
+ }),
+];
+```
+
+### 5. Test Your Component
+
+Run the component tests:
+
+```bash
+make test
+```
+
+### 6. Run Your Project
+
+Go back to the project root and start the development server:
+
+```bash
+cd ..
+ftl watch
+```
+
+Your MCP server is now running! The echo tool is available at:
+- `http://localhost:3000/echo-tool/mcp`
+
+### 7. Test with an MCP Client
+
+You can test your MCP server using any MCP client. Here's a quick test using curl:
+
+```bash
+# List available tools
+curl -X POST http://localhost:3000/echo-tool/mcp \
+ -H "Content-Type: application/json" \
+ -d '{
+ "jsonrpc": "2.0",
+ "method": "tools/list",
+ "id": 1
+ }'
+
+# Call the echo tool
+curl -X POST http://localhost:3000/echo-tool/mcp \
+ -H "Content-Type: application/json" \
+ -d '{
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {
+ "name": "echo",
+ "arguments": {
+ "message": "Hello FTL",
+ "excitement": 8
+ }
+ },
+ "id": 2
+ }'
+```
+
+## Next Steps
+
+### Add More Components
+
+Try adding components in different languages:
+
+```bash
+# Add a Rust component
+ftl add rust-tool --language rust --description "A Rust-powered tool"
+
+# Add a JavaScript component
+ftl add js-tool --language javascript --description "A JavaScript tool"
+```
+
+### Build for Production
+
+```bash
+# Build all components in release mode
+ftl build --release
+
+# Run in production mode
+ftl up --port 8080
+```
+
+### Publish Your Component
+
+```bash
+# Publish to GitHub Container Registry
+ftl publish --tag v1.0.0
+
+# Or publish to Docker Hub
+ftl publish --registry docker.io --tag latest
+```
+
+## Common Commands
+
+| Command | Description |
+|---------|-------------|
+| `ftl init` | Create a new project |
+| `ftl add` | Add a component to the project |
+| `ftl build` | Build components |
+| `ftl test` | Run component tests |
+| `ftl watch` | Start dev server with hot reload |
+| `ftl up` | Run the project |
+| `ftl publish` | Publish to registry |
+
+## Learn More
+
+- [Component Development](./components.md) - Deep dive into building components
+- [CLI Reference](./cli-reference.md) - Complete command documentation
+- [Publishing Guide](./publishing.md) - Share your components
+- [Deployment Guide](./deployment.md) - Deploy to production
\ No newline at end of file
diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md
new file mode 100644
index 00000000..f8fefe2c
--- /dev/null
+++ b/docs/sdk-reference.md
@@ -0,0 +1,525 @@
+# SDK Reference
+
+This document provides a complete reference for the FTL SDKs in TypeScript/JavaScript and Rust.
+
+## TypeScript/JavaScript SDK
+
+### Installation
+
+```bash
+npm install @fastertools/ftl-sdk
+```
+
+### Core Functions
+
+#### `createHandler(features)`
+
+Creates an MCP handler with the specified features.
+
+```typescript
+import { createHandler } from '@fastertools/ftl-sdk';
+
+export const handler = createHandler({
+ tools: [...], // Array of tools
+ resources: [...], // Array of resources
+ prompts: [...] // Array of prompts
+});
+```
+
+#### `createTool(config)`
+
+Creates a tool that can be called by AI agents.
+
+```typescript
+const tool = createTool({
+ name: string, // Unique tool identifier
+ description: string, // Human-readable description
+ inputSchema: object, // JSON Schema for input validation
+ execute: async (args) => string // Tool implementation
+});
+```
+
+**Example:**
+```typescript
+const calculateTool = createTool({
+ name: 'calculate',
+ description: 'Perform mathematical calculations',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ expression: {
+ type: 'string',
+ description: 'Mathematical expression to evaluate'
+ }
+ },
+ required: ['expression']
+ },
+ execute: async (args) => {
+ const result = evaluateExpression(args.expression);
+ return `Result: ${result}`;
+ }
+});
+```
+
+#### `createResource(config)`
+
+Creates a resource that can be read by AI agents.
+
+```typescript
+const resource = createResource({
+ uri: string, // Resource URI
+ name: string, // Display name
+ description?: string, // Resource description
+ mimeType?: string, // Content MIME type
+ read: async () => string // Resource reader
+});
+```
+
+**Example:**
+```typescript
+const configResource = createResource({
+ uri: 'config://app-settings',
+ name: 'Application Settings',
+ description: 'Current application configuration',
+ mimeType: 'application/json',
+ read: async () => {
+ const config = await loadConfig();
+ return JSON.stringify(config, null, 2);
+ }
+});
+```
+
+#### `createPrompt(config)`
+
+Creates a reusable prompt template.
+
+```typescript
+const prompt = createPrompt({
+ name: string, // Prompt identifier
+ description?: string, // Prompt description
+ arguments?: Array<{ // Prompt arguments
+ name: string,
+ description?: string,
+ required?: boolean
+ }>,
+ resolve: async (args) => PromptMessage[]
+});
+
+interface PromptMessage {
+ role: 'user' | 'assistant';
+ content: string;
+}
+```
+
+**Example:**
+```typescript
+const analysisPrompt = createPrompt({
+ name: 'analyze_code',
+ description: 'Generate code analysis prompt',
+ arguments: [
+ { name: 'language', description: 'Programming language', required: true },
+ { name: 'code', description: 'Code to analyze', required: true }
+ ],
+ resolve: async (args) => {
+ return [
+ {
+ role: 'user',
+ content: `Analyze this ${args.language} code:\n\n${args.code}`
+ },
+ {
+ role: 'assistant',
+ content: 'I\'ll analyze this code for bugs, performance, and best practices.'
+ }
+ ];
+ }
+});
+```
+
+### Types
+
+```typescript
+interface Tool {
+ name: string;
+ description: string;
+ inputSchema: object;
+ execute: (args: TArgs) => string | Promise;
+}
+
+interface Resource {
+ uri: string;
+ name: string;
+ description?: string;
+ mimeType?: string;
+ read: () => string | Promise;
+}
+
+interface Prompt {
+ name: string;
+ description?: string;
+ arguments?: Array<{
+ name: string;
+ description?: string;
+ required?: boolean;
+ }>;
+ resolve: (args: TArgs) => PromptMessage[] | Promise;
+}
+```
+
+## Rust SDK
+
+### Installation
+
+Add to your `Cargo.toml`:
+
+```toml
+[dependencies]
+ftl-sdk = "0.2.0"
+```
+
+### Core Macros
+
+#### `create_handler!`
+
+Creates an MCP handler with the specified functions.
+
+```rust
+use ftl_sdk::*;
+
+create_handler!(
+ tools: get_tools,
+ resources: get_resources,
+ prompts: get_prompts
+);
+
+fn get_tools() -> Vec { vec![] }
+fn get_resources() -> Vec { vec![] }
+fn get_prompts() -> Vec { vec![] }
+```
+
+#### `tool!`
+
+Creates a tool definition.
+
+```rust
+tool!(
+ name: &str, // Tool name
+ description: &str, // Tool description
+ schema: serde_json::Value, // Input schema
+ handler: fn // Handler function
+)
+```
+
+**Example:**
+```rust
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+#[derive(Deserialize)]
+struct CalculateArgs {
+ expression: String,
+}
+
+fn calculate(args: CalculateArgs) -> Result {
+ let result = evaluate_expression(&args.expression)?;
+ Ok(format!("Result: {}", result))
+}
+
+fn get_tools() -> Vec {
+ vec![
+ tool!(
+ "calculate",
+ "Perform mathematical calculations",
+ json!({
+ "type": "object",
+ "properties": {
+ "expression": {
+ "type": "string",
+ "description": "Mathematical expression"
+ }
+ },
+ "required": ["expression"]
+ }),
+ calculate
+ )
+ ]
+}
+```
+
+#### `resource!`
+
+Creates a resource definition.
+
+```rust
+resource!(
+ uri: &str, // Resource URI
+ name: &str, // Display name
+ mime_type: &str, // MIME type
+ reader: fn() -> Result
+)
+```
+
+**Example:**
+```rust
+fn read_config() -> Result {
+ let config = load_configuration()?;
+ serde_json::to_string_pretty(&config)
+ .map_err(|e| e.to_string())
+}
+
+fn get_resources() -> Vec {
+ vec![
+ resource!(
+ "config://settings",
+ "Application Settings",
+ "application/json",
+ read_config
+ )
+ ]
+}
+```
+
+#### `prompt!`
+
+Creates a prompt definition.
+
+```rust
+prompt!(
+ name: &str, // Prompt name
+ description: &str, // Description
+ arguments: Vec, // Arguments
+ resolver: fn // Resolver function
+)
+```
+
+**Example:**
+```rust
+#[derive(Deserialize)]
+struct AnalyzeArgs {
+ language: String,
+ code: String,
+}
+
+fn analyze_prompt(args: AnalyzeArgs) -> Result, String> {
+ Ok(vec![
+ PromptMessage {
+ role: Role::User,
+ content: format!("Analyze this {} code:\n\n{}",
+ args.language, args.code),
+ },
+ PromptMessage {
+ role: Role::Assistant,
+ content: "I'll analyze this code for bugs and improvements.".to_string(),
+ }
+ ])
+}
+
+fn get_prompts() -> Vec {
+ vec![
+ prompt!(
+ "analyze_code",
+ "Generate code analysis prompt",
+ vec![
+ PromptArg::new("language", "Programming language", true),
+ PromptArg::new("code", "Code to analyze", true),
+ ],
+ analyze_prompt
+ )
+ ]
+}
+```
+
+### Types
+
+```rust
+pub struct Tool {
+ pub name: String,
+ pub description: String,
+ pub input_schema: serde_json::Value,
+ pub handler: Box Result>,
+}
+
+pub struct Resource {
+ pub uri: String,
+ pub name: String,
+ pub description: Option,
+ pub mime_type: Option,
+ pub reader: Box Result>,
+}
+
+pub struct Prompt {
+ pub name: String,
+ pub description: Option,
+ pub arguments: Vec,
+ pub resolver: Box Result, String>>,
+}
+
+pub struct PromptArg {
+ pub name: String,
+ pub description: Option,
+ pub required: bool,
+}
+
+pub struct PromptMessage {
+ pub role: Role,
+ pub content: String,
+}
+
+pub enum Role {
+ User,
+ Assistant,
+}
+```
+
+## Error Handling
+
+### TypeScript/JavaScript
+
+Return error information as part of the response:
+
+```typescript
+execute: async (args) => {
+ try {
+ const result = await riskyOperation(args);
+ return JSON.stringify({ success: true, data: result });
+ } catch (error) {
+ return JSON.stringify({
+ success: false,
+ error: error.message,
+ code: error.code || 'UNKNOWN_ERROR'
+ });
+ }
+}
+```
+
+### Rust
+
+Use `Result` for error handling:
+
+```rust
+fn my_tool(args: MyArgs) -> Result {
+ match risky_operation(&args) {
+ Ok(result) => Ok(format!("Success: {}", result)),
+ Err(e) => Err(format!("Error: {}", e)),
+ }
+}
+```
+
+## Best Practices
+
+### 1. Input Validation
+
+Always validate inputs using JSON Schema:
+
+```typescript
+inputSchema: {
+ type: 'object',
+ properties: {
+ url: {
+ type: 'string',
+ format: 'uri',
+ pattern: '^https?://'
+ },
+ timeout: {
+ type: 'integer',
+ minimum: 1,
+ maximum: 30000
+ }
+ },
+ required: ['url'],
+ additionalProperties: false
+}
+```
+
+### 2. Async Operations
+
+Handle async operations properly:
+
+```typescript
+// TypeScript
+execute: async (args) => {
+ const results = await Promise.all([
+ fetchData(args.url1),
+ fetchData(args.url2)
+ ]);
+ return JSON.stringify(results);
+}
+```
+
+```rust
+// Rust
+use tokio::runtime::Runtime;
+
+fn async_tool(args: Args) -> Result {
+ let rt = Runtime::new().map_err(|e| e.to_string())?;
+ rt.block_on(async {
+ let data = fetch_data(&args.url).await?;
+ Ok(format!("Data: {}", data))
+ })
+}
+```
+
+### 3. Resource Cleanup
+
+Always clean up resources:
+
+```typescript
+let client;
+try {
+ client = await createClient(args.config);
+ return await client.query(args.query);
+} finally {
+ if (client) {
+ await client.close();
+ }
+}
+```
+
+### 4. Structured Responses
+
+Return structured data when possible:
+
+```typescript
+execute: async (args) => {
+ const result = {
+ status: 'success',
+ data: {
+ count: 42,
+ items: ['a', 'b', 'c']
+ },
+ metadata: {
+ timestamp: new Date().toISOString(),
+ version: '1.0.0'
+ }
+ };
+ return JSON.stringify(result, null, 2);
+}
+```
+
+## Migration Guide
+
+### From MCP SDK to FTL SDK
+
+If you're migrating from the standard MCP SDK:
+
+**Before (MCP SDK):**
+```typescript
+const server = new Server({
+ name: 'my-server',
+ version: '1.0.0'
+});
+
+server.setRequestHandler(ListToolsRequestSchema, async () => {
+ return { tools: [...] };
+});
+```
+
+**After (FTL SDK):**
+```typescript
+export const handler = createHandler({
+ tools: [...],
+ resources: [...],
+ prompts: [...]
+});
+```
+
+The FTL SDK handles all the MCP protocol details for you!
\ No newline at end of file
diff --git a/packages/ftl-cli/Cargo.toml b/packages/ftl-cli/Cargo.toml
index 71e68d52..e1008deb 100644
--- a/packages/ftl-cli/Cargo.toml
+++ b/packages/ftl-cli/Cargo.toml
@@ -5,7 +5,7 @@ authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
-description = "CLI for building and deploying WebAssembly-based MCP tools"
+description = "Build and deploy Model Context Protocol (MCP) servers on WebAssembly"
readme = "README.md"
homepage.workspace = true
documentation.workspace = true
@@ -43,6 +43,7 @@ indicatif = "0.17"
dialoguer = "0.11"
console = "0.15"
semver = "1.0"
+atty = "0.2"
futures-util = "0.3"
flate2 = "1.0"
tar = "0.4"
diff --git a/packages/ftl-cli/README.md b/packages/ftl-cli/README.md
index 06a41725..02da1543 100644
--- a/packages/ftl-cli/README.md
+++ b/packages/ftl-cli/README.md
@@ -1,15 +1,160 @@
# FTL CLI
-This crate contains the command-line interface for the FTL framework. It provides a complete developer experience for the entire lifecycle of creating, testing, and deploying WebAssembly-based tools for AI agents.
+Build and deploy Model Context Protocol (MCP) servers on WebAssembly.
-## Features
+FTL provides a streamlined developer experience for creating, testing, and deploying MCP components that run on the Fermyon Spin platform.
-- **Tool Scaffolding:** `ftl new` creates a new tool from a template.
-- **Building:** `ftl build` compiles your Rust code into an optimized WebAssembly component.
-- **Local Development:** `ftl serve` starts a local server with hot reloading for rapid development and testing.
-- **Deployment:** `ftl deploy` pushes your tool to the FTL Edge platform.
-- **Toolkit Management:** `ftl toolkit` commands allow you to bundle multiple tools into a single deployable unit.
+## Installation
-## Usage
+```bash
+cargo install ftl-cli
+```
-The `ftl` CLI is the primary entry point for developers using the FTL platform. For installation and usage instructions, please see the main project [README.md](../README.md) and the [documentation](../docs).
+Or download pre-built binaries from the [releases page](https://github.com/fastertools/ftl-cli/releases).
+
+## Quick Start
+
+```bash
+# Create a new MCP project
+ftl init my-assistant
+cd my-assistant
+
+# Add a component
+ftl add weather-tool --language typescript
+
+# Start development server with auto-rebuild
+ftl watch
+
+# Publish to registry
+ftl publish
+```
+
+## Commands
+
+### Project & Component Management
+
+#### `ftl init [name]`
+Create a new MCP project for composing components.
+
+Options:
+- `--here` - Initialize in current directory
+
+#### `ftl add [name]`
+Add a new MCP component to the current project.
+
+Options:
+- `--language ` - Language to use (rust, typescript, javascript)
+- `--description ` - Component description
+- `--route ` - HTTP route for the component
+
+#### `ftl build`
+Build the component in the current directory.
+
+Options:
+- `--release` - Build in release mode
+- `--path ` - Path to component directory
+
+#### `ftl up`
+Run the component locally for development.
+
+Options:
+- `--build` - Build before running
+- `--port ` - Port to serve on (default: 3000)
+
+#### `ftl watch`
+Build and run the component, automatically rebuilding when files change.
+
+Options:
+- `--port ` - Port to serve on (default: 3000)
+
+#### `ftl test`
+Run component tests.
+
+Options:
+- `--path ` - Path to component directory
+
+#### `ftl publish`
+Publish component to an OCI registry.
+
+Options:
+- `--registry ` - Registry URL (default: ghcr.io)
+- `--tag ` - Version tag to publish
+
+#### `ftl deploy`
+Deploy the project to FTL.
+
+Options:
+- `-e, --environment ` - Target environment
+
+### Configuration
+
+#### `ftl setup templates`
+Install or update FTL component templates.
+
+Options:
+- `--force` - Force reinstall
+
+#### `ftl setup info`
+Show FTL configuration and status.
+
+### Registry Operations
+
+#### `ftl registry list`
+List available components (coming soon).
+
+#### `ftl registry search `
+Search for components (coming soon).
+
+#### `ftl registry info `
+Show component details (coming soon).
+
+## Project Structure
+
+FTL projects follow a standard structure:
+
+```
+my-project/
+βββ spin.toml # Spin project configuration
+βββ weather-tool/ # Component directory
+β βββ ftl.toml # Component manifest
+β βββ Makefile # Build automation
+β βββ src/ # Component source code
+β βββ index.ts # Main entry point
+β βββ features.ts # MCP tools/resources
+βββ calculator/ # Another component
+ βββ ftl.toml
+ βββ Makefile
+ βββ src/
+ βββ lib.rs
+```
+
+## Publishing Components
+
+Components are published as OCI artifacts to container registries:
+
+```bash
+# Publish to GitHub Container Registry (default)
+ftl publish
+
+# Publish with specific version
+ftl publish --tag v1.0.0
+
+# Publish to Docker Hub
+ftl publish --registry docker.io
+```
+
+Published components can be referenced as:
+- `ghcr.io/username/component-name:version`
+- `docker.io/username/component-name:version`
+
+## Prerequisites
+
+- **Spin**: Automatically installed by FTL if not present
+- **wkg**: Required for publishing ([install](https://github.com/bytecodealliance/wasm-pkg-tools))
+- **Language toolchains**:
+ - Rust: cargo with wasm32-wasi target
+ - TypeScript/JavaScript: Node.js 20+
+
+## License
+
+Apache-2.0
\ No newline at end of file
diff --git a/packages/ftl-cli/build.rs b/packages/ftl-cli/build.rs
index c21c6ec8..cbf5da8b 100644
--- a/packages/ftl-cli/build.rs
+++ b/packages/ftl-cli/build.rs
@@ -1,44 +1,4 @@
-use std::{env, fs, path::Path};
-
fn main() {
- println!("cargo:rerun-if-changed=../../Cargo.toml");
- println!("cargo:rerun-if-changed=../ftl-sdk-ts/package.json");
-
- // Read ftl-sdk-rs version from workspace Cargo.toml
- let workspace_toml_path = Path::new("../../Cargo.toml");
- let ftl_sdk_rs_version = if workspace_toml_path.exists() {
- let content = fs::read_to_string(workspace_toml_path).unwrap();
- let workspace_toml: toml::Value = toml::from_str(&content).unwrap();
-
- workspace_toml
- .get("workspace")
- .and_then(|w| w.get("package"))
- .and_then(|p| p.get("version"))
- .and_then(|v| v.as_str())
- .unwrap_or("0.0.9")
- .to_string()
- } else {
- // Fallback for when building outside the workspace
- env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.9".to_string())
- };
-
- // Read @fastertools/ftl-sdk-ts version from package.json
- let sdk_ts_package_json = Path::new("../ftl-sdk-ts/package.json");
- let ftl_sdk_ts_version = if sdk_ts_package_json.exists() {
- let content = fs::read_to_string(sdk_ts_package_json).unwrap();
- let package_json: serde_json::Value = serde_json::from_str(&content).unwrap();
-
- package_json
- .get("version")
- .and_then(|v| v.as_str())
- .unwrap()
- .to_string()
- } else {
- // Use same version as Rust SDK as fallback
- ftl_sdk_rs_version.clone()
- };
-
- // Set environment variables that will be available at compile time
- println!("cargo:rustc-env=FTL_SDK_RS_VERSION={ftl_sdk_rs_version}");
- println!("cargo:rustc-env=FTL_SDK_TS_VERSION={ftl_sdk_ts_version}");
+ // Empty build script for now
+ // Can be used in the future for other build-time operations
}
diff --git a/packages/ftl-cli/src/commands/add.rs b/packages/ftl-cli/src/commands/add.rs
new file mode 100644
index 00000000..3bf2ab3d
--- /dev/null
+++ b/packages/ftl-cli/src/commands/add.rs
@@ -0,0 +1,218 @@
+use std::path::PathBuf;
+use std::process::Command;
+
+use anyhow::{Context, Result};
+use atty;
+use console::style;
+use dialoguer::{Input, Select, theme::ColorfulTheme};
+
+use crate::{common::spin_installer::check_and_install_spin, language::Language};
+
+pub struct AddOptions {
+ pub name: Option,
+ pub description: Option,
+ pub language: Option,
+ pub route: Option,
+ pub git: Option,
+ pub branch: Option,
+ pub dir: Option,
+ pub tar: Option,
+}
+
+pub async fn execute(options: AddOptions) -> Result<()> {
+ let AddOptions {
+ name,
+ description,
+ language,
+ route,
+ git,
+ branch,
+ dir,
+ tar,
+ } = options;
+ // Check if we're in a Spin project directory
+ if !PathBuf::from("spin.toml").exists() {
+ anyhow::bail!("No spin.toml found. Not in a Spin project directory? Run 'ftl init' first.");
+ }
+
+ // Get component name interactively if not provided
+ let component_name = match name {
+ Some(n) => n,
+ None => Input::::with_theme(&ColorfulTheme::default())
+ .with_prompt("Component name")
+ .interact_text()?,
+ };
+
+ println!(
+ "{} Adding component: {}",
+ style("β").cyan(),
+ style(&component_name).bold()
+ );
+
+ // Validate component name
+ if !component_name
+ .chars()
+ .all(|c| c.is_lowercase() || c == '-' || c.is_numeric())
+ {
+ anyhow::bail!("Component name must be lowercase with hyphens (e.g., my-component)");
+ }
+
+ // Don't allow leading or trailing hyphens, or double hyphens
+ if component_name.starts_with('-')
+ || component_name.ends_with('-')
+ || component_name.contains("--")
+ {
+ anyhow::bail!("Component name cannot start or end with hyphens, or contain double hyphens");
+ }
+
+ // Get description interactively if not provided
+ let description = match description {
+ Some(d) => d,
+ None => Input::::with_theme(&ColorfulTheme::default())
+ .with_prompt("Component description")
+ .interact_text()?,
+ };
+
+ // Determine language
+ let selected_language = match language {
+ Some(lang_str) => Language::from_str(&lang_str).ok_or_else(|| {
+ anyhow::anyhow!(
+ "Invalid language: {lang_str}. Valid options are: rust, javascript, typescript"
+ )
+ })?,
+ None => {
+ // Interactive language selection
+ let languages = vec!["rust", "javascript", "typescript"];
+ let selection = Select::with_theme(&ColorfulTheme::default())
+ .with_prompt("Select programming language")
+ .items(&languages)
+ .default(0)
+ .interact()?;
+
+ Language::from_str(languages[selection]).unwrap()
+ }
+ };
+
+ // Get route interactively if not provided
+ let route = match route {
+ Some(r) => {
+ // Ensure route ends with /mcp
+ if r.ends_with("/mcp") {
+ r
+ } else if r.ends_with('/') {
+ format!("{r}mcp")
+ } else {
+ format!("{r}/mcp")
+ }
+ }
+ None => {
+ // Convert component name to kebab-case for the route
+ let kebab_name = component_name.replace('_', "-").to_lowercase();
+ let default_route = format!("/{kebab_name}/mcp");
+
+ // Check if we're in a terminal
+ if atty::is(atty::Stream::Stdin) {
+ Input::::with_theme(&ColorfulTheme::default())
+ .with_prompt("HTTP route")
+ .default(default_route)
+ .interact_text()?
+ } else {
+ // Non-interactive mode - use default
+ default_route
+ }
+ }
+ };
+
+ // Get spin path
+ let spin_path = check_and_install_spin().await?;
+
+ // Use spin add with the appropriate template
+ let template_id = match selected_language {
+ Language::Rust => "ftl-rust",
+ Language::TypeScript => "ftl-typescript",
+ Language::JavaScript => "ftl-javascript",
+ };
+
+ // Check if custom template source is provided
+ let using_custom_template = git.is_some() || dir.is_some() || tar.is_some();
+
+ let mut spin_cmd = Command::new(&spin_path);
+ spin_cmd.args(["add"]);
+
+ // Add template source options
+ if let Some(git_url) = &git {
+ spin_cmd.args(["--git", git_url]);
+ if let Some(branch_name) = &branch {
+ spin_cmd.args(["--branch", branch_name]);
+ }
+ } else if let Some(dir_path) = &dir {
+ spin_cmd.args(["--dir", dir_path.to_str().unwrap()]);
+ } else if let Some(tar_path) = &tar {
+ spin_cmd.args(["--tar", tar_path]);
+ } else {
+ // Use default template
+ spin_cmd.args(["-t", template_id]);
+ }
+
+ spin_cmd.args([
+ "--accept-defaults",
+ "--value",
+ &format!("project-description={description}"),
+ "--value",
+ &format!("route={route}"),
+ &component_name,
+ ]);
+
+ let output = spin_cmd.output().context("Failed to run spin add")?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ // Check if templates need to be installed (only for default templates)
+ if !using_custom_template
+ && (stderr.contains("no such template") || stderr.contains("template not found"))
+ {
+ eprintln!();
+ eprintln!("{} FTL templates not found.", style("β").red());
+ eprintln!();
+ eprintln!("Please install the FTL templates by running:");
+ eprintln!(" ftl setup templates");
+ eprintln!();
+ anyhow::bail!("FTL templates not installed");
+ } else {
+ anyhow::bail!("Failed to add component:\n{}", stderr);
+ }
+ }
+
+ // Success message based on language
+ let main_file = match selected_language {
+ Language::Rust => format!("{component_name}/src/lib.rs"),
+ Language::JavaScript => format!("{component_name}/src/index.js"),
+ Language::TypeScript => format!("{component_name}/src/index.ts"),
+ };
+
+ println!(
+ r#"
+{} {} component added successfully!
+
+{} Component location:
+ βββ {}/ # Component source code
+
+{} Edit {} to implement your MCP features
+
+{} cd {} && make build # Build component
+
+{} ftl watch # Start development server with auto-rebuild"#,
+ style("β").green(),
+ selected_language,
+ style("π").blue(),
+ component_name,
+ style("π‘").bright(),
+ style(main_file).cyan(),
+ style("π¨").bright(),
+ component_name,
+ style("π").yellow(),
+ );
+
+ Ok(())
+}
diff --git a/packages/ftl-cli/src/commands/build.rs b/packages/ftl-cli/src/commands/build.rs
index 4249ebab..489d583d 100644
--- a/packages/ftl-cli/src/commands/build.rs
+++ b/packages/ftl-cli/src/commands/build.rs
@@ -1,182 +1,177 @@
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
-use anyhow::Result;
+use anyhow::{Context, Result};
use console::style;
-use indicatif::{ProgressBar, ProgressStyle};
-
-use crate::{
- common::{
- build_utils::{format_size, get_file_size, optimize_wasm},
- manifest_utils::load_manifest_and_name,
- spin_installer::check_and_install_spin,
- tool_paths::{self, ensure_ftl_dir, get_profile_dir, get_spin_toml_path},
- },
- language::{Language, get_language_support},
- spin_generator::SpinConfig,
-};
-
-pub async fn execute(name: Option, profile: Option) -> Result<()> {
- let tool_path = name.unwrap_or_else(|| ".".to_string());
- build_tool(&tool_path, profile, false).await
-}
-
-pub async fn execute_quiet(tool_path: &str, profile: Option) -> Result<()> {
- build_tool(tool_path, profile, true).await
-}
-
-pub async fn execute_and_serve(name: Option, profile: Option) -> Result<()> {
- let tool_path = name.unwrap_or_else(|| ".".to_string());
- // First build the tool
- build_tool(&tool_path, profile, false).await?;
+use crate::{common::spin_installer::check_and_install_spin, manifest::ComponentManifest};
- // Then serve it
- println!();
- crate::commands::serve::execute(tool_path, 3000, false).await
-}
+pub async fn execute(path: Option, release: bool) -> Result<()> {
+ let working_path = path.unwrap_or_else(|| PathBuf::from("."));
-async fn build_tool(tool_path: &str, profile: Option, quiet: bool) -> Result<()> {
- if !quiet {
+ // Check if we're in a project directory (has spin.toml) or component directory (has ftl.toml)
+ if working_path.join("spin.toml").exists() {
+ // Project-level build - use spin build
println!(
- "{} Building tool: {}",
+ "{} Building project {}",
style("β").cyan(),
- style(tool_path).bold()
+ style(working_path.display()).bold()
);
- }
- // Validate tool directory exists
- crate::common::tool_paths::validate_tool_exists(tool_path)?;
-
- // Load and validate manifest
- let (manifest, tool_name) = load_manifest_and_name(tool_path)?;
- manifest.validate()?;
-
- // Determine build profile
- let build_profile = profile.unwrap_or_else(|| manifest.build.profile.clone());
-
- // Get language support
- let language_support = get_language_support(manifest.tool.language);
-
- // Ensure .ftl directory exists and check spin is installed
- ensure_ftl_dir(tool_path)?;
- check_and_install_spin().await?;
-
- // Generate spin.toml in .ftl directory
- match manifest.tool.language {
- Language::Rust => {
- // Generate spin.toml with build configuration for Rust
- let profile_dir = get_profile_dir(&build_profile);
- let wasm_filename = format!("{}.wasm", tool_name.replace('-', "_"));
- let relative_wasm_path = PathBuf::from("..")
- .join("target")
- .join("wasm32-wasip1")
- .join(profile_dir)
- .join(&wasm_filename);
-
- let spin_config = SpinConfig::from_tool(&manifest, &relative_wasm_path)?;
- let spin_path = get_spin_toml_path(tool_path);
- spin_config.save(&spin_path)?;
- }
- Language::JavaScript | Language::TypeScript => {
- // For JavaScript/TypeScript, spin.toml is already in .ftl directory
- // (moved during project creation) We don't need to
- // generate or copy it
+ let spin_path = check_and_install_spin().await?;
+ let mut child = Command::new(&spin_path)
+ .args(["build"])
+ .current_dir(&working_path)
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .context("Failed to run spin build")?;
+
+ let status = child.wait()?;
+ if !status.success() {
+ anyhow::bail!("Build failed");
}
+
+ println!("\n{} Project built successfully!", style("β").green());
+
+ return Ok(());
}
- // Create progress bar (only if not quiet)
- let pb = if quiet {
- ProgressBar::hidden()
- } else {
- let pb = ProgressBar::new(3);
- pb.set_style(
- ProgressStyle::default_bar()
- .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
- .unwrap()
- .progress_chars("#>-"),
- );
- pb
- };
+ // Component-level build
+ println!(
+ "{} Building component {}",
+ style("β").cyan(),
+ style(working_path.display()).bold()
+ );
+
+ // Validate component directory exists
+ if !working_path.join("ftl.toml").exists() {
+ anyhow::bail!("No ftl.toml or spin.toml found. Not in a component or project directory?");
+ }
- // Step 1: Run language-specific build
- let language = manifest.tool.language;
- pb.set_message(format!("Building {language} tool..."));
- pb.inc(1);
+ let component_path = working_path;
- // Validate language environment
- language_support.validate_environment()?;
+ // Load component manifest
+ let manifest = ComponentManifest::load(&component_path)?;
- // Run the language-specific build
- language_support.build(&manifest, std::path::Path::new(tool_path))?;
+ // Run build based on detected build system
+ println!("{} Running build...", style("βΆ").green());
+ println!();
- // Step 2: Verify WASM was built
- pb.set_message("Verifying build output...");
- pb.inc(1);
+ let status = if component_path.join("Makefile").exists() {
+ // Use make if available
+ Command::new("make")
+ .arg("build")
+ .current_dir(&component_path)
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .context("Failed to run make build")?
+ .wait()?
+ } else if component_path.join("handler/Cargo.toml").exists() {
+ // Rust component
+ let profile = if release { "release" } else { "debug" };
+ Command::new("cargo")
+ .args(["component", "build", "--target", "wasm32-wasip1"])
+ .arg(format!("--{profile}"))
+ .current_dir(component_path.join("handler"))
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .context("Failed to run cargo build")?
+ .wait()?
+ } else if component_path.join("handler/package.json").exists() {
+ // JavaScript/TypeScript component
+ // First install dependencies
+ println!("{} Installing dependencies...", style("β").dim());
+ let npm_install_status = Command::new("npm")
+ .arg("install")
+ .current_dir(component_path.join("handler"))
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .context("Failed to run npm install")?
+ .wait()?;
+
+ if !npm_install_status.success() {
+ anyhow::bail!("npm install failed");
+ }
- let wasm_path = tool_paths::get_wasm_path_for_language(
- tool_path,
- &tool_name,
- &build_profile,
- manifest.tool.language,
- );
+ // Then build
+ println!("\n{} Building component...", style("β").dim());
+ Command::new("npm")
+ .args(["run", "build"])
+ .current_dir(component_path.join("handler"))
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .context("Failed to run npm build")?
+ .wait()?
+ } else {
+ anyhow::bail!("Unable to determine build system for component");
+ };
- if !wasm_path.exists() {
- anyhow::bail!("WASM binary not found at: {}", wasm_path.display());
+ if !status.success() {
+ anyhow::bail!("Build failed");
}
- // For JavaScript/TypeScript, also copy the WASM to .ftl/dist directory for
- // deployment
- if matches!(
- manifest.tool.language,
- Language::JavaScript | Language::TypeScript
- ) {
- let ftl_dist_dir = PathBuf::from(tool_path).join(".ftl").join("dist");
- std::fs::create_dir_all(&ftl_dist_dir)?;
- let dest_wasm = ftl_dist_dir.join(format!("{tool_name}.wasm"));
- std::fs::copy(&wasm_path, &dest_wasm)?;
- }
+ // Verify output exists
+ let wasm_path = find_wasm_output(&component_path, &manifest)?;
+ let size = std::fs::metadata(&wasm_path)?.len();
- // Step 3: Run wasm-opt (post-build optimization) - only for Rust
- if manifest.tool.language == Language::Rust {
- pb.set_message("Optimizing WASM binary...");
- pb.inc(1);
-
- // Always include flags to match Rust's target features
- let mut wasm_opt_flags = vec![
- "--enable-simd".to_string(),
- "--enable-bulk-memory".to_string(),
- "--enable-mutable-globals".to_string(),
- "--enable-sign-ext".to_string(),
- "--enable-nontrapping-float-to-int".to_string(),
- "--enable-reference-types".to_string(),
- ];
-
- // Add user-specified flags
- if !manifest.optimization.flags.is_empty() {
- wasm_opt_flags.extend(manifest.optimization.flags.clone());
- } else {
- // Default optimization if none specified
- wasm_opt_flags.push("-O2".to_string());
- }
+ println!("\n{} Component built successfully!", style("β").green());
+ println!(" Output: {}", style(wasm_path.display()).dim());
+ println!(" Size: {}", style(format_size(size)).yellow());
- optimize_wasm(&wasm_path, &wasm_opt_flags)?;
- } else {
- pb.inc(1);
+ Ok(())
+}
+
+fn find_wasm_output(component_path: &Path, manifest: &ComponentManifest) -> Result {
+ // Check common output locations based on language
+ let possible_paths = vec![
+ // Rust
+ component_path
+ .join("handler/target/wasm32-wasip1/release")
+ .join(format!(
+ "{}.wasm",
+ manifest.component.name.replace('-', "_")
+ )),
+ component_path
+ .join("handler/target/wasm32-wasip1/debug")
+ .join(format!(
+ "{}.wasm",
+ manifest.component.name.replace('-', "_")
+ )),
+ // JavaScript/TypeScript
+ component_path.join("handler/dist/handler.wasm"),
+ component_path
+ .join("handler/dist")
+ .join(format!("{}.wasm", manifest.component.name)),
+ ];
+
+ for path in possible_paths {
+ if path.exists() {
+ return Ok(path);
+ }
}
- pb.finish_with_message("Build complete!");
+ anyhow::bail!("Could not find built WASM file. Build may have failed.")
+}
- // Display binary size (only if not quiet)
- if !quiet {
- let size = get_file_size(&wasm_path)?;
+fn format_size(bytes: u64) -> String {
+ const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
+ let mut size = bytes as f64;
+ let mut unit_index = 0;
- println!();
- println!("{} Build successful!", style("β").green());
- println!(" Binary: {}", wasm_path.display());
- let size = format_size(size);
- println!(" Size: {size}");
- println!(" Profile: {build_profile}");
+ while size >= 1024.0 && unit_index < UNITS.len() - 1 {
+ size /= 1024.0;
+ unit_index += 1;
}
- Ok(())
+ format!("{:.2} {}", size, UNITS[unit_index])
}
diff --git a/packages/ftl-cli/src/commands/delete.rs b/packages/ftl-cli/src/commands/delete.rs
deleted file mode 100644
index ac2c70dc..00000000
--- a/packages/ftl-cli/src/commands/delete.rs
+++ /dev/null
@@ -1,80 +0,0 @@
-use std::{
- io::{self, Write},
- process::Command,
-};
-
-use anyhow::Result;
-use console::style;
-
-use crate::common::deploy_utils::infer_app_name;
-
-pub async fn execute(name: Option, yes: bool) -> Result<()> {
- // Check if spin is installed
- if which::which("spin").is_err() {
- anyhow::bail!(
- "Spin CLI not found. Please install it from: https://developer.fermyon.com/spin/install"
- );
- }
-
- // Get the app name - either provided or inferred from current directory
- let app_name = match name {
- Some(n) => n,
- None => infer_app_name(".")?,
- };
-
- // Confirm deletion unless --yes flag is provided
- if !yes {
- print!(
- "{} Are you sure you want to delete '{}'? [y/N] ",
- style("?").yellow(),
- style(&app_name).bold()
- );
- io::stdout().flush()?;
-
- let mut response = String::new();
- io::stdin().read_line(&mut response)?;
-
- if !response.trim().eq_ignore_ascii_case("y") {
- println!("Deletion cancelled.");
- return Ok(());
- }
- }
-
- println!(
- "{} Deleting tool/toolkit: {}",
- style("β").cyan(),
- style(&app_name).bold()
- );
-
- // Run spin aka app delete with --app-name flag and --no-confirm
- let output = Command::new("spin")
- .args([
- "aka",
- "app",
- "delete",
- "--app-name",
- &app_name,
- "--no-confirm",
- ])
- .output()?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- if stderr.contains("not logged in") || stderr.contains("authentication") {
- anyhow::bail!("Not authenticated with FTL Edge. Please run: ftl login");
- }
- if stderr.contains("not found") || stderr.contains("does not exist") {
- anyhow::bail!(
- "Tool/toolkit '{app_name}' not found. Use 'ftl list' to see deployed tools and toolkits."
- );
- }
- anyhow::bail!("Failed to delete tool/toolkit:\n{stderr}");
- }
-
- println!(
- "{} Tool/toolkit '{app_name}' deleted successfully",
- style("β").green()
- );
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/deploy.rs b/packages/ftl-cli/src/commands/deploy.rs
index 1a0932f3..2d6f1373 100644
--- a/packages/ftl-cli/src/commands/deploy.rs
+++ b/packages/ftl-cli/src/commands/deploy.rs
@@ -1,91 +1,75 @@
-use anyhow::Result;
+use std::path::PathBuf;
+use std::process::Command;
+
+use anyhow::{Context, Result};
use console::style;
-use indicatif::{ProgressBar, ProgressStyle};
-use crate::common::{
- config::FtlConfig,
- manifest_utils::load_manifest_and_name,
- spin_installer::check_and_install_spin,
- spin_utils::{check_akamai_auth, deploy_to_akamai},
- tool_paths::{get_spin_toml_path, validate_tool_exists},
-};
+use crate::common::spin_installer::check_and_install_spin;
-pub async fn execute(tool_path: String) -> Result<()> {
+pub async fn execute(environment: Option) -> Result<()> {
println!(
- "{} Deploying tool: {}",
+ "{} Deploying project{}",
style("β").cyan(),
- style(&tool_path).bold()
+ environment
+ .as_ref()
+ .map(|e| format!(" to {e}"))
+ .unwrap_or_default()
);
- // Validate tool exists and load manifest
- validate_tool_exists(&tool_path)?;
- let (_manifest, tool_name) = load_manifest_and_name(&tool_path)?;
-
- // Ensure tool is built with production profile
- println!("{} Building release version...", style("β").cyan());
- crate::commands::build::execute(Some(tool_path.clone()), Some("release".to_string())).await?;
-
- // Check if spin.toml exists
- let spin_path = get_spin_toml_path(&tool_path);
- if !spin_path.exists() {
- anyhow::bail!(".ftl/spin.toml not found. This should have been created during build.");
+ // Check if we're in a Spin project directory
+ if !PathBuf::from("spin.toml").exists() {
+ anyhow::bail!("No spin.toml found. Not in a project directory?");
}
- // Get spin path and check Akamai authentication
+ // Get spin path
let spin_path = check_and_install_spin().await?;
- check_akamai_auth(&spin_path).await?;
- // Deploy using spin aka with spinner
- let spinner = ProgressBar::new_spinner();
- spinner.set_style(
- ProgressStyle::default_spinner()
- .template("{spinner:.cyan} {msg}")
- .unwrap()
- .tick_strings(&["β ", "β ", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ", "β "]),
- );
- spinner.set_message("Deploying to FTL Edge...");
- spinner.enable_steady_tick(std::time::Duration::from_millis(80));
+ // Build the project first
+ println!("{} Building project...", style("β").dim());
+ let build_output = Command::new(&spin_path)
+ .args(["build"])
+ .output()
+ .context("Failed to build project")?;
- // Load config and generate app name with user prefix
- let config = FtlConfig::load().unwrap_or_default();
- let prefix = config.get_app_prefix();
- let app_name = format!("{prefix}{tool_name}");
+ if !build_output.status.success() {
+ anyhow::bail!(
+ "Build failed:\n{}",
+ String::from_utf8_lossy(&build_output.stderr)
+ );
+ }
- // Deploy with the generated app name
- let deployment_result = deploy_to_akamai(&tool_path, Some(&app_name)).await;
+ // Deploy using spin deploy
+ println!("{} Deploying to FTL...", style("β").dim());
+ let mut deploy_args = vec!["deploy"];
- // Handle deployment result
- match deployment_result {
- Ok(deployment_info) => {
- spinner.finish_and_clear();
- // Ensure URL includes /mcp path
- let full_url = if deployment_info.url.ends_with("/mcp") {
- deployment_info.url.clone()
- } else {
- let url = deployment_info.url.trim_end_matches('/');
- format!("{url}/mcp")
- };
+ if let Some(env) = &environment {
+ deploy_args.extend(["--environment-name", env]);
+ }
- println!("{} Deployment successful!", style("β").green());
- println!(" Name: {}", style(&deployment_info.app_name).cyan());
- println!(" URL: {}", style(&full_url).yellow().bold());
- println!();
- println!("Test your tool:");
- println!(" curl -X POST {full_url} \\");
- println!(" -H \"Content-Type: application/json\" \\");
- println!(" -d '{{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":1}}'");
- println!();
- println!("Manage your deployment:");
- let app_name = &deployment_info.app_name;
- println!(" ftl status {app_name}");
- println!(" ftl logs {app_name}");
- println!(" ftl delete {app_name}");
- Ok(())
- }
- Err(e) => {
- spinner.finish_and_clear();
- println!("{} Deployment failed", style("β").red());
- anyhow::bail!("{e}");
+ let deploy_output = Command::new(&spin_path)
+ .args(&deploy_args)
+ .output()
+ .context("Failed to deploy project")?;
+
+ if !deploy_output.status.success() {
+ let stderr = String::from_utf8_lossy(&deploy_output.stderr);
+
+ if stderr.contains("not logged in") {
+ anyhow::bail!("Not logged in to FTL. Run 'spin login' first.");
}
+
+ anyhow::bail!("Deploy failed:\n{}", stderr);
}
+
+ // Parse deployment URL
+ let output_str = String::from_utf8_lossy(&deploy_output.stdout);
+ if let Some(url_line) = output_str.lines().find(|l| l.contains("https://")) {
+ println!();
+ println!("{} Project deployed successfully!", style("β").green());
+ println!(" URL: {}", style(url_line.trim()).cyan());
+ } else {
+ println!("{} Project deployed successfully!", style("β").green());
+ }
+
+ Ok(())
}
diff --git a/packages/ftl-cli/src/commands/export.rs b/packages/ftl-cli/src/commands/export.rs
deleted file mode 100644
index 7dadb40f..00000000
--- a/packages/ftl-cli/src/commands/export.rs
+++ /dev/null
@@ -1,190 +0,0 @@
-use std::{
- fs,
- path::{Path, PathBuf},
- process::Command,
-};
-
-use anyhow::{Context, Result};
-use console::style;
-
-use crate::common::{
- manifest_utils::load_manifest_and_name,
- tool_paths::{self, get_profile_dir, validate_tool_exists},
-};
-
-const WASI_ADAPTER_URL: &str = "https://github.com/bytecodealliance/wasmtime/releases/download/v22.0.0/wasi_snapshot_preview1.reactor.wasm";
-
-pub async fn execute(
- name: Option,
- output: Option,
- profile: Option,
-) -> Result<()> {
- let tool_path = name.unwrap_or_else(|| ".".to_string());
-
- println!(
- "{} Exporting tool: {}",
- style("β").cyan(),
- style(&tool_path).bold()
- );
-
- // Validate tool directory exists
- validate_tool_exists(&tool_path)?;
-
- // Load manifest to get tool name
- let (manifest, tool_name) = load_manifest_and_name(&tool_path)?;
-
- // Determine build profile
- let build_profile = profile.unwrap_or_else(|| manifest.build.profile.clone());
- let language = manifest.tool.language;
-
- // Get the WASM path based on language
- let wasm_path =
- tool_paths::get_wasm_path_for_language(&tool_path, &tool_name, &build_profile, language);
-
- if !wasm_path.exists() {
- let display = wasm_path.display();
- anyhow::bail!("WASM file not found at {display}. Please run 'ftl build' first.");
- }
-
- // Check if wasm-tools is installed
- if which::which("wasm-tools").is_err() {
- anyhow::bail!(
- "wasm-tools CLI not found. Please install it from: https://github.com/bytecodealliance/wasm-tools"
- );
- }
-
- // Determine output path
- let output_path = match output {
- Some(path) => path,
- None => {
- use crate::language::Language;
- match language {
- Language::Rust => {
- let profile_dir = get_profile_dir(&build_profile);
- PathBuf::from(&tool_path)
- .join("target")
- .join("wasm32-wasip1")
- .join(profile_dir)
- .join(format!("{}.component.wasm", tool_name.replace('-', "_")))
- }
- Language::JavaScript | Language::TypeScript => {
- // For JavaScript/TypeScript, put the component next to the WASM file in dist
- PathBuf::from(&tool_path)
- .join("dist")
- .join(format!("{tool_name}.component.wasm"))
- }
- }
- }
- };
-
- // Ensure output directory exists
- if let Some(parent) = output_path.parent() {
- fs::create_dir_all(parent)?;
- }
-
- // Check if the WASM file is already a component
- let validate_output = Command::new("wasm-tools")
- .args([
- "validate",
- wasm_path.to_str().unwrap(),
- "--features",
- "component-model",
- ])
- .output()
- .context("Failed to run wasm-tools validate")?;
-
- let is_already_component = validate_output.status.success();
-
- if is_already_component {
- // For JavaScript, the WASM is already a component, just copy it
- println!(
- "{} WASM is already a component, copying...",
- style("β").cyan()
- );
- fs::copy(&wasm_path, &output_path).context("Failed to copy component")?;
- } else {
- // For Rust, we need to componentize the module
- // Download WASI adapter if not already cached
- let adapter_path = get_wasi_adapter_path()?;
- if !adapter_path.exists() {
- println!("{} Downloading WASI adapter...", style("β").cyan());
- download_wasi_adapter(&adapter_path).await?;
- }
-
- // Run wasm-tools component new
- println!("{} Creating WASM component...", style("β").cyan());
-
- let output = Command::new("wasm-tools")
- .args([
- "component",
- "new",
- wasm_path.to_str().unwrap(),
- "-o",
- output_path.to_str().unwrap(),
- "--adapt",
- adapter_path.to_str().unwrap(),
- ])
- .output()
- .context("Failed to run wasm-tools")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("Failed to create WASM component:\n{stderr}");
- }
- }
-
- let component_size = fs::metadata(&output_path)?.len();
-
- println!();
- println!("{} Export successful!", style("β").green());
- println!(" Component: {}", output_path.display());
- let size = format_file_size(component_size);
- println!(" Size: {size}");
- println!();
- println!("You can now serve this component with:");
- println!(" wasmtime serve -Scli {}", output_path.display());
-
- Ok(())
-}
-
-fn get_wasi_adapter_path() -> Result {
- let cache_dir = dirs::cache_dir()
- .ok_or_else(|| anyhow::anyhow!("Could not find cache directory"))?
- .join("ftl");
-
- fs::create_dir_all(&cache_dir)?;
-
- Ok(cache_dir.join("wasi_snapshot_preview1.reactor.wasm"))
-}
-
-async fn download_wasi_adapter(path: &Path) -> Result<()> {
- let response = reqwest::get(WASI_ADAPTER_URL)
- .await
- .context("Failed to download WASI adapter")?;
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Failed to download WASI adapter: HTTP {}",
- response.status()
- );
- }
-
- let bytes = response.bytes().await?;
- fs::write(path, bytes).context("Failed to write WASI adapter")?;
-
- Ok(())
-}
-
-fn format_file_size(size: u64) -> String {
- const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
- let mut size = size as f64;
- let mut unit_index = 0;
-
- while size >= 1024.0 && unit_index < UNITS.len() - 1 {
- size /= 1024.0;
- unit_index += 1;
- }
-
- let unit = UNITS[unit_index];
- format!("{size:.2} {unit}")
-}
diff --git a/packages/ftl-cli/src/commands/init.rs b/packages/ftl-cli/src/commands/init.rs
new file mode 100644
index 00000000..45a6683c
--- /dev/null
+++ b/packages/ftl-cli/src/commands/init.rs
@@ -0,0 +1,112 @@
+use std::path::PathBuf;
+use std::process::Command;
+
+use anyhow::{Context, Result};
+use console::style;
+use dialoguer::{Input, theme::ColorfulTheme};
+
+use crate::common::spin_installer::check_and_install_spin;
+
+pub async fn execute(name: Option, here: bool) -> Result<()> {
+ // Get project name interactively if not provided
+ let project_name = match name {
+ Some(n) => n,
+ None => Input::::with_theme(&ColorfulTheme::default())
+ .with_prompt("Project name")
+ .interact_text()?,
+ };
+
+ println!(
+ "{} Initializing new MCP project: {}",
+ style("β").cyan(),
+ style(&project_name).bold()
+ );
+
+ // Validate project name
+ if !project_name
+ .chars()
+ .all(|c| c.is_lowercase() || c == '-' || c.is_numeric())
+ {
+ anyhow::bail!("Project name must be lowercase with hyphens (e.g., my-project)");
+ }
+
+ // Don't allow leading or trailing hyphens, or double hyphens
+ if project_name.starts_with('-') || project_name.ends_with('-') || project_name.contains("--") {
+ anyhow::bail!("Project name cannot start or end with hyphens, or contain double hyphens");
+ }
+
+ // Get spin path
+ let spin_path = check_and_install_spin().await?;
+
+ // Determine output directory
+ let output_dir = if here {
+ ".".to_string()
+ } else {
+ project_name.clone()
+ };
+
+ // Check if directory exists and is not empty (unless using --here)
+ if !here && PathBuf::from(&output_dir).exists() {
+ anyhow::bail!("Directory '{}' already exists", project_name);
+ } else if here {
+ let current_dir = std::env::current_dir()?;
+ if current_dir.read_dir()?.next().is_some() {
+ anyhow::bail!("Current directory is not empty");
+ }
+ }
+
+ // Use spin new with http-empty template to create the project container
+ let mut spin_cmd = Command::new(&spin_path);
+ spin_cmd.args([
+ "new",
+ "-t",
+ "http-empty",
+ "-o",
+ &output_dir,
+ "--accept-defaults",
+ ]);
+
+ if !here {
+ spin_cmd.arg(&project_name);
+ }
+
+ let output = spin_cmd.output().context("Failed to run spin new")?;
+
+ if !output.status.success() {
+ anyhow::bail!(
+ "Failed to create project:\n{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+
+ let cd_instruction = if here {
+ ""
+ } else {
+ &format!("cd {project_name} && ")
+ };
+
+ println!(
+ r#"
+{} MCP project initialized!
+
+{} Structure:
+ βββ spin.toml # Spin project manifest
+
+{} Next steps:
+ {}ftl add # Add a component to the project
+ ftl watch # Start development server with auto-rebuild
+
+{} Example:
+ {}ftl add weather-api --language typescript
+ {}ftl add calculator --language rust"#,
+ style("β").green(),
+ style("π").blue(),
+ style("π").yellow(),
+ cd_instruction,
+ style("π‘").bright(),
+ cd_instruction,
+ cd_instruction
+ );
+
+ Ok(())
+}
diff --git a/packages/ftl-cli/src/commands/link.rs b/packages/ftl-cli/src/commands/link.rs
deleted file mode 100644
index 18b6e25e..00000000
--- a/packages/ftl-cli/src/commands/link.rs
+++ /dev/null
@@ -1,77 +0,0 @@
-use std::process::Command;
-
-use anyhow::Result;
-use console::style;
-
-use crate::common::tool_paths::validate_tool_exists;
-
-pub async fn execute(name: String, path: Option) -> Result<()> {
- let tool_path = path.unwrap_or_else(|| ".".to_string());
-
- // Validate tool directory exists
- validate_tool_exists(&tool_path)?;
-
- // Check if spin is installed
- if which::which("spin").is_err() {
- anyhow::bail!(
- "Spin CLI not found. Please install it from: https://developer.fermyon.com/spin/install"
- );
- }
-
- println!(
- "{} Linking tool to deployment: {}",
- style("β").cyan(),
- style(&name).bold()
- );
-
- // Check if .ftl/spin.toml exists
- let spin_toml = std::path::Path::new(&tool_path).join(".ftl/spin.toml");
- if !spin_toml.exists() {
- anyhow::bail!(".ftl/spin.toml not found. Please build the tool first with: ftl build");
- }
-
- // Run spin aka app link with --app-name flag
- let output = Command::new("spin")
- .args([
- "aka",
- "app",
- "link",
- "--app-name",
- &name,
- "-f",
- ".ftl/spin.toml",
- ])
- .current_dir(&tool_path)
- .output()?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- if stderr.contains("not logged in") || stderr.contains("authentication") {
- anyhow::bail!("Not authenticated with FTL Edge. Please run: ftl login");
- }
- if stderr.contains("not found") || stderr.contains("does not exist") {
- anyhow::bail!(
- "Tool/toolkit '{name}' not found in FTL Edge. Use 'ftl list' to see available \
- deployments."
- );
- }
- if stderr.contains("already linked") {
- anyhow::bail!(
- "This tool is already linked to a deployment. Use 'ftl unlink' first to unlink it."
- );
- }
- anyhow::bail!("Failed to link tool:\n{stderr}");
- }
-
- println!(
- "{} Tool successfully linked to '{name}'",
- style("β").green()
- );
- println!();
- println!("You can now:");
- println!(" ftl deploy # Deploy updates to the linked tool");
- println!(" ftl logs {name} # View logs");
- println!(" ftl status {name} # Check status");
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/list.rs b/packages/ftl-cli/src/commands/list.rs
deleted file mode 100644
index a9b65c0a..00000000
--- a/packages/ftl-cli/src/commands/list.rs
+++ /dev/null
@@ -1,45 +0,0 @@
-use std::process::Command;
-
-use anyhow::Result;
-use console::style;
-
-pub async fn execute() -> Result<()> {
- // Check if spin is installed
- if which::which("spin").is_err() {
- anyhow::bail!(
- "Spin CLI not found. Please install it from: https://developer.fermyon.com/spin/install"
- );
- }
-
- println!(
- "{} Listing deployed tools and toolkits...",
- style("β").cyan()
- );
- println!();
-
- // Run spin aka app list
- let output = Command::new("spin").args(["aka", "app", "list"]).output()?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- if stderr.contains("not logged in") || stderr.contains("authentication") {
- anyhow::bail!("Not authenticated with FTL Edge. Please run: ftl login");
- }
- anyhow::bail!("Failed to list tools and toolkits:\n{stderr}");
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
-
- // Check if there are no apps
- if stdout.trim().is_empty() || stdout.contains("No apps") {
- println!("No tools or toolkits deployed yet.");
- println!();
- println!("Deploy your first tool with:");
- println!(" ftl deploy ");
- } else {
- // Print the output as-is (spin aka app list has nice formatting)
- print!("{stdout}");
- }
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/login.rs b/packages/ftl-cli/src/commands/login.rs
deleted file mode 100644
index 952c8b07..00000000
--- a/packages/ftl-cli/src/commands/login.rs
+++ /dev/null
@@ -1,84 +0,0 @@
-use std::{
- io::{BufRead, BufReader},
- process::{Command, Stdio},
-};
-
-use anyhow::Result;
-use console::style;
-
-use crate::common::config::FtlConfig;
-
-pub async fn execute() -> Result<()> {
- // Check if spin is installed
- if which::which("spin").is_err() {
- anyhow::bail!(
- "Spin CLI not found. Please install it from: https://developer.fermyon.com/spin/install"
- );
- }
-
- println!("{} Logging in to FTL Edge...", style("β").cyan());
- println!();
-
- // Run spin aka auth login and capture output to parse username
- let mut child = Command::new("spin")
- .args(["aka", "auth", "login"])
- .stdin(Stdio::inherit())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()?;
-
- let stdout = child.stdout.take().expect("Failed to capture stdout");
- let stderr = child.stderr.take().expect("Failed to capture stderr");
-
- // Read output line by line, looking for "Welcome, username."
- let stdout_reader = BufReader::new(stdout);
- let stderr_reader = BufReader::new(stderr);
-
- // Print stdout and look for username
- let stdout_handle = std::thread::spawn(move || {
- let mut username = None;
- for line in stdout_reader.lines().map_while(Result::ok) {
- println!("{line}");
- if line.starts_with("Welcome, ") && line.ends_with('.') {
- // Extract username from "Welcome, username."
- let user = line
- .trim_start_matches("Welcome, ")
- .trim_end_matches('.')
- .to_string();
- username = Some(user);
- }
- }
- username
- });
-
- // Print stderr
- for line in stderr_reader.lines().map_while(Result::ok) {
- eprintln!("{line}");
- }
-
- let status = child.wait()?;
- let captured_username = stdout_handle.join().unwrap();
-
- if status.success() {
- println!();
- println!("{} Successfully logged in to FTL Edge!", style("β").green());
-
- // Load existing config
- let mut config = FtlConfig::load().unwrap_or_default();
-
- // Save username if we captured it
- if let Some(username) = captured_username {
- config.username = Some(username.clone());
- config.save()?;
- }
-
- println!();
- println!("You can now:");
- println!(" β’ Deploy tools with: ftl deploy");
- println!(" β’ List your tools with: ftl list");
- } else {
- anyhow::bail!("Login failed");
- }
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/logs.rs b/packages/ftl-cli/src/commands/logs.rs
deleted file mode 100644
index 48d44568..00000000
--- a/packages/ftl-cli/src/commands/logs.rs
+++ /dev/null
@@ -1,57 +0,0 @@
-use std::process::{Command, Stdio};
-
-use anyhow::Result;
-use console::style;
-
-use crate::common::deploy_utils::infer_app_name;
-
-pub async fn execute(name: Option, _follow: bool, tail: Option) -> Result<()> {
- // Check if spin is installed
- if which::which("spin").is_err() {
- anyhow::bail!(
- "Spin CLI not found. Please install it from: https://developer.fermyon.com/spin/install"
- );
- }
-
- // Get the app name - either provided or inferred from current directory
- let app_name = match name {
- Some(n) => n,
- None => infer_app_name(".")?,
- };
-
- println!(
- "{} Fetching logs for: {}",
- style("β").cyan(),
- style(&app_name).bold()
- );
-
- let mut args = vec!["aka", "app", "logs", "--app-name", &app_name];
-
- // Add tail option if specified
- let tail_str;
- if let Some(lines) = tail {
- tail_str = lines.to_string();
- args.push("--tail");
- args.push(&tail_str);
- }
-
- // Note: spin aka app logs doesn't support --follow yet
-
- // Run spin aka app logs with inherited stdio for real-time output
- let mut child = Command::new("spin")
- .args(&args)
- .stdin(Stdio::inherit())
- .stdout(Stdio::inherit())
- .stderr(Stdio::inherit())
- .spawn()?;
-
- // Wait for the command to complete
- let status = child.wait()?;
-
- if !status.success() {
- // Error handling is done by spin itself with inherited stderr
- std::process::exit(status.code().unwrap_or(1));
- }
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/mod.rs b/packages/ftl-cli/src/commands/mod.rs
index 347d8991..5a6cc4b4 100644
--- a/packages/ftl-cli/src/commands/mod.rs
+++ b/packages/ftl-cli/src/commands/mod.rs
@@ -1,18 +1,10 @@
+pub mod add;
pub mod build;
-pub mod delete;
pub mod deploy;
-pub mod export;
-pub mod link;
-pub mod list;
-pub mod login;
-pub mod logs;
-pub mod new;
-pub mod serve;
-pub mod size;
-pub mod spin;
-pub mod status;
+pub mod init;
+pub mod publish;
+pub mod registry;
+pub mod setup;
pub mod test;
-pub mod toolkit;
-pub mod unlink;
-pub mod validate;
+pub mod up;
pub mod watch;
diff --git a/packages/ftl-cli/src/commands/new.rs b/packages/ftl-cli/src/commands/new.rs
deleted file mode 100644
index d97e8e71..00000000
--- a/packages/ftl-cli/src/commands/new.rs
+++ /dev/null
@@ -1,110 +0,0 @@
-use std::path::PathBuf;
-
-use anyhow::Result;
-use console::style;
-use dialoguer::{Input, Select, theme::ColorfulTheme};
-
-use crate::{
- language::{Language, get_language_support},
- templates,
-};
-
-pub async fn execute(
- name: String,
- description: Option,
- language: Option,
-) -> Result<()> {
- println!(
- "{} Creating new tool: {}",
- style("β").cyan(),
- style(&name).bold()
- );
-
- // Validate tool name
- if !name
- .chars()
- .all(|c| c.is_lowercase() || c == '-' || c.is_numeric())
- {
- anyhow::bail!("Tool name must be lowercase with hyphens (e.g., my-tool)");
- }
-
- // Don't allow leading or trailing hyphens, or double hyphens
- if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
- anyhow::bail!("Tool name cannot start or end with hyphens, or contain double hyphens");
- }
-
- // Get description interactively if not provided
- let description = match description {
- Some(d) => d,
- None => Input::::with_theme(&ColorfulTheme::default())
- .with_prompt("Tool description")
- .interact_text()?,
- };
-
- // Determine language
- let selected_language = match language {
- Some(lang_str) => Language::from_str(&lang_str).ok_or_else(|| {
- anyhow::anyhow!(
- "Invalid language: {lang_str}. Valid options are: rust, javascript, typescript"
- )
- })?,
- None => {
- // Interactive language selection
- let languages = vec!["rust", "javascript", "typescript"];
- let selection = Select::with_theme(&ColorfulTheme::default())
- .with_prompt("Select programming language")
- .items(&languages)
- .default(0)
- .interact()?;
-
- Language::from_str(languages[selection]).unwrap()
- }
- };
-
- // Determine target directory
- let target_dir = PathBuf::from(&name);
- if target_dir.exists() {
- anyhow::bail!("Directory '{name}' already exists");
- }
-
- // Create tool using language-specific support
- let language_support = get_language_support(selected_language);
-
- // Use templates for Rust (existing), or language-specific for others
- match selected_language {
- Language::Rust => {
- templates::create_tool(&name, &description, &target_dir)?;
- }
- Language::JavaScript | Language::TypeScript => {
- language_support.new_project(&name, &description, "default", &target_dir)?;
- }
- }
-
- // Success message based on language
- let main_file = match selected_language {
- Language::Rust => "src/lib.rs",
- Language::JavaScript => "src/index.js",
- Language::TypeScript => "src/index.ts",
- };
-
- println!(
- r#"
-{} {selected_language} tool created successfully!
-
-Next steps:
- 1. cd {name}
- 2. ftl build # Build your tool
- 3. ftl test # Run the included tests
- 4. ftl serve # Start development server
-
-Then edit {main_file} to implement your tool logic!
-
-Other commands:
- ftl deploy # Deploy to FTL Edge
- ftl validate # Validate tool configuration
- ftl size # Show binary size details"#,
- style("β").green()
- );
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/publish.rs b/packages/ftl-cli/src/commands/publish.rs
new file mode 100644
index 00000000..d20e2468
--- /dev/null
+++ b/packages/ftl-cli/src/commands/publish.rs
@@ -0,0 +1,157 @@
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use anyhow::{Context, Result};
+use console::style;
+
+use crate::manifest::ComponentManifest;
+
+pub async fn execute(
+ path: Option,
+ registry: Option,
+ tag: Option,
+) -> Result<()> {
+ let component_path = path.unwrap_or_else(|| PathBuf::from("."));
+
+ println!("{} Publishing component", style("β").cyan());
+
+ // Validate component directory exists
+ if !component_path.join("ftl.toml").exists() {
+ anyhow::bail!("No ftl.toml found. Not in a component directory?");
+ }
+
+ // Load component manifest
+ let manifest = ComponentManifest::load(&component_path)?;
+ let version = tag.as_ref().unwrap_or(&manifest.component.version);
+
+ // Use make registry-push if Makefile exists
+ if component_path.join("Makefile").exists() {
+ println!("{} Using Makefile to publish...", style("β").dim());
+
+ let output = Command::new("make")
+ .arg("registry-push")
+ .current_dir(&component_path)
+ .output()
+ .context("Failed to run make registry-push")?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+
+ // Check if wkg is missing
+ if stderr.contains("wkg: command not found") || stderr.contains("wkg: not found") {
+ anyhow::bail!(
+ "The 'wkg' tool is required for publishing. Install it from: https://github.com/bytecodealliance/wasm-pkg-tools"
+ );
+ }
+
+ anyhow::bail!("Publishing failed:\n{}\n{}", stdout, stderr);
+ }
+
+ // Parse the output to get the published URL
+ let output_str = String::from_utf8_lossy(&output.stdout);
+ if let Some(line) = output_str.lines().find(|l| l.contains("Pushing ghcr.io/")) {
+ println!();
+ println!("{} Component published!", style("β").green());
+ println!(" {}", style(line.trim_start_matches("Pushing ")).cyan());
+ } else {
+ println!("{} Component published successfully!", style("β").green());
+ }
+ } else {
+ // Manual publish flow
+ let registry_url = registry.as_deref().unwrap_or("ghcr.io");
+
+ // Get username from git config
+ let username_output = Command::new("git")
+ .args(["config", "user.name"])
+ .output()
+ .context("Failed to get git username")?;
+
+ let username = String::from_utf8_lossy(&username_output.stdout)
+ .trim()
+ .to_lowercase()
+ .replace(' ', "-");
+
+ if username.is_empty() {
+ anyhow::bail!("Could not determine username from git config");
+ }
+
+ // Build component first
+ println!("{} Building component...", style("β").dim());
+ crate::commands::build::execute(Some(component_path.clone()), true).await?;
+
+ // Find the built WASM file
+ let wasm_path = find_wasm_file(&component_path, &manifest)?;
+
+ // Construct package URL
+ let package_name = format!(
+ "{}/{}/{}:{}",
+ registry_url, username, manifest.component.name, version
+ );
+
+ println!("{} Publishing to {}...", style("β").dim(), package_name);
+
+ // Use wkg to push
+ let output = Command::new("wkg")
+ .args(["oci", "push", &package_name, wasm_path.to_str().unwrap()])
+ .output()
+ .context("Failed to run wkg")?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ if stderr.contains("not found") && stderr.contains("wkg") {
+ anyhow::bail!(
+ "The 'wkg' tool is required for publishing. Install it from: https://github.com/bytecodealliance/wasm-pkg-tools"
+ );
+ }
+
+ anyhow::bail!("Publishing failed:\n{}", stderr);
+ }
+
+ println!();
+ println!("{} Component published!", style("β").green());
+ println!(" {}", style(&package_name).cyan());
+ }
+
+ println!();
+ println!("{} Next steps:", style("β").dim());
+ println!(
+ " - Use 'ftl project add {}' to add this component to a project",
+ manifest.component.name
+ );
+ println!(
+ " - Share the registry URL: {}",
+ tag.as_ref()
+ .map(|t| format!("{}:{}", manifest.component.name, t))
+ .unwrap_or_else(|| manifest.component.name.clone())
+ );
+
+ Ok(())
+}
+
+fn find_wasm_file(component_path: &Path, manifest: &ComponentManifest) -> Result {
+ // Check common locations
+ let candidates = vec![
+ // Rust
+ component_path
+ .join("handler/target/wasm32-wasip1/release")
+ .join(format!(
+ "{}.wasm",
+ manifest.component.name.replace('-', "_")
+ )),
+ // JS/TS
+ component_path.join("handler/dist/handler.wasm"),
+ component_path
+ .join("handler/dist")
+ .join(format!("{}.wasm", manifest.component.name)),
+ ];
+
+ for path in candidates {
+ if path.exists() {
+ return Ok(path);
+ }
+ }
+
+ anyhow::bail!("Could not find built WASM file. Did you run 'ftl build'?")
+}
diff --git a/packages/ftl-cli/src/commands/registry.rs b/packages/ftl-cli/src/commands/registry.rs
new file mode 100644
index 00000000..5238c37a
--- /dev/null
+++ b/packages/ftl-cli/src/commands/registry.rs
@@ -0,0 +1,64 @@
+use anyhow::Result;
+use console::style;
+
+pub async fn list(registry: Option) -> Result<()> {
+ let registry_url = registry.as_deref().unwrap_or("ghcr.io");
+
+ println!(
+ "{} Listing components from {}",
+ style("β").cyan(),
+ style(registry_url).bold()
+ );
+
+ println!();
+ println!(
+ "{} Registry listing not yet implemented",
+ style("!").yellow()
+ );
+ println!();
+ println!("For now, you can browse components at:");
+ println!(" - GitHub Container Registry: https://github.com/orgs/YOUR_ORG/packages");
+ println!(" - Docker Hub: https://hub.docker.com/");
+
+ Ok(())
+}
+
+pub async fn search(query: String, registry: Option) -> Result<()> {
+ let registry_url = registry.as_deref().unwrap_or("ghcr.io");
+
+ println!(
+ "{} Searching for '{}' in {}",
+ style("β").cyan(),
+ style(&query).bold(),
+ style(registry_url).dim()
+ );
+
+ println!();
+ println!(
+ "{} Registry search not yet implemented",
+ style("!").yellow()
+ );
+ println!();
+ println!("For now, you can search at:");
+ println!(" - GitHub: https://github.com/search?q=mcp+{query}&type=registrypackages");
+
+ Ok(())
+}
+
+pub async fn info(component: String) -> Result<()> {
+ println!(
+ "{} Getting info for component: {}",
+ style("β").cyan(),
+ style(&component).bold()
+ );
+
+ println!();
+ println!("{} Registry info not yet implemented", style("!").yellow());
+ println!();
+ println!("Component reference formats:");
+ println!(" - ghcr.io/username/component:version");
+ println!(" - docker.io/username/component:version");
+ println!(" - component-name (searches default registry)");
+
+ Ok(())
+}
diff --git a/packages/ftl-cli/src/commands/serve.rs b/packages/ftl-cli/src/commands/serve.rs
deleted file mode 100644
index 618f7e55..00000000
--- a/packages/ftl-cli/src/commands/serve.rs
+++ /dev/null
@@ -1,219 +0,0 @@
-use std::{
- path::PathBuf,
- sync::{
- Arc,
- atomic::{AtomicBool, Ordering},
- },
- time::Duration,
-};
-
-use anyhow::Result;
-use console::style;
-use tokio::{signal, time::sleep};
-use tracing::{debug, warn};
-
-use crate::{
- common::{
- manifest_utils::load_manifest_and_name,
- spin_installer::check_and_install_spin,
- spin_utils::start_spin_server_with_path,
- tool_paths::{
- ensure_ftl_dir, get_profile_dir, get_spin_toml_path, get_wasm_path,
- validate_tool_exists,
- },
- watch_utils::{Debouncer, setup_file_watcher},
- },
- language::Language,
- spin_generator,
-};
-
-pub async fn execute(tool_path: String, port: u16, build_first: bool) -> Result<()> {
- println!(
- "{} Serving tool: {} on port {}",
- style("β").cyan(),
- style(&tool_path).bold(),
- style(port).yellow()
- );
-
- // Validate tool exists and load manifest
- validate_tool_exists(&tool_path)?;
- let (manifest, tool_name) = load_manifest_and_name(&tool_path)?;
-
- // Build if requested
- if build_first {
- println!("{} Building tool first...", style("β").cyan());
- crate::commands::build::execute(Some(tool_path.clone()), None).await?;
- }
-
- // Check WASM binary exists and determine spin.toml path
- let (_wasm_path, spin_toml_path) = match manifest.tool.language {
- Language::Rust => {
- let wasm = get_wasm_path(&tool_path, &tool_name, &manifest.build.profile);
- if !wasm.exists() {
- let display = wasm.display();
- anyhow::bail!(
- "WASM binary not found at: {display}. Run 'ftl build {tool_path}' first."
- );
- }
-
- // Ensure .ftl directory and spin.toml exist for Rust
- ensure_ftl_dir(&tool_path)?;
- let spin_path = get_spin_toml_path(&tool_path);
-
- if !spin_path.exists() {
- // Generate development spin.toml if it doesn't exist
- let profile_dir = get_profile_dir(&manifest.build.profile);
- let wasm_filename = format!("{}.wasm", tool_name.replace('-', "_"));
- let relative_wasm_path = PathBuf::from("..")
- .join("target")
- .join("wasm32-wasip1")
- .join(profile_dir)
- .join(&wasm_filename);
-
- let spin_content = spin_generator::generate_development_config(
- &tool_name,
- port,
- &relative_wasm_path,
- )?;
- std::fs::write(&spin_path, spin_content)?;
- }
-
- (wasm, spin_path)
- }
- Language::JavaScript | Language::TypeScript => {
- // For JS/TS, use Spin's generated paths
- let wasm = PathBuf::from(&tool_path)
- .join("dist")
- .join(format!("{tool_name}.wasm"));
- if !wasm.exists() {
- let display = wasm.display();
- anyhow::bail!(
- "WASM binary not found at: {display}. Run 'ftl build {tool_path}' first."
- );
- }
-
- // Use spin.toml from .ftl directory
- let spin_path = get_spin_toml_path(&tool_path);
- if !spin_path.exists() {
- anyhow::bail!(
- "spin.toml not found in .ftl directory. Run 'ftl build {tool_path}' first."
- );
- }
-
- (wasm, spin_path)
- }
- };
-
- // Check spin is installed and get the path
- let spin_path = check_and_install_spin().await?;
-
- // Set up hot reload
- let should_rebuild = Arc::new(AtomicBool::new(false));
- let rebuild_flag = should_rebuild.clone();
-
- // Set up file watcher
- let (tx, rx) = std::sync::mpsc::channel();
- let _watcher = setup_file_watcher(&tool_path, tx)?;
-
- let tool_path_clone = tool_path.clone();
- let watcher_task = tokio::task::spawn_blocking(move || {
- let mut debouncer = Debouncer::new(Duration::from_millis(500));
-
- while let Ok(event) = rx.recv() {
- if debouncer.should_trigger() {
- // Set rebuild flag
- rebuild_flag.store(true, Ordering::Relaxed);
-
- // Display changed files
- for path in &event.paths {
- if let Ok(rel_path) = path.strip_prefix(&tool_path_clone) {
- let display = rel_path.display();
- println!("\nπ Changed: {display}");
- }
- }
-
- println!("π Reloading...");
- }
- }
- });
-
- // Start initial server
- println!();
- println!(
- "{} Starting development server with hot reload...",
- style("βΆ").green()
- );
- println!();
- println!(" Tool: {tool_path}");
- println!(" URL: http://localhost:{port}/mcp");
- println!(" Watching for changes in src/");
- println!();
- println!("Press Ctrl+C to stop");
- println!();
-
- let mut server =
- start_spin_server_with_path(&spin_path, &tool_path, port, Some(&spin_toml_path))?;
-
- // Main server loop with rebuild handling
- let rebuild_check = should_rebuild.clone();
- let mut rebuild_interval = tokio::time::interval(Duration::from_millis(250));
-
- loop {
- tokio::select! {
- _ = signal::ctrl_c() => {
- println!();
- println!("{} Stopping server...", style("β ").red());
- break;
- }
- _ = rebuild_interval.tick() => {
- if rebuild_check.load(Ordering::Relaxed) {
- rebuild_check.store(false, Ordering::Relaxed);
-
- // Stop current server
- if let Err(e) = server.kill() {
- warn!("Failed to stop server: {e}");
- }
- let _ = server.wait();
-
- // Rebuild
- match crate::commands::build::execute(Some(tool_path.clone()), None).await {
- Ok(_) => {
- println!("β
Build successful, restarting server...");
-
- // Small delay to ensure port is released
- sleep(Duration::from_millis(100)).await;
-
- // Restart server
- match start_spin_server_with_path(&spin_path, &tool_path, port, Some(&spin_toml_path)) {
- Ok(new_server) => {
- server = new_server;
- }
- Err(e) => {
- println!("β Failed to restart server: {e}");
- println!(" Fix the issue and save to retry");
- }
- }
- }
- Err(e) => {
- println!("β Build failed: {e}");
- println!(" Fix the error and save to retry");
-
- // Restart server anyway (will serve last good build)
- if let Ok(new_server) = start_spin_server_with_path(&spin_path, &tool_path, port, Some(&spin_toml_path)) {
- server = new_server;
- }
- }
- }
- }
- }
- }
- }
-
- // Cleanup
- drop(watcher_task);
- if let Err(e) = server.kill() {
- debug!("Failed to stop server during cleanup: {e}");
- }
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/setup.rs b/packages/ftl-cli/src/commands/setup.rs
new file mode 100644
index 00000000..e3980005
--- /dev/null
+++ b/packages/ftl-cli/src/commands/setup.rs
@@ -0,0 +1,201 @@
+use std::path::PathBuf;
+use std::process::Command;
+
+use anyhow::{Context, Result};
+use console::style;
+
+use crate::common::spin_installer::check_and_install_spin;
+
+pub async fn templates(
+ force: bool,
+ git: Option,
+ branch: Option,
+ dir: Option,
+ tar: Option,
+) -> Result<()> {
+ println!("{} Managing FTL templates", style("β").cyan());
+
+ // Get spin path
+ let spin_path = check_and_install_spin().await?;
+
+ // Check if templates are already installed
+ if !force {
+ let list_output = Command::new(&spin_path)
+ .args(["templates", "list"])
+ .output()
+ .context("Failed to list templates")?;
+
+ let output_str = String::from_utf8_lossy(&list_output.stdout);
+ let has_ftl_templates = output_str.contains("ftl-rust")
+ || output_str.contains("ftl-typescript")
+ || output_str.contains("ftl-javascript");
+
+ if has_ftl_templates {
+ println!("{} FTL templates are already installed", style("β").green());
+ println!();
+ println!("Use --force to reinstall/update them");
+ return Ok(());
+ }
+ }
+
+ // Build install command based on provided options
+ let mut install_cmd = Command::new(&spin_path);
+ install_cmd.args(["templates", "install"]);
+
+ if let Some(git_url) = &git {
+ println!(
+ "{} Installing templates from Git: {}",
+ style("β").dim(),
+ style(git_url).dim()
+ );
+ install_cmd.args(["--git", git_url]);
+ if let Some(branch_name) = &branch {
+ install_cmd.args(["--branch", branch_name]);
+ }
+ } else if let Some(dir_path) = &dir {
+ println!(
+ "{} Installing templates from directory: {}",
+ style("β").dim(),
+ style(dir_path.display()).dim()
+ );
+ install_cmd.args(["--dir", dir_path.to_str().unwrap()]);
+ } else if let Some(tar_path) = &tar {
+ println!(
+ "{} Installing templates from tarball: {}",
+ style("β").dim(),
+ style(tar_path).dim()
+ );
+ install_cmd.args(["--tar", tar_path]);
+ } else {
+ // Default: install from bundled templates
+ let template_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
+
+ println!(
+ "{} Installing FTL templates from {}",
+ style("β").dim(),
+ style(template_dir.display()).dim()
+ );
+
+ install_cmd.args(["--dir", template_dir.to_str().unwrap()]);
+ }
+
+ install_cmd.arg("--upgrade");
+
+ let install_output = install_cmd
+ .output()
+ .context("Failed to install templates")?;
+
+ if !install_output.status.success() {
+ anyhow::bail!(
+ "Failed to install templates:\n{}",
+ String::from_utf8_lossy(&install_output.stderr)
+ );
+ }
+
+ println!("{} Templates installed successfully!", style("β").green());
+ println!();
+
+ // List installed FTL templates
+ let list_output = Command::new(&spin_path)
+ .args(["templates", "list"])
+ .output()
+ .context("Failed to list templates")?;
+
+ let output_str = String::from_utf8_lossy(&list_output.stdout);
+ println!("Available FTL templates:");
+ for line in output_str.lines() {
+ if line.contains("ftl-") {
+ println!(" {}", line.trim());
+ }
+ }
+
+ Ok(())
+}
+
+pub async fn info() -> Result<()> {
+ println!("{} FTL Configuration", style("β").cyan());
+ println!();
+
+ // Show version
+ println!("FTL CLI version: {}", env!("CARGO_PKG_VERSION"));
+ println!();
+
+ // Check spin installation
+ match crate::common::spin_installer::get_spin_path() {
+ Ok(spin_path) => {
+ println!(
+ "Spin: {} {}",
+ style("β").green(),
+ style(spin_path.display()).dim()
+ );
+
+ // Get spin version
+ if let Ok(output) = Command::new(&spin_path).arg("--version").output() {
+ let version = String::from_utf8_lossy(&output.stdout);
+ println!(" Version: {}", version.trim());
+ }
+ }
+ Err(_) => {
+ println!("Spin: {} Not installed", style("β").red());
+ println!(" Run 'ftl setup templates' to install");
+ }
+ }
+ println!();
+
+ // Check templates
+ if let Ok(spin_path) = crate::common::spin_installer::get_spin_path() {
+ if let Ok(output) = Command::new(&spin_path)
+ .args(["templates", "list"])
+ .output()
+ {
+ let output_str = String::from_utf8_lossy(&output.stdout);
+ let ftl_templates: Vec<&str> = output_str
+ .lines()
+ .filter(|line| line.contains("ftl-"))
+ .collect();
+
+ if ftl_templates.is_empty() {
+ println!("FTL Templates: {} Not installed", style("β").red());
+ println!(" Run 'ftl setup templates' to install");
+ } else {
+ println!("FTL Templates: {} Installed", style("β").green());
+ for template in ftl_templates {
+ println!(" - {}", template.trim());
+ }
+ }
+ }
+ }
+ println!();
+
+ // Check for cargo-component
+ match Command::new("cargo")
+ .args(["component", "--version"])
+ .output()
+ {
+ Ok(output) => {
+ let version = String::from_utf8_lossy(&output.stdout);
+ println!("cargo-component: {} {}", style("β").green(), version.trim());
+ }
+ Err(_) => {
+ println!("cargo-component: {} Not installed", style("β").red());
+ println!(" Required for building Rust components");
+ println!(" Will be installed automatically when building Rust components");
+ }
+ }
+ println!();
+
+ // Check for wkg
+ match Command::new("wkg").arg("--version").output() {
+ Ok(output) => {
+ let version = String::from_utf8_lossy(&output.stdout);
+ println!("wkg: {} {}", style("β").green(), version.trim());
+ }
+ Err(_) => {
+ println!("wkg: {} Not installed", style("β").red());
+ println!(" Required for 'ftl publish'");
+ println!(" Install from: https://github.com/bytecodealliance/wasm-pkg-tools");
+ }
+ }
+
+ Ok(())
+}
diff --git a/packages/ftl-cli/src/commands/size.rs b/packages/ftl-cli/src/commands/size.rs
deleted file mode 100644
index 9a4276c4..00000000
--- a/packages/ftl-cli/src/commands/size.rs
+++ /dev/null
@@ -1,519 +0,0 @@
-use std::{collections::HashMap, path::Path, process::Command};
-
-use anyhow::{Context, Result};
-use serde_json;
-
-use crate::{common::tool_paths, manifest::ToolManifest};
-
-pub async fn execute(tool_path: String, verbose: bool) -> Result<()> {
- let tool_dir = Path::new(&tool_path);
- if !tool_dir.exists() {
- anyhow::bail!("Tool directory '{tool_path}' not found");
- }
-
- let manifest_path = tool_dir.join("ftl.toml");
- if !manifest_path.exists() {
- anyhow::bail!("No ftl.toml found in '{tool_path}'");
- }
-
- let manifest = ToolManifest::load(&manifest_path)?;
- let tool_name = &manifest.tool.name;
- let build_profile = &manifest.build.profile;
- let language = manifest.tool.language;
-
- println!("π Size analysis for tool: {tool_name}");
- println!(" Profile: {build_profile}");
-
- // Get the WASM path based on the language
- let wasm_path =
- tool_paths::get_wasm_path_for_language(tool_dir, tool_name, build_profile, language);
-
- if !wasm_path.exists() {
- println!("\nβ οΈ WASM binary not found. Building first...");
- crate::commands::build::execute(Some(tool_path.clone()), None).await?;
- }
-
- // Get file metadata
- let metadata = std::fs::metadata(&wasm_path).context("Failed to read WASM file metadata")?;
- let wasm_size = metadata.len();
-
- // Get build time
- if let Ok(modified) = metadata.modified() {
- if let Ok(elapsed) = std::time::SystemTime::now().duration_since(modified) {
- let age = format_duration(elapsed.as_secs());
- println!(" Built: {age}");
- }
- }
-
- println!("\nπ¦ Binary Sizes:");
- let wasm_size_str = format_size(wasm_size);
- println!(" WASM: {wasm_size_str}");
-
- // Check for optimized version if using wasm-opt
- let opt_wasm_path = wasm_path.with_extension("opt.wasm");
- if opt_wasm_path.exists() {
- let opt_size = std::fs::metadata(&opt_wasm_path)?.len();
- let opt_size_str = format_size(opt_size);
- let reduction = (wasm_size - opt_size) * 100 / wasm_size;
- println!(" Optimized WASM: {opt_size_str} ({reduction}% reduction)");
- }
-
- // Run wasm-tools if available to get detailed info
- if which::which("wasm-tools").is_ok() {
- println!("\nπ Detailed Analysis:");
-
- // Get module info
- let output = Command::new("wasm-tools")
- .arg("print")
- .arg(&wasm_path)
- .arg("--print-offsets")
- .output();
-
- if let Ok(output) = output {
- if output.status.success() {
- // Count sections
- let content = String::from_utf8_lossy(&output.stdout);
- let func_count = content.matches("(func ").count();
- let type_count = content.matches("(type ").count();
- let import_count = content.matches("(import ").count();
- let export_count = content.matches("(export ").count();
-
- println!(" Functions: {func_count}");
- println!(" Types: {type_count}");
- println!(" Imports: {import_count}");
- println!(" Exports: {export_count}");
- }
- }
-
- // Get section sizes
- let output = Command::new("wasm-tools")
- .arg("objdump")
- .arg(&wasm_path)
- .arg("--section-offsets")
- .output();
-
- if let Ok(output) = output {
- if output.status.success() {
- let content = String::from_utf8_lossy(&output.stdout);
- println!("\nπ Section Breakdown:");
-
- // Parse section info
- let mut sections = Vec::new();
- for line in content.lines() {
- if line.contains("section") && line.contains("size") {
- sections.push(line.trim().to_string());
- }
- }
-
- // Sort sections by size if possible
- sections.sort_by(|a, b| {
- let size_a = extract_size_from_section(a).unwrap_or(0);
- let size_b = extract_size_from_section(b).unwrap_or(0);
- size_b.cmp(&size_a)
- });
-
- for section in sections.iter().take(if verbose { 20 } else { 5 }) {
- println!(" {section}");
- }
-
- if verbose && sections.len() > 20 {
- println!(" ... and {} more sections", sections.len() - 20);
- }
- }
- }
-
- // Verbose mode: Show detailed import analysis
- if verbose {
- println!("\nπ₯ Import Analysis (Startup Cost):");
-
- let output = Command::new("wasm-tools")
- .arg("print")
- .arg(&wasm_path)
- .output();
-
- if let Ok(output) = output {
- if output.status.success() {
- let content = String::from_utf8_lossy(&output.stdout);
- let mut import_counts: HashMap = HashMap::new();
-
- for line in content.lines() {
- if line.contains("(import \"") {
- if let Some(module) = extract_import_module(line) {
- *import_counts.entry(module).or_insert(0) += 1;
- }
- }
- }
-
- let mut imports: Vec<_> = import_counts.into_iter().collect();
- imports.sort_by(|a, b| b.1.cmp(&a.1));
-
- for (module, count) in imports {
- println!(" {module}: {count} imports");
- }
-
- println!("\nπ‘ Import Tips:");
- println!(" - Each import adds startup overhead");
- println!(" - Consider bundling multiple operations into single imports");
- println!(" - Lazy-load optional functionality");
- }
- }
-
- // Show size history if available
- let size_history_path = tool_dir.join(".ftl").join("size_history.json");
- if size_history_path.exists() {
- println!("\nπ Size History:");
- if let Ok(history) = std::fs::read_to_string(&size_history_path) {
- if let Ok(history_data) = serde_json::from_str::(&history) {
- if let Some(entries) = history_data.as_array() {
- let current_size_in_history = entries
- .last()
- .and_then(|e| e["size"].as_u64())
- .unwrap_or(wasm_size);
-
- // Show current if different from last recorded
- if current_size_in_history != wasm_size {
- let current_str = format_size(wasm_size);
- let diff_str = if wasm_size > current_size_in_history {
- let diff = format_size(wasm_size - current_size_in_history);
- format!("+{diff}")
- } else {
- let diff = format_size(current_size_in_history - wasm_size);
- format!("-{diff}")
- };
- println!(" Current: {current_str} ({diff_str})");
- }
-
- // Show last 5 entries
- for entry in entries.iter().rev().take(5) {
- if let Some(size) = entry["size"].as_u64() {
- let date_display =
- if let Some(timestamp) = entry["timestamp"].as_u64() {
- format_timestamp(timestamp)
- } else {
- entry["date"].as_str().unwrap_or("Unknown").to_string()
- };
-
- let size_str = format_size(size);
- println!(" {date_display}: {size_str}");
- }
- }
- }
- }
- }
- }
-
- // Save current size to history
- save_size_to_history(tool_dir, wasm_size)?;
- }
- } else {
- println!("\nπ‘ Tip: Install wasm-tools for detailed binary analysis");
- println!(" cargo install wasm-tools");
- }
-
- // Size recommendations (only in verbose mode)
- if verbose {
- println!("\nπ‘ Optimization Suggestions:");
-
- let mut suggestions = Vec::new();
-
- if wasm_size > 5_000_000 {
- suggestions.push(
- "β οΈ Binary is larger than 5MB - optimization strongly recommended".to_string(),
- );
- }
-
- // Check optimization flags
- let has_opt_flags = manifest
- .optimization
- .flags
- .iter()
- .any(|f| f.contains("-O") || f.contains("--optimize"));
-
- if !has_opt_flags {
- suggestions.push(
- "Add wasm-opt optimization to ftl.toml:\n [optimization]\n flags = [\"-O3\", \"--enable-bulk-memory\"]"
- .to_string(),
- );
- } else {
- // Check if they could use more aggressive optimization
- let has_o3_or_higher = manifest.optimization.flags.iter().any(|f| {
- f.contains("-O3") || f.contains("-O4") || f.contains("-Os") || f.contains("-Oz")
- });
-
- if !has_o3_or_higher {
- let flags = &manifest.optimization.flags;
- suggestions.push(format!(
- "Consider more aggressive optimization in ftl.toml:\n Current: {flags:?}\n \
- Try: [\"-O3\"] or [\"-Oz\"] for size"
- ));
- }
- }
-
- // Profile suggestions
- match manifest.build.profile.as_str() {
- "dev" | "debug" => {
- let profile = &manifest.build.profile;
- suggestions.push(format!(
- "Using '{profile}' profile - switch to 'release' for smaller binaries:\n \
- [build]\n profile = \"release\""
- ));
- }
- "release" => {
- if wasm_size > 1_000_000 {
- suggestions.push(
- "Consider 'tiny' profile for maximum size reduction:\n [build]\n \
- profile = \"tiny\""
- .to_string(),
- );
- }
- }
- _ => {}
- }
-
- // Cargo.toml suggestions
- suggestions.push(
- "Review Cargo.toml for optimization opportunities:\n - Remove unused \
- dependencies\n - Use default-features = false where possible\n - Consider \
- lighter alternatives to heavy crates"
- .to_string(),
- );
-
- // Show suggestions
- for (i, suggestion) in suggestions.iter().enumerate() {
- if i > 0 {
- println!();
- }
- println!(" {suggestion}");
- }
- } // End of verbose mode optimization suggestions
-
- // Check dependencies size impact
- if let Ok(metadata) = cargo_metadata::MetadataCommand::new()
- .current_dir(tool_dir)
- .exec()
- {
- // Count direct dependencies vs total
- let total_packages = metadata.packages.len();
- let workspace_member = metadata.workspace_members.first();
-
- let direct_deps = if let Some(member_id) = workspace_member {
- metadata
- .packages
- .iter()
- .find(|p| &p.id == member_id)
- .map(|p| p.dependencies.len())
- .unwrap_or(0)
- } else {
- 0
- };
-
- let transitive_deps = total_packages.saturating_sub(1); // Exclude the tool itself
-
- // In verbose mode, show full dependency analysis
- if verbose && transitive_deps > 20 {
- println!();
- println!(" π¦ Dependency Analysis:");
- println!(" Direct dependencies: {direct_deps}");
- println!(" Total (including transitive): {transitive_deps}");
-
- if transitive_deps > 50 {
- println!(" β οΈ High dependency count may significantly increase binary size");
- println!();
- println!(" To investigate dependencies:");
- println!(" β’ See which crates bring in the most dependencies:");
- println!(" cargo tree --duplicates");
- println!(" β’ Find a specific heavy dependency:");
- println!(" cargo tree -i ");
- println!(" β’ See dependency tree sorted by depth:");
- println!(" cargo tree --depth 1");
- }
- }
-
- // Always show heaviest dependencies (in both normal and verbose modes)
- if let Some(member_id) = workspace_member {
- if let Some(package) = metadata.packages.iter().find(|p| &p.id == member_id) {
- let mut dep_weights: Vec<(String, usize)> = Vec::new();
-
- for dep in &package.dependencies {
- // Count how many packages depend on this dependency
- let weight = count_dependency_weight(&metadata, &dep.name);
- dep_weights.push((dep.name.clone(), weight));
- }
-
- // Sort by weight
- dep_weights.sort_by(|a, b| b.1.cmp(&a.1));
-
- // Show top 5 if any are significant
- let significant_deps: Vec<_> = dep_weights
- .iter()
- .take(5)
- .filter(|(_, weight)| *weight > 5)
- .collect();
-
- if !significant_deps.is_empty() {
- println!("\nπ‘ Heaviest dependencies:");
- for (name, weight) in significant_deps {
- println!(" β’ {name} (brings in ~{weight} crates)");
- }
- }
- }
- }
- }
-
- Ok(())
-}
-
-fn format_size(bytes: u64) -> String {
- const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
- let mut size = bytes as f64;
- let mut unit_index = 0;
-
- while size >= 1024.0 && unit_index < UNITS.len() - 1 {
- size /= 1024.0;
- unit_index += 1;
- }
-
- if unit_index == 0 {
- let size_int = size as u64;
- let unit = UNITS[unit_index];
- format!("{size_int} {unit}")
- } else {
- let unit = UNITS[unit_index];
- format!("{size:.2} {unit}")
- }
-}
-
-fn extract_size_from_section(section: &str) -> Option {
- // Extract size from section string like "code section: size 123456"
- if let Some(size_pos) = section.find("size ") {
- let size_str = §ion[size_pos + 5..];
- let size_end = size_str
- .find(|c: char| !c.is_numeric())
- .unwrap_or(size_str.len());
- size_str[..size_end].parse().ok()
- } else {
- None
- }
-}
-
-fn extract_import_module(line: &str) -> Option {
- // Extract module name from import line like: (import "wasi:io/streams@0.2.0"
- // ...)
- if let Some(start) = line.find("(import \"") {
- let rest = &line[start + 9..];
- if let Some(end) = rest.find('"') {
- let full_module = &rest[..end];
- // Extract just the module part before @version
- if let Some(at_pos) = full_module.find('@') {
- Some(full_module[..at_pos].to_string())
- } else {
- Some(full_module.to_string())
- }
- } else {
- None
- }
- } else {
- None
- }
-}
-
-fn format_duration(seconds: u64) -> String {
- if seconds < 60 {
- "just now".to_string()
- } else if seconds < 3600 {
- format!("{} minutes ago", seconds / 60)
- } else if seconds < 86400 {
- format!("{} hours ago", seconds / 3600)
- } else {
- format!("{} days ago", seconds / 86400)
- }
-}
-
-fn format_timestamp(timestamp: u64) -> String {
- use std::time::{SystemTime, UNIX_EPOCH};
-
- let now = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap()
- .as_secs();
- let diff = now.saturating_sub(timestamp);
-
- if diff < 60 {
- "Just now".to_string()
- } else if diff < 3600 {
- format!("{} minutes ago", diff / 60)
- } else if diff < 86400 {
- format!("{} hours ago", diff / 3600)
- } else {
- format!("{} days ago", diff / 86400)
- }
-}
-
-fn count_dependency_weight(metadata: &cargo_metadata::Metadata, dep_name: &str) -> usize {
- // Simple heuristic: count packages that have this dependency in their name
- // This catches things like "serde" counting "serde_derive", "serde_json", etc.
- metadata
- .packages
- .iter()
- .filter(|p| p.name.contains(dep_name) || p.dependencies.iter().any(|d| d.name == dep_name))
- .count()
-}
-
-fn save_size_to_history(tool_dir: &Path, size: u64) -> Result<()> {
- use std::time::{SystemTime, UNIX_EPOCH};
-
- let ftl_dir = tool_dir.join(".ftl");
- std::fs::create_dir_all(&ftl_dir)?;
-
- let history_path = ftl_dir.join("size_history.json");
-
- // Load existing history
- let mut history: Vec = if history_path.exists() {
- let content = std::fs::read_to_string(&history_path)?;
- serde_json::from_str(&content).unwrap_or_default()
- } else {
- Vec::new()
- };
-
- // Get current timestamp
- let timestamp = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap()
- .as_secs();
-
- // Format date simply
- let date = format!("{timestamp}");
-
- // Add new entry
- history.push(serde_json::json!({
- "date": date,
- "size": size,
- "timestamp": timestamp,
- }));
-
- // Keep only last 20 entries
- if history.len() > 20 {
- let skip_count = history.len() - 20;
- history = history.into_iter().skip(skip_count).collect();
- }
-
- // Save updated history
- std::fs::write(&history_path, serde_json::to_string_pretty(&history)?)?;
-
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_format_size() {
- assert_eq!(format_size(0), "0 B");
- assert_eq!(format_size(1023), "1023 B");
- assert_eq!(format_size(1024), "1.00 KB");
- assert_eq!(format_size(1536), "1.50 KB");
- assert_eq!(format_size(1048576), "1.00 MB");
- assert_eq!(format_size(1073741824), "1.00 GB");
- }
-}
diff --git a/packages/ftl-cli/src/commands/spin.rs b/packages/ftl-cli/src/commands/spin.rs
deleted file mode 100644
index 0a80662c..00000000
--- a/packages/ftl-cli/src/commands/spin.rs
+++ /dev/null
@@ -1,151 +0,0 @@
-use anyhow::{Context, Result};
-use console::style;
-use dialoguer::Confirm;
-use std::process::Command;
-
-use crate::common::spin_installer::{SPIN_REQUIRED_VERSION, check_and_install_spin};
-
-pub async fn install() -> Result<()> {
- println!(
- "{} Installing Spin v{}",
- style("β").cyan(),
- SPIN_REQUIRED_VERSION
- );
-
- check_and_install_spin().await?;
-
- Ok(())
-}
-
-pub async fn update() -> Result<()> {
- println!(
- "{} Updating Spin to v{}",
- style("β").cyan(),
- SPIN_REQUIRED_VERSION
- );
-
- // For now, update is the same as install since the script handles it
- check_and_install_spin().await?;
-
- Ok(())
-}
-
-pub async fn remove() -> Result<()> {
- println!("{} Removing Spin", style("β").cyan());
-
- // Check FTL-managed installation first
- let home_dir = dirs::home_dir().context("Could not determine home directory")?;
- let ftl_bin_dir = home_dir.join(".ftl").join("bin");
- let ftl_spin_path = ftl_bin_dir.join("spin");
-
- if ftl_spin_path.exists() {
- let confirm = Confirm::new()
- .with_prompt("Remove FTL-managed Spin installation?")
- .default(false)
- .interact()?;
-
- if !confirm {
- println!("Removal cancelled");
- return Ok(());
- }
-
- std::fs::remove_file(&ftl_spin_path)?;
- println!(
- "{} FTL-managed Spin removed successfully",
- style("β").green()
- );
- return Ok(());
- }
-
- // Check if spin is in PATH (system-wide)
- if let Ok(spin_path) = which::which("spin") {
- println!("Found system-wide Spin at: {}", spin_path.display());
- println!("β οΈ This appears to be a system-wide installation.");
- println!("FTL cannot remove system-wide installations.");
-
- // Try to provide OS-specific instructions
- match std::env::consts::OS {
- "macos" => {
- println!("\nFor macOS, you might have installed it via:");
- println!(" - Homebrew: brew uninstall fermyon-spin");
- println!(" - Or manually via the install script");
- }
- "linux" => {
- println!("\nFor Linux, check if you installed it via:");
- println!(" - Your package manager (apt, yum, dnf, etc.)");
- println!(" - Or manually via the install script");
- }
- _ => {}
- }
- } else {
- println!("Spin is not installed");
- }
-
- Ok(())
-}
-
-pub async fn info() -> Result<()> {
- println!("{} Spin Installation Info", style("βΉ").blue());
- println!();
-
- // Check FTL-managed installation first
- let home_dir = dirs::home_dir().context("Could not determine home directory")?;
- let ftl_bin_dir = home_dir.join(".ftl").join("bin");
- let ftl_spin_path = ftl_bin_dir.join("spin");
-
- let mut found_ftl_managed = false;
- if ftl_spin_path.exists() {
- found_ftl_managed = true;
- println!("FTL-managed Spin:");
- println!(" Path: {}", ftl_spin_path.display());
-
- // Get version
- let output = Command::new(&ftl_spin_path).arg("--version").output()?;
-
- if output.status.success() {
- let version = String::from_utf8_lossy(&output.stdout);
- let version = version.trim();
- println!(" Version: {version}");
- }
- println!();
- }
-
- // Check system-wide installation
- if let Ok(system_spin_path) = which::which("spin") {
- // Skip if it's the same as FTL-managed
- if !found_ftl_managed || system_spin_path != ftl_spin_path {
- println!("System-wide Spin:");
- println!(" Path: {}", system_spin_path.display());
-
- // Get version
- let output = Command::new(&system_spin_path).arg("--version").output()?;
-
- if output.status.success() {
- let version = String::from_utf8_lossy(&output.stdout);
- let version = version.trim();
- println!(" Version: {version}");
- }
- println!();
- }
- }
-
- println!("FTL requires Spin v{SPIN_REQUIRED_VERSION}");
- println!();
-
- if found_ftl_managed {
- println!("β FTL will use the managed installation in ~/.ftl/bin");
- } else if which::which("spin").is_ok() {
- println!("β οΈ System Spin found, but FTL prefers its own managed version");
- println!(
- " Run 'ftl spin install' to install Spin v{SPIN_REQUIRED_VERSION} in ~/.ftl/bin"
- );
- println!(" This ensures version compatibility and won't affect your system installation");
- } else {
- println!("β Spin is not installed");
- println!(
- " Run 'ftl spin install' to install Spin v{SPIN_REQUIRED_VERSION} in ~/.ftl/bin"
- );
- }
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/status.rs b/packages/ftl-cli/src/commands/status.rs
deleted file mode 100644
index 4a2751d9..00000000
--- a/packages/ftl-cli/src/commands/status.rs
+++ /dev/null
@@ -1,51 +0,0 @@
-use std::process::Command;
-
-use anyhow::Result;
-use console::style;
-
-use crate::common::deploy_utils::infer_app_name;
-
-pub async fn execute(name: Option) -> Result<()> {
- // Check if spin is installed
- if which::which("spin").is_err() {
- anyhow::bail!(
- "Spin CLI not found. Please install it from: https://developer.fermyon.com/spin/install"
- );
- }
-
- // Get the app name - either provided or inferred from current directory
- let app_name = match name {
- Some(n) => n,
- None => infer_app_name(".")?,
- };
-
- println!(
- "{} Getting status for: {}",
- style("β").cyan(),
- style(&app_name).bold()
- );
- println!();
-
- // Run spin aka app status with --app-name flag
- let output = Command::new("spin")
- .args(["aka", "app", "status", "--app-name", &app_name])
- .output()?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- if stderr.contains("not logged in") || stderr.contains("authentication") {
- anyhow::bail!("Not authenticated with FTL Edge. Please run: ftl login");
- }
- if stderr.contains("not found") || stderr.contains("does not exist") {
- anyhow::bail!(
- "Tool/toolkit '{app_name}' not found. Use 'ftl list' to see deployed tools and toolkits."
- );
- }
- anyhow::bail!("Failed to get tool/toolkit status:\n{stderr}");
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- print!("{stdout}");
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/test.rs b/packages/ftl-cli/src/commands/test.rs
index 4f9841e5..344af9fe 100644
--- a/packages/ftl-cli/src/commands/test.rs
+++ b/packages/ftl-cli/src/commands/test.rs
@@ -1,104 +1,64 @@
-use anyhow::Result;
-use tracing::info;
+use std::path::PathBuf;
+use std::process::Command;
-use crate::{
- common::{manifest_utils::validate_and_load_manifest, tool_paths::validate_tool_exists},
- language::get_language_support,
-};
+use anyhow::{Context, Result};
+use console::style;
-pub async fn execute(name: Option) -> Result<()> {
- let tool_path = name.unwrap_or_else(|| ".".to_string());
- test_tool(&tool_path).await
-}
-
-async fn test_tool(tool_path: &str) -> Result<()> {
- // Validate tool exists and load manifest
- validate_tool_exists(tool_path)?;
- let manifest = validate_and_load_manifest(tool_path)?;
+pub async fn execute(path: Option) -> Result<()> {
+ let component_path = path.unwrap_or_else(|| PathBuf::from("."));
- let name = &manifest.tool.name;
- info!("Testing tool: {name}");
- println!("π§ͺ Running tests for '{tool_path}'...");
+ println!("{} Running tests", style("β").cyan());
- // Get language support and run tests
- let language_support = get_language_support(manifest.tool.language);
+ // Check if Makefile exists and has test target
+ if component_path.join("Makefile").exists() {
+ let output = Command::new("make")
+ .arg("test")
+ .current_dir(&component_path)
+ .output()
+ .context("Failed to run make test")?;
- // Use the language-specific test implementation
- match language_support.test(&manifest, std::path::Path::new(tool_path)) {
- Ok(_) => {
- println!("β
All tests passed for '{tool_path}'");
- Ok(())
+ if !output.status.success() {
+ println!("{}", String::from_utf8_lossy(&output.stdout));
+ println!("{}", String::from_utf8_lossy(&output.stderr));
+ anyhow::bail!("Tests failed");
}
- Err(e) => {
- println!("β Tests failed for '{tool_path}'");
- // Provide helpful message for missing tests
- let error_msg = e.to_string();
- if error_msg.contains("0 tests") || error_msg.contains("could not find") {
- println!(
- "\nπ‘ No tests found. Your tool template includes example tests in {}",
- match manifest.tool.language {
- crate::language::Language::Rust => "src/lib.rs",
- crate::language::Language::JavaScript => "src/index.test.js",
- crate::language::Language::TypeScript => "src/index.test.ts",
- }
- );
- println!(" The tests verify basic tool functionality like name and description.");
- println!("\nTo add more tests:");
- match manifest.tool.language {
- crate::language::Language::Rust => {
- println!(" 1. Add #[test] functions to src/lib.rs");
- println!(" 2. Test your tool's logic without needing WASM runtime");
- println!(" 3. Use standard Rust testing patterns");
- }
- crate::language::Language::JavaScript => {
- println!(" 1. Add test files matching *.test.js or *.spec.js");
- println!(" 2. Use your preferred JavaScript testing framework");
- println!(" 3. Tests run in Node.js, not in the WASM runtime");
- }
- crate::language::Language::TypeScript => {
- println!(" 1. Add test files matching *.test.ts or *.spec.ts");
- println!(" 2. Use your preferred TypeScript testing framework");
- println!(" 3. Tests run in Node.js, not in the WASM runtime");
- }
- }
- println!("\nNote: These are unit tests that run natively, not in WASM.");
- println!("For WASM runtime testing, consider spin-test (experimental).");
+ println!("{}", String::from_utf8_lossy(&output.stdout));
+ } else {
+ // Try to detect test framework
+ if component_path.join("handler/Cargo.toml").exists() {
+ // Rust component
+ let output = Command::new("cargo")
+ .arg("test")
+ .current_dir(component_path.join("handler"))
+ .output()
+ .context("Failed to run cargo test")?;
+
+ println!("{}", String::from_utf8_lossy(&output.stdout));
+ if !output.status.success() {
+ println!("{}", String::from_utf8_lossy(&output.stderr));
+ anyhow::bail!("Tests failed");
}
+ } else if component_path.join("handler/package.json").exists() {
+ // JavaScript/TypeScript component
+ let output = Command::new("npm")
+ .arg("test")
+ .current_dir(component_path.join("handler"))
+ .output()
+ .context("Failed to run npm test")?;
- Err(e)
+ println!("{}", String::from_utf8_lossy(&output.stdout));
+ if !output.status.success() {
+ println!("{}", String::from_utf8_lossy(&output.stderr));
+ anyhow::bail!("Tests failed");
+ }
+ } else {
+ anyhow::bail!("Could not determine how to run tests for this component");
}
}
-}
-
-#[cfg(test)]
-mod tests {
- use std::fs;
- use tempfile::TempDir;
+ println!();
+ println!("{} All tests passed!", style("β").green());
- use super::*;
-
- #[tokio::test]
- async fn test_missing_tool() {
- let result = test_tool("nonexistent_tool").await;
- assert!(result.is_err());
- assert!(result.unwrap_err().to_string().contains("not found"));
- }
-
- #[tokio::test]
- async fn test_missing_manifest() {
- let temp_dir = TempDir::new().unwrap();
- let tool_dir = temp_dir.path().join("test_tool");
- fs::create_dir(&tool_dir).unwrap();
-
- let result = test_tool(tool_dir.to_str().unwrap()).await;
- assert!(result.is_err());
- assert!(
- result
- .unwrap_err()
- .to_string()
- .contains("No ftl.toml found")
- );
- }
+ Ok(())
}
diff --git a/packages/ftl-cli/src/commands/toolkit.rs b/packages/ftl-cli/src/commands/toolkit.rs
deleted file mode 100644
index a384008b..00000000
--- a/packages/ftl-cli/src/commands/toolkit.rs
+++ /dev/null
@@ -1,497 +0,0 @@
-use std::{
- path::{Path, PathBuf},
- process::Command,
- sync::Arc,
-};
-
-use anyhow::{Context, Result};
-use console::style;
-use indicatif::{ProgressBar, ProgressStyle};
-use tokio::task::JoinSet;
-
-use crate::{
- common::config::FtlConfig,
- manifest::{ToolkitConfig, ToolkitManifest, ToolkitTool},
- spin_generator::SpinConfig,
-};
-
-pub async fn build(name: String, tools: Vec) -> Result<()> {
- println!(
- "{} Building toolkit: {} with {} tools",
- style("β").cyan(),
- style(&name).bold(),
- tools.len()
- );
-
- if tools.is_empty() {
- anyhow::bail!("No tools specified for toolkit");
- }
-
- // Create toolkit directory
- let toolkit_dir = PathBuf::from(&name);
- std::fs::create_dir_all(&toolkit_dir)?;
-
- // First check that all tools exist
- for tool_name in &tools {
- let tool_dir = PathBuf::from(tool_name);
- if !tool_dir.join("ftl.toml").exists() {
- anyhow::bail!("Tool '{tool_name}' not found");
- }
- }
-
- // Build all tools in parallel
- println!();
- let tools_list = tools.join(", ");
- println!("Tools to build: {tools_list}");
- println!();
-
- // Create a single progress bar that shows overall progress
- let pb = ProgressBar::new(tools.len() as u64);
- pb.set_style(
- ProgressStyle::default_bar()
- .template("{spinner:.cyan} [{bar:30.cyan/blue}] {pos}/{len} {msg}")
- .unwrap()
- .progress_chars("ββββββββ ")
- .tick_strings(&["β ", "β ", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ", "β "]),
- );
- pb.set_message("π¨ Building...");
- pb.enable_steady_tick(std::time::Duration::from_millis(80));
-
- let mut tasks = JoinSet::new();
- let completed = Arc::new(std::sync::Mutex::new(Vec::new()));
-
- for tool_name in tools.clone() {
- let toolkit_dir = toolkit_dir.clone();
- let pb = pb.clone();
- let completed = completed.clone();
- let tools_count = tools.len();
-
- tasks.spawn(async move {
- // Build the tool quietly
- let build_result =
- crate::commands::build::execute_quiet(&tool_name, Some("release".to_string()))
- .await;
-
- if let Err(e) = build_result {
- return Err(anyhow::anyhow!("Failed to build {tool_name}: {e}"));
- }
-
- // Update progress
- pb.inc(1);
- let mut comp = completed.lock().unwrap();
- comp.push(tool_name.clone());
- let done = comp.len();
-
- if done < tools_count {
- pb.set_message(format!("completed {tool_name}"));
- }
-
- // Find and copy WASM - check multiple locations
- let tool_dir = PathBuf::from(&tool_name);
- let wasm_filename = format!("{}.wasm", tool_name.replace('-', "_"));
-
- // Try Rust path first
- let rust_wasm_path = tool_dir
- .join("target")
- .join("wasm32-wasip1")
- .join("release")
- .join(&wasm_filename);
-
- // Try JavaScript path
- let js_wasm_filename = format!("{tool_name}.wasm");
- let js_wasm_path = tool_dir.join("dist").join(&js_wasm_filename);
-
- let (wasm_path, final_wasm_filename) = if rust_wasm_path.exists() {
- (rust_wasm_path, wasm_filename)
- } else if js_wasm_path.exists() {
- (js_wasm_path, js_wasm_filename)
- } else {
- return Err(anyhow::anyhow!(
- "WASM binary not found for tool: {}",
- tool_name
- ));
- };
-
- // Copy WASM to toolkit directory
- let dest_path = toolkit_dir.join(&final_wasm_filename);
- std::fs::copy(&wasm_path, &dest_path)?;
-
- // Path relative to .ftl/ directory
- Ok((tool_name, format!("../{final_wasm_filename}")))
- });
- }
-
- // Wait for all builds to complete
- let mut tool_paths = Vec::new();
- while let Some(result) = tasks.join_next().await {
- match result {
- Ok(Ok(tool_info)) => tool_paths.push(tool_info),
- Ok(Err(e)) => {
- pb.abandon();
- return Err(e);
- }
- Err(e) => {
- pb.abandon();
- return Err(anyhow::anyhow!("Task failed: {e}"));
- }
- }
- }
-
- pb.set_style(
- ProgressStyle::default_bar()
- .template("{prefix:.green} [{bar:30.green}] {pos}/{len} {msg}")
- .unwrap()
- .progress_chars("ββββββββ "),
- );
- pb.set_prefix("β");
- pb.finish_with_message("β All tools built successfully");
-
- println!();
- println!("{} Building gateway component...", style("β").cyan());
-
- // Create toolkit manifest
- let tool_count = tools.len();
- let toolkit_manifest = ToolkitManifest {
- toolkit: ToolkitConfig {
- name: name.clone(),
- version: "1.0.0".to_string(),
- description: format!("Toolkit containing {tool_count} tools"),
- },
- tools: tools
- .iter()
- .map(|tool_name| ToolkitTool {
- name: tool_name.clone(),
- route: format!("/{tool_name}"),
- })
- .collect(),
- gateway: None, // Gateway is opt-in
- };
-
- // Save toolkit manifest
- let manifest_path = toolkit_dir.join("toolkit.toml");
- toolkit_manifest.save(&manifest_path)?;
-
- // Create .ftl directory in toolkit
- let ftl_dir = toolkit_dir.join(".ftl");
- std::fs::create_dir_all(&ftl_dir)?;
-
- // Generate gateway code
- generate_gateway_code(&toolkit_dir, &toolkit_manifest)?;
-
- // Build the gateway
- let gateway_output = Command::new("cargo")
- .args([
- "build",
- "--target",
- "wasm32-wasip1",
- "--release",
- "--manifest-path",
- "gateway/Cargo.toml",
- ])
- .current_dir(&toolkit_dir)
- .output()
- .context("Failed to build gateway")?;
-
- if !gateway_output.status.success() {
- anyhow::bail!(
- "Gateway build failed: {}",
- String::from_utf8_lossy(&gateway_output.stderr)
- );
- }
-
- // Copy gateway WASM to toolkit directory
- let gateway_wasm_path = toolkit_dir
- .join("gateway")
- .join("target")
- .join("wasm32-wasip1")
- .join("release")
- .join(format!(
- "{}_gateway.wasm",
- toolkit_manifest.toolkit.name.replace('-', "_")
- ));
-
- let gateway_dest = toolkit_dir.join("gateway.wasm");
- std::fs::copy(&gateway_wasm_path, &gateway_dest).context("Failed to copy gateway WASM")?;
-
- println!("{} Gateway built successfully", style("β").green());
-
- // Generate spin.toml for toolkit
- let spin_config = SpinConfig::from_toolkit(&toolkit_manifest, &tool_paths)?;
- let spin_path = ftl_dir.join("spin.toml");
- spin_config.save(&spin_path)?;
-
- println!();
- println!("{} Toolkit built successfully!", style("β").green());
- println!();
- println!("Toolkit directory: {}", toolkit_dir.display());
- println!("Tools included:");
- for tool in &tools {
- println!(" - {tool}");
- }
- println!();
- println!("Next steps:");
- println!(" ftl toolkit serve {name} # Serve locally");
- println!(" ftl toolkit deploy {name} # Deploy to FTL Edge");
-
- Ok(())
-}
-
-pub async fn serve(name: String, port: u16) -> Result<()> {
- println!(
- "{} Serving toolkit: {} on port {}",
- style("β").cyan(),
- style(&name).bold(),
- style(port).yellow()
- );
-
- let toolkit_dir = PathBuf::from(&name);
- if !toolkit_dir.exists() {
- anyhow::bail!(
- "Toolkit '{}' not found. Build it first with: ftl toolkit build",
- name
- );
- }
-
- // Check if spin.toml exists
- let spin_path = toolkit_dir.join(".ftl").join("spin.toml");
- if !spin_path.exists() {
- anyhow::bail!(".ftl/spin.toml not found in toolkit directory");
- }
-
- // Start spin server
- println!();
- println!("{} Starting toolkit server...", style("βΆ").green());
- println!();
- println!(" Toolkit: {name}");
- println!(" URL: http://localhost:{port}");
-
- // Load toolkit manifest to show available routes
- let manifest_path = toolkit_dir.join("toolkit.toml");
- if let Ok(manifest) = ToolkitManifest::load(&manifest_path) {
- println!(" Routes:");
- println!(" - {} (aggregates all tools)", style("/mcp").yellow());
- for tool in &manifest.tools {
- let route = &tool.route;
- println!(" - {route}/mcp");
- }
- }
-
- println!();
- println!("Press Ctrl+C to stop");
- println!();
-
- let mut child = Command::new("spin")
- .arg("up")
- .arg("--listen")
- .arg(format!("127.0.0.1:{port}"))
- .arg("--from")
- .arg(".ftl/spin.toml")
- .current_dir(&toolkit_dir)
- .spawn()
- .context("Failed to start spin server")?;
-
- // Wait for Ctrl+C
- tokio::signal::ctrl_c().await?;
-
- println!();
- println!("{} Stopping server...", style("β ").red());
-
- // Kill the spin process
- child.kill().context("Failed to kill spin process")?;
-
- Ok(())
-}
-
-pub async fn deploy(name: String) -> Result<()> {
- println!(
- "{} Deploying toolkit: {}",
- style("β").cyan(),
- style(&name).bold()
- );
-
- let toolkit_dir = PathBuf::from(&name);
- if !toolkit_dir.exists() {
- anyhow::bail!(
- "Toolkit '{}' not found. Build it first with: ftl toolkit build",
- name
- );
- }
-
- // Deploy using spin aka with spinner
- let spinner = ProgressBar::new_spinner();
- spinner.set_style(
- ProgressStyle::default_spinner()
- .template("{spinner:.cyan} {msg}")
- .unwrap()
- .tick_strings(&["β ", "β ", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ", "β "]),
- );
- spinner.set_message("Deploying to FTL Edge...");
- spinner.enable_steady_tick(std::time::Duration::from_millis(80));
-
- // Load config and generate app name with user prefix
- let config = FtlConfig::load().unwrap_or_default();
- let app_name = format!("{}{name}", config.get_app_prefix());
-
- // Try deploying without --create-name first (for existing apps)
- let output = Command::new("spin")
- .args(["aka", "deploy", "--from", ".ftl/spin.toml", "--no-confirm"])
- .current_dir(&toolkit_dir)
- .output()
- .context("Failed to run spin aka deploy")?;
-
- let output = if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- // If it fails because app doesn't exist, try with --create-name
- if stderr.contains("no app")
- || stderr.contains("not found")
- || stderr.contains("does not exist")
- || stderr.contains("No terminal available")
- || stderr.contains("must use --create-name")
- {
- spinner.set_message(format!("Creating new toolkit: {app_name}..."));
- Command::new("spin")
- .args([
- "aka",
- "deploy",
- "--from",
- ".ftl/spin.toml",
- "--create-name",
- &app_name,
- "--no-confirm",
- ])
- .current_dir(&toolkit_dir)
- .output()
- .context("Failed to run spin aka deploy with --create-name")?
- } else {
- output
- }
- } else {
- output
- };
-
- spinner.finish_and_clear();
-
- if !output.status.success() {
- println!("{} Deployment failed", style("β").red());
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("{stderr}");
- }
-
- // Parse deployment URL
- let output_str = String::from_utf8_lossy(&output.stdout);
- let full_url = output_str
- .lines()
- .find(|line| line.contains("https://"))
- .and_then(|line| {
- line.split_whitespace()
- .find(|word| word.starts_with("https://"))
- })
- .unwrap_or("(URL not found in output)");
-
- // Extract base URL (remove any path components)
- let base_url = if let Some(domain_end) = full_url.find(".tech") {
- &full_url[..domain_end + 5] // Include ".tech"
- } else if let Some(first_slash) = full_url[8..].find('/') {
- // Skip "https://"
- &full_url[..8 + first_slash]
- } else {
- full_url
- };
-
- println!();
- println!("{} Toolkit deployed successfully!", style("β").green());
- println!();
- println!("Toolkit: {name}");
- println!("URL: {base_url}");
-
- // Show available routes
- let manifest_path = toolkit_dir.join("toolkit.toml");
- if let Ok(manifest) = ToolkitManifest::load(&manifest_path) {
- println!("Available endpoints:");
- println!(" - {base_url}/mcp (aggregates all tools)");
- for tool in &manifest.tools {
- let route = &tool.route;
- println!(" - {base_url}{route}/mcp");
- }
- }
-
- Ok(())
-}
-
-/// Generate the gateway code for a toolkit
-fn generate_gateway_code(toolkit_dir: &Path, manifest: &ToolkitManifest) -> Result<()> {
- // Create gateway directory
- let gateway_dir = toolkit_dir.join("gateway");
- std::fs::create_dir_all(&gateway_dir)?;
-
- // Get the SDK version from compile-time constant
- let sdk_version = env!("FTL_SDK_RS_VERSION");
-
- // Generate Cargo.toml
- let cargo_toml = format!(
- r#"[package]
-name = "{}-gateway"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-ftl-sdk-rs = {{ version = "^{}" }}
-spin-sdk = "3.1.1"
-serde = {{ version = "1.0", features = ["derive"] }}
-serde_json = "1.0"
-
-[lib]
-crate-type = ["cdylib"]
-
-[workspace]"#,
- manifest.toolkit.name, sdk_version
- );
- std::fs::write(gateway_dir.join("Cargo.toml"), cargo_toml)?;
-
- // Create src directory
- let src_dir = gateway_dir.join("src");
- std::fs::create_dir_all(&src_dir)?;
-
- // Generate lib.rs with gateway configuration
- let lib_rs = format!(
- r#"use ftl_sdk_rs::{{ftl_mcp_gateway, gateway::{{GatewayConfig, ToolEndpoint}}, mcp::ServerInfo}};
-
-// Configure the gateway with all tools in the toolkit
-fn create_gateway_config() -> GatewayConfig {{
- GatewayConfig {{
- tools: vec![
-{}
- ],
- server_info: ServerInfo {{
- name: "{}-gateway".to_string(),
- version: "{}".to_string(),
- }},
- // Base URL is empty - gateway uses component IDs directly
- base_url: "".to_string(),
- }}
-}}
-
-// Create the gateway component
-ftl_mcp_gateway!(create_gateway_config());"#,
- manifest
- .tools
- .iter()
- .map(|tool| {
- format!(
- r#" ToolEndpoint {{
- name: "{}".to_string(),
- route: "{}".to_string(),
- description: None,
- }},"#,
- tool.name, tool.route
- )
- })
- .collect::>()
- .join("\n"),
- manifest.toolkit.name,
- manifest.toolkit.version
- );
- std::fs::write(src_dir.join("lib.rs"), lib_rs)?;
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/unlink.rs b/packages/ftl-cli/src/commands/unlink.rs
deleted file mode 100644
index 5c292af4..00000000
--- a/packages/ftl-cli/src/commands/unlink.rs
+++ /dev/null
@@ -1,54 +0,0 @@
-use std::process::Command;
-
-use anyhow::Result;
-use console::style;
-
-use crate::common::tool_paths::validate_tool_exists;
-
-pub async fn execute(path: Option) -> Result<()> {
- let tool_path = path.unwrap_or_else(|| ".".to_string());
-
- // Validate tool directory exists
- validate_tool_exists(&tool_path)?;
-
- // Check if spin is installed
- if which::which("spin").is_err() {
- anyhow::bail!(
- "Spin CLI not found. Please install it from: https://developer.fermyon.com/spin/install"
- );
- }
-
- // Check if .ftl/spin.toml exists
- let spin_toml = std::path::Path::new(&tool_path).join(".ftl/spin.toml");
- if !spin_toml.exists() {
- anyhow::bail!(".ftl/spin.toml not found. This tool doesn't appear to be built.");
- }
-
- println!("{} Unlinking tool from deployment...", style("β").cyan());
-
- // Run spin aka app unlink
- let output = Command::new("spin")
- .args(["aka", "app", "unlink", "-f", ".ftl/spin.toml"])
- .current_dir(&tool_path)
- .output()?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- if stderr.contains("not logged in") || stderr.contains("authentication") {
- anyhow::bail!("Not authenticated with FTL Edge. Please run: ftl login");
- }
- if stderr.contains("not linked") || stderr.contains("No app linked") {
- anyhow::bail!("This tool is not linked to any deployment.");
- }
- anyhow::bail!("Failed to unlink tool:\n{stderr}");
- }
-
- println!("{} Tool successfully unlinked", style("β").green());
- println!();
- println!("The tool is no longer connected to any FTL Edge deployment.");
- println!("You can:");
- println!(" ftl link # Link to an existing deployment");
- println!(" ftl deploy # Deploy as a new tool");
-
- Ok(())
-}
diff --git a/packages/ftl-cli/src/commands/up.rs b/packages/ftl-cli/src/commands/up.rs
new file mode 100644
index 00000000..d872acc0
--- /dev/null
+++ b/packages/ftl-cli/src/commands/up.rs
@@ -0,0 +1,62 @@
+use std::path::PathBuf;
+use std::process::{Command, Stdio};
+
+use anyhow::{Context, Result};
+use console::style;
+
+use crate::common::spin_installer::check_and_install_spin;
+
+pub async fn execute(path: Option, port: u16, build: bool) -> Result<()> {
+ let component_path = path.unwrap_or_else(|| PathBuf::from("."));
+
+ // Validate component directory exists
+ if !component_path.join("spin.toml").exists() {
+ anyhow::bail!("No spin.toml found. Not in a component or project directory?");
+ }
+
+ // Get spin path
+ let spin_path = check_and_install_spin().await?;
+
+ // Build command args
+ let mut args = vec!["up"];
+
+ if build {
+ args.push("--build");
+ }
+
+ let listen_addr = format!("127.0.0.1:{port}");
+ args.extend(["--listen", &listen_addr]);
+
+ println!();
+ println!("{} Server starting...", style("βΆ").green());
+ println!();
+ println!("{} Press Ctrl+C to stop", style("βΉ").green());
+ println!();
+
+ // Run spin up with inherited stdio so user can see logs
+ let mut child = Command::new(&spin_path)
+ .args(&args)
+ .current_dir(&component_path)
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .context("Failed to start spin up")?;
+
+ // Wait for Ctrl+C
+ tokio::select! {
+ _ = tokio::signal::ctrl_c() => {
+ println!();
+ println!("{} Stopping server...", style("β ").red());
+ }
+ status = tokio::task::spawn_blocking(move || child.wait()) => {
+ if let Ok(Ok(status)) = status {
+ if !status.success() {
+ anyhow::bail!("Spin exited with status: {}", status);
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
diff --git a/packages/ftl-cli/src/commands/validate.rs b/packages/ftl-cli/src/commands/validate.rs
deleted file mode 100644
index a16e8e6c..00000000
--- a/packages/ftl-cli/src/commands/validate.rs
+++ /dev/null
@@ -1,195 +0,0 @@
-use std::{path::Path, process::Command};
-
-use anyhow::{Context, Result};
-use tracing::info;
-
-use crate::manifest::ToolManifest;
-
-pub async fn execute(name: String) -> Result<()> {
- let tool_dir = Path::new(&name);
- if !tool_dir.exists() {
- anyhow::bail!("Tool directory '{name}' not found");
- }
-
- println!("π Validating tool: {name}");
-
- let mut errors = Vec::new();
- let mut warnings = Vec::new();
-
- // Check for required files
- let manifest_path = tool_dir.join("ftl.toml");
- if !manifest_path.exists() {
- errors.push("Missing ftl.toml manifest file".to_string());
- } else {
- // Validate manifest
- match ToolManifest::load(&manifest_path) {
- Ok(manifest) => {
- info!("Manifest loaded successfully");
-
- // Validate manifest fields
- if manifest.tool.name.is_empty() {
- errors.push("Tool name cannot be empty".to_string());
- }
-
- if manifest.tool.description.is_empty() {
- warnings.push("Tool description is empty".to_string());
- }
-
- // Validate version format
- if !is_valid_version(&manifest.tool.version) {
- let version = &manifest.tool.version;
- errors.push(format!("Invalid version format: {version}"));
- }
- }
- Err(e) => {
- errors.push(format!("Invalid ftl.toml: {e}"));
- }
- }
- }
-
- // Check Cargo.toml
- let cargo_path = tool_dir.join("Cargo.toml");
- if !cargo_path.exists() {
- errors.push("Missing Cargo.toml file".to_string());
- } else {
- // Validate Cargo.toml
- match std::fs::read_to_string(&cargo_path) {
- Ok(content) => {
- let cargo_toml: toml::Value =
- content.parse().context("Failed to parse Cargo.toml")?;
-
- // Check for required dependencies
- if let Some(deps) = cargo_toml.get("dependencies") {
- if deps.get("ftl-sdk-rs").is_none() {
- errors.push("Missing ftl-sdk-rs dependency in Cargo.toml".to_string());
- }
- } else {
- errors.push("No dependencies section in Cargo.toml".to_string());
- }
-
- // Check crate type
- if let Some(lib) = cargo_toml.get("lib") {
- if let Some(crate_types) = lib.get("crate-type") {
- let types = crate_types
- .as_array()
- .and_then(|arr| arr.first())
- .and_then(|v| v.as_str());
-
- if types != Some("cdylib") {
- errors.push(
- "Library crate-type must be [\"cdylib\"] for WebAssembly"
- .to_string(),
- );
- }
- } else {
- errors.push("Missing crate-type in [lib] section".to_string());
- }
- } else {
- errors.push("Missing [lib] section in Cargo.toml".to_string());
- }
- }
- Err(e) => {
- errors.push(format!("Failed to read Cargo.toml: {e}"));
- }
- }
- }
-
- // Check source files
- let lib_path = tool_dir.join("src/lib.rs");
- if !lib_path.exists() {
- errors.push("Missing src/lib.rs file".to_string());
- } else {
- // Basic validation of lib.rs content
- match std::fs::read_to_string(&lib_path) {
- Ok(content) => {
- if !content.contains("impl Tool for") {
- warnings.push("No Tool trait implementation found in src/lib.rs".to_string());
- }
-
- if !content.contains("ftl_mcp_server!") {
- errors
- .push("Missing ftl_mcp_server! macro invocation in src/lib.rs".to_string());
- }
- }
- Err(e) => {
- errors.push(format!("Failed to read src/lib.rs: {e}"));
- }
- }
- }
-
- // Try to run cargo check
- println!("\nπ¦ Running cargo check...");
- let check_result = Command::new("cargo")
- .current_dir(tool_dir)
- .arg("check")
- .arg("--target")
- .arg("wasm32-wasip1")
- .output();
-
- match check_result {
- Ok(output) => {
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- errors.push(format!("Cargo check failed:\n{stderr}"));
- } else {
- println!("β
Cargo check passed");
- }
- }
- Err(e) => {
- warnings.push(format!("Could not run cargo check: {e}"));
- }
- }
-
- // Print results
- println!("\nπ Validation Results:");
-
- if errors.is_empty() && warnings.is_empty() {
- println!("β
All checks passed!");
- Ok(())
- } else {
- if !warnings.is_empty() {
- println!("\nβ οΈ Warnings ({})", warnings.len());
- for warning in warnings {
- println!(" - {warning}");
- }
- }
-
- if !errors.is_empty() {
- println!("\nβ Errors ({})", errors.len());
- let error_count = errors.len();
- for error in errors {
- println!(" - {error}");
- }
- anyhow::bail!("Validation failed with {error_count} error(s)");
- }
-
- Ok(())
- }
-}
-
-fn is_valid_version(version: &str) -> bool {
- // Basic semver validation
- let parts: Vec<&str> = version.split('.').collect();
- if parts.len() != 3 {
- return false;
- }
-
- parts.iter().all(|part| part.parse::().is_ok())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_version_validation() {
- assert!(is_valid_version("1.0.0"));
- assert!(is_valid_version("0.0.1"));
- assert!(is_valid_version("10.20.30"));
-
- assert!(!is_valid_version("1.0"));
- assert!(!is_valid_version("1.0.0.0"));
- assert!(!is_valid_version("1.a.0"));
- assert!(!is_valid_version(""));
- }
-}
diff --git a/packages/ftl-cli/src/commands/watch.rs b/packages/ftl-cli/src/commands/watch.rs
index b581aeb7..91d49dd0 100644
--- a/packages/ftl-cli/src/commands/watch.rs
+++ b/packages/ftl-cli/src/commands/watch.rs
@@ -1,101 +1,66 @@
-use std::time::Duration;
+use std::path::PathBuf;
+use std::process::{Command, Stdio};
-use anyhow::Result;
-use tracing::{debug, info, warn};
+use anyhow::{Context, Result};
+use console::style;
-use crate::{
- commands::build,
- common::{
- manifest_utils::validate_and_load_manifest,
- tool_paths::validate_tool_exists,
- watch_utils::{Debouncer, setup_file_watcher},
- },
-};
+use crate::common::spin_installer::check_and_install_spin;
-pub async fn execute(tool_path: String) -> Result<()> {
- // Validate tool exists
- validate_tool_exists(&tool_path)?;
- let manifest = validate_and_load_manifest(&tool_path)?;
+pub async fn execute(path: Option, port: u16) -> Result<()> {
+ let component_path = path.unwrap_or_else(|| PathBuf::from("."));
- info!("Watching tool: {} for changes...", manifest.tool.name);
-
- // Initial build
- println!("π¨ Initial build...");
- build::execute(Some(tool_path.clone()), None).await?;
-
- // Set up file watcher
- let (tx, rx) = std::sync::mpsc::channel();
- let _watcher = setup_file_watcher(&tool_path, tx)?;
-
- println!("π Watching for changes... (Press Ctrl+C to stop)");
- println!(" Watching:");
- println!(" - {tool_path}/src/");
- println!(" - {tool_path}/Cargo.toml");
- println!(" - {tool_path}/ftl.toml");
-
- // Process file change events
- let mut debouncer = Debouncer::new(Duration::from_millis(500));
-
- loop {
- match rx.recv() {
- Ok(event) => {
- debug!("File change detected: {:?}", event.paths);
-
- // Debounce rapid changes
- if !debouncer.should_trigger() {
- continue;
- }
-
- // Display changed files
- for path in &event.paths {
- if let Ok(rel_path) = path.strip_prefix(&tool_path) {
- println!("π Changed: {}", rel_path.display());
- }
- }
+ // Validate component directory exists
+ if !component_path.join("spin.toml").exists() {
+ anyhow::bail!("No spin.toml found. Not in a component or project directory?");
+ }
- // Rebuild
- println!("π¨ Rebuilding...");
- match build::execute(Some(tool_path.clone()), None).await {
- Ok(_) => println!("β
Build successful"),
- Err(e) => {
- println!("β Build failed: {e}");
- warn!("Build error: {}", e);
- }
+ // Get spin path
+ let spin_path = check_and_install_spin().await?;
+
+ // Build command args
+ let listen_addr = format!("127.0.0.1:{port}");
+ // Pass arguments through to spin up
+ let args = vec!["watch", "--", "--listen", &listen_addr];
+
+ println!();
+ println!(
+ "{} Starting development server with auto-rebuild...",
+ style("βΆ").green()
+ );
+ println!();
+ println!("{} Watching for file changes:", style("π").cyan());
+ println!();
+ println!(
+ "{} Routes will be displayed after components are built",
+ style("βΉ").blue()
+ );
+ println!("{} Press Ctrl+C to stop", style("βΉ").yellow());
+ println!();
+
+ // Run spin watch with inherited stdio so user can see logs
+ let mut child = Command::new(&spin_path)
+ .args(&args)
+ .current_dir(&component_path)
+ .stdin(Stdio::inherit())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .context("Failed to start spin watch")?;
+
+ // Wait for Ctrl+C
+ tokio::select! {
+ _ = tokio::signal::ctrl_c() => {
+ println!();
+ println!("{} Stopping development server...", style("β ").red());
+ }
+ status = tokio::task::spawn_blocking(move || child.wait()) => {
+ if let Ok(Ok(status)) = status {
+ if !status.success() {
+ anyhow::bail!("Spin watch exited with status: {}", status);
}
}
- Err(e) => {
- warn!("Watch error: {}", e);
- break;
- }
}
}
Ok(())
}
-
-#[cfg(test)]
-mod tests {
- use std::path::PathBuf;
-
- use notify::{Event, EventKind, event::ModifyKind};
-
- use crate::common::watch_utils::should_rebuild;
-
- #[test]
- fn test_should_rebuild() {
- // Should rebuild for Rust files
- let event =
- Event::new(EventKind::Modify(ModifyKind::Any)).add_path(PathBuf::from("src/main.rs"));
- assert!(should_rebuild(&event));
-
- // Should rebuild for TOML files
- let event =
- Event::new(EventKind::Modify(ModifyKind::Any)).add_path(PathBuf::from("Cargo.toml"));
- assert!(should_rebuild(&event));
-
- // Should not rebuild for non-code files
- let event =
- Event::new(EventKind::Modify(ModifyKind::Any)).add_path(PathBuf::from("README.md"));
- assert!(!should_rebuild(&event));
- }
-}
diff --git a/packages/ftl-cli/src/common/build_utils.rs b/packages/ftl-cli/src/common/build_utils.rs
deleted file mode 100644
index c771a59d..00000000
--- a/packages/ftl-cli/src/common/build_utils.rs
+++ /dev/null
@@ -1,60 +0,0 @@
-use std::{path::Path, process::Command};
-
-use anyhow::{Context, Result};
-use tracing::info;
-
-/// Optimize a WASM binary using wasm-opt
-pub fn optimize_wasm(wasm_path: &Path, optimization_flags: &[String]) -> Result<()> {
- // Check if wasm-opt is available
- if which::which("wasm-opt").is_err() {
- anyhow::bail!("wasm-opt not found. Install it with: cargo install wasm-opt");
- }
-
- let temp_path = wasm_path.with_extension("wasm.tmp");
-
- let mut cmd = Command::new("wasm-opt");
- cmd.arg(wasm_path).arg("-o").arg(&temp_path);
-
- // Add optimization flags
- for flag in optimization_flags {
- cmd.arg(flag);
- }
-
- info!("Optimizing WASM with flags: {optimization_flags:?}");
-
- let output = cmd.output().context("Failed to execute wasm-opt")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("wasm-opt failed:\n{stderr}");
- }
-
- // Replace original with optimized version
- std::fs::rename(&temp_path, wasm_path)
- .context("Failed to replace WASM with optimized version")?;
-
- Ok(())
-}
-
-/// Get the size of a file in bytes
-pub fn get_file_size(path: &Path) -> Result {
- let metadata = std::fs::metadata(path).with_context(|| {
- let display = path.display();
- format!("Failed to get metadata for {display}")
- })?;
- Ok(metadata.len())
-}
-
-/// Format file size in human-readable format
-pub fn format_size(size: u64) -> String {
- const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
- let mut size = size as f64;
- let mut unit_idx = 0;
-
- while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
- size /= 1024.0;
- unit_idx += 1;
- }
-
- format!("{:.2} {}", size, UNITS[unit_idx])
-}
diff --git a/packages/ftl-cli/src/common/config.rs b/packages/ftl-cli/src/common/config.rs
deleted file mode 100644
index 87775078..00000000
--- a/packages/ftl-cli/src/common/config.rs
+++ /dev/null
@@ -1,69 +0,0 @@
-use std::{fs, path::PathBuf};
-
-use anyhow::{Context, Result};
-use serde::{Deserialize, Serialize};
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct FtlConfig {
- pub username: Option,
-}
-
-impl FtlConfig {
- /// Get the path to the global FTL config directory
- fn config_dir() -> Result {
- let home =
- dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
- Ok(home.join(".ftl"))
- }
-
- /// Get the path to the config file
- fn config_file() -> Result {
- Ok(Self::config_dir()?.join("config.json"))
- }
-
- /// Load config from disk
- pub fn load() -> Result {
- let config_file = Self::config_file()?;
-
- if !config_file.exists() {
- return Ok(Self::default());
- }
-
- let content = fs::read_to_string(&config_file).with_context(|| {
- let display = config_file.display();
- format!("Failed to read config from {display}")
- })?;
-
- let config =
- serde_json::from_str(&content).with_context(|| "Failed to parse config file")?;
-
- Ok(config)
- }
-
- /// Save config to disk
- pub fn save(&self) -> Result<()> {
- let config_dir = Self::config_dir()?;
- fs::create_dir_all(&config_dir).with_context(|| {
- let display = config_dir.display();
- format!("Failed to create config directory at {display}")
- })?;
-
- let config_file = Self::config_file()?;
- let content = serde_json::to_string_pretty(self)?;
-
- fs::write(&config_file, content).with_context(|| {
- let display = config_file.display();
- format!("Failed to write config to {display}")
- })?;
-
- Ok(())
- }
-
- /// Get the app prefix for deployments
- pub fn get_app_prefix(&self) -> String {
- self.username
- .as_ref()
- .map(|u| format!("{u}-"))
- .unwrap_or_else(|| "ftl-".to_string())
- }
-}
diff --git a/packages/ftl-cli/src/common/deploy_utils.rs b/packages/ftl-cli/src/common/deploy_utils.rs
deleted file mode 100644
index 1638263e..00000000
--- a/packages/ftl-cli/src/common/deploy_utils.rs
+++ /dev/null
@@ -1,16 +0,0 @@
-use anyhow::Result;
-
-use crate::common::{config::FtlConfig, manifest_utils::load_manifest_and_name};
-
-/// Infer the deployed app name from the current directory
-/// This combines the username prefix with the tool name
-pub fn infer_app_name(tool_path: &str) -> Result {
- // Load manifest to get tool name
- let (_manifest, tool_name) = load_manifest_and_name(tool_path)?;
-
- // Load config to get username prefix
- let config = FtlConfig::load().unwrap_or_default();
-
- // Generate the full app name
- Ok(format!("{}{}", config.get_app_prefix(), tool_name))
-}
diff --git a/packages/ftl-cli/src/common/manifest_utils.rs b/packages/ftl-cli/src/common/manifest_utils.rs
deleted file mode 100644
index c9db1c15..00000000
--- a/packages/ftl-cli/src/common/manifest_utils.rs
+++ /dev/null
@@ -1,29 +0,0 @@
-use std::path::Path;
-
-use anyhow::{Context, Result};
-
-use super::tool_paths::{get_manifest_path, validate_tool_exists};
-use crate::manifest::ToolManifest;
-
-/// Load a tool manifest from a tool directory
-pub fn load_tool_manifest>(tool_path: P) -> Result {
- let manifest_path = get_manifest_path(&tool_path);
- ToolManifest::load(&manifest_path).with_context(|| {
- let display = manifest_path.display();
- format!("Failed to load manifest from '{display}'")
- })
-}
-
-/// Validate that a tool exists and load its manifest
-pub fn validate_and_load_manifest>(tool_path: P) -> Result {
- validate_tool_exists(&tool_path)?;
- load_tool_manifest(tool_path)
-}
-
-/// Load and validate a tool manifest, returning both the manifest and resolved
-/// tool name
-pub fn load_manifest_and_name>(tool_path: P) -> Result<(ToolManifest, String)> {
- let manifest = validate_and_load_manifest(&tool_path)?;
- let tool_name = manifest.tool.name.clone();
- Ok((manifest, tool_name))
-}
diff --git a/packages/ftl-cli/src/common/mod.rs b/packages/ftl-cli/src/common/mod.rs
index cb4e1fd8..67462516 100644
--- a/packages/ftl-cli/src/common/mod.rs
+++ b/packages/ftl-cli/src/common/mod.rs
@@ -1,8 +1 @@
-pub mod build_utils;
-pub mod config;
-pub mod deploy_utils;
-pub mod manifest_utils;
pub mod spin_installer;
-pub mod spin_utils;
-pub mod tool_paths;
-pub mod watch_utils;
diff --git a/packages/ftl-cli/src/common/spin_installer.rs b/packages/ftl-cli/src/common/spin_installer.rs
index 9c82ada3..a8ced538 100644
--- a/packages/ftl-cli/src/common/spin_installer.rs
+++ b/packages/ftl-cli/src/common/spin_installer.rs
@@ -12,6 +12,25 @@ use tracing::{debug, info};
pub const SPIN_REQUIRED_VERSION: &str = "3.3.1";
const SPIN_RELEASES_URL: &str = "https://github.com/fermyon/spin/releases/download";
+/// Get the path to spin if it exists (does not install)
+pub fn get_spin_path() -> Result {
+ // First check if FTL-managed Spin is installed in ~/.ftl/bin
+ let home_dir = dirs::home_dir().context("Could not determine home directory")?;
+ let ftl_bin_dir = home_dir.join(".ftl").join("bin");
+ let spin_path = ftl_bin_dir.join("spin");
+
+ if spin_path.exists() {
+ return Ok(spin_path);
+ }
+
+ // If no FTL-managed version, check if spin is available in PATH
+ if let Ok(system_spin_path) = which::which("spin") {
+ return Ok(system_spin_path);
+ }
+
+ anyhow::bail!("Spin not found")
+}
+
pub async fn check_and_install_spin() -> Result {
// First check if FTL-managed Spin is installed in ~/.ftl/bin
let home_dir = dirs::home_dir().context("Could not determine home directory")?;
@@ -27,8 +46,23 @@ pub async fn check_and_install_spin() -> Result {
// If no FTL-managed version, check if spin is available in PATH
if let Ok(system_spin_path) = which::which("spin") {
debug!("Found system Spin in PATH at: {:?}", system_spin_path);
- // Even if system spin exists, we should install our own version for consistency
- info!("System Spin found, but FTL will install its own version for version consistency");
+
+ // Check if system spin version is compatible
+ if let Ok(version) = get_spin_version(&system_spin_path) {
+ if is_version_compatible(&version, SPIN_REQUIRED_VERSION)? {
+ debug!(
+ "System Spin version {} is compatible with required version {}",
+ version, SPIN_REQUIRED_VERSION
+ );
+ ensure_akamai_plugin(&system_spin_path)?;
+ return Ok(system_spin_path);
+ } else {
+ info!(
+ "System Spin version {} is older than required version {}",
+ version, SPIN_REQUIRED_VERSION
+ );
+ }
+ }
}
// Need to install
@@ -40,17 +74,25 @@ pub async fn check_and_install_spin() -> Result {
if which::which("spin").is_ok() {
eprintln!();
- eprintln!("Note: System Spin detected, but FTL will install its own version");
- eprintln!("to ensure compatibility. Your system installation won't be affected.");
+ eprintln!("Note: System Spin detected, but it's not compatible with FTL requirements.");
+ eprintln!(
+ "FTL will install its own version. Your system installation won't be affected."
+ );
}
- let should_install = Confirm::new()
- .with_prompt("Would you like to install Spin now?")
- .default(true)
- .interact()?;
-
- if !should_install {
- anyhow::bail!("Spin installation is required to continue");
+ // Check if we're in a terminal
+ if atty::is(atty::Stream::Stdin) {
+ let should_install = Confirm::new()
+ .with_prompt("Would you like to install Spin now?")
+ .default(true)
+ .interact()?;
+
+ if !should_install {
+ anyhow::bail!("Spin installation is required to continue");
+ }
+ } else {
+ // Non-interactive mode (CI, scripts, etc)
+ eprintln!("Running in non-interactive mode, proceeding with installation...");
}
}
@@ -307,3 +349,34 @@ fn ensure_akamai_plugin(spin_path: &PathBuf) -> Result<()> {
Ok(())
}
+
+fn get_spin_version(spin_path: &PathBuf) -> Result {
+ let output = Command::new(spin_path)
+ .arg("--version")
+ .output()
+ .context("Failed to get spin version")?;
+
+ if !output.status.success() {
+ anyhow::bail!("Failed to get spin version");
+ }
+
+ let version_str = String::from_utf8_lossy(&output.stdout);
+ // Parse version from output like "spin 3.3.1 (6fd46d4 2025-06-17)"
+ if let Some(version) = version_str.split_whitespace().nth(1) {
+ Ok(version.to_string())
+ } else {
+ anyhow::bail!("Could not parse spin version from: {}", version_str)
+ }
+}
+
+fn is_version_compatible(actual: &str, required: &str) -> Result {
+ use semver::Version;
+
+ let actual_version = Version::parse(actual)
+ .with_context(|| format!("Failed to parse actual version: {actual}"))?;
+ let required_version = Version::parse(required)
+ .with_context(|| format!("Failed to parse required version: {required}"))?;
+
+ // Check if actual version is >= required version
+ Ok(actual_version >= required_version)
+}
diff --git a/packages/ftl-cli/src/common/spin_utils.rs b/packages/ftl-cli/src/common/spin_utils.rs
deleted file mode 100644
index e2e65c10..00000000
--- a/packages/ftl-cli/src/common/spin_utils.rs
+++ /dev/null
@@ -1,187 +0,0 @@
-use std::{
- path::Path,
- process::{Child, Command, Stdio},
-};
-
-use anyhow::{Context, Result};
-use tracing::{debug, info};
-
-use super::spin_installer::check_and_install_spin;
-
-/// Start a spin server for development (with spin path provided)
-pub fn start_spin_server_with_path>(
- spin_path: &Path,
- tool_path: P,
- port: u16,
- spin_toml_path: Option<&Path>,
-) -> Result {
- let spin_toml = spin_toml_path
- .map(|p| p.to_path_buf())
- .unwrap_or_else(|| tool_path.as_ref().join(".ftl/spin.toml"));
-
- info!("Starting Spin server on port {port}");
-
- let child = Command::new(spin_path)
- .arg("up")
- .arg("--listen")
- .arg(format!("127.0.0.1:{port}"))
- .arg("-f")
- .arg(&spin_toml)
- .current_dir(tool_path)
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()
- .context("Failed to start Spin server")?;
-
- Ok(child)
-}
-
-/// Deploy to FTL EdgeWorkers using Spin
-pub async fn deploy_to_akamai>(
- tool_path: P,
- app_name: Option<&str>,
-) -> Result {
- let spin_path = check_and_install_spin().await?;
- check_akamai_auth(&spin_path).await?;
-
- let tool_path = tool_path.as_ref();
- let spin_toml = tool_path.join(".ftl/spin.toml");
-
- // Check if spin.toml exists
- if !spin_toml.exists() {
- anyhow::bail!("spin.toml not found at {spin_toml:?}. Did you build the tool first?");
- }
-
- // Get absolute path for spin.toml to avoid relative path issues
- let spin_toml_abs = spin_toml
- .canonicalize()
- .context("Failed to get absolute path for spin.toml")?;
-
- // Check if app is already linked by running 'spin aka app status'
- debug!("Checking if app is already linked from: {spin_toml_abs:?}");
- let status_output = Command::new(&spin_path)
- .args([
- "aka",
- "app",
- "status",
- "-f",
- &spin_toml_abs.to_string_lossy(),
- ])
- .current_dir(tool_path)
- .output()
- .context("Failed to run spin aka app status")?;
-
- let app_linked = status_output.status.success();
-
- if !app_linked {
- // Log the error for debugging but don't fail - this is expected for first
- // deployment
- let stderr = String::from_utf8_lossy(&status_output.stderr);
- debug!("App not linked (expected for first deployment): {}", stderr);
- }
-
- // If app is linked, deploy without --create-name
- if app_linked {
- debug!("App is linked, deploying without --create-name");
- let output = Command::new(&spin_path)
- .args([
- "aka",
- "deploy",
- "--from",
- &spin_toml_abs.to_string_lossy(),
- "--no-confirm",
- ])
- .current_dir(tool_path)
- .output()?;
-
- if output.status.success() {
- let stdout = String::from_utf8_lossy(&output.stdout);
- return parse_deployment_info(&stdout);
- } else {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("Deployment failed:\n{stderr}");
- }
- }
-
- // App doesn't exist or isn't linked, need to create/deploy with --create-name
- let app_name = app_name.ok_or_else(|| {
- anyhow::anyhow!("Tool/toolkit not linked. First deployment requires a name")
- })?;
-
- info!("Creating new tool(kit): {app_name}");
-
- let output = Command::new(&spin_path)
- .args([
- "aka",
- "deploy",
- "--from",
- &spin_toml_abs.to_string_lossy(),
- "--create-name",
- app_name,
- "--no-confirm",
- ])
- .current_dir(tool_path)
- .output()?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("Deployment failed:\n{}", stderr);
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- parse_deployment_info(&stdout)
-}
-
-/// Check if Akamai CLI is authenticated
-pub async fn check_akamai_auth(spin_path: &Path) -> Result {
- let output = Command::new(spin_path)
- .args(["aka", "apps", "list"])
- .output()
- .context("Failed to check Akamai authentication")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- if stderr.contains("not logged in") || stderr.contains("authentication") {
- anyhow::bail!("Not authenticated with Akamai. Please run: ftl login");
- }
- }
-
- Ok(true)
-}
-
-#[derive(Debug)]
-pub struct DeploymentInfo {
- pub app_name: String,
- pub url: String,
-}
-
-fn parse_deployment_info(output: &str) -> Result {
- // Parse deployment output to extract URL and app name
- // Example output: "- string-formatter: https://...aka.fermyon.tech/mcp (wildcard)"
- let url = output
- .lines()
- .find(|line| line.contains("http"))
- .and_then(|line| {
- // Extract URL from the line
- line.split_whitespace().find(|s| s.starts_with("http"))
- })
- .ok_or_else(|| anyhow::anyhow!("Could not parse deployment URL"))?;
-
- // Extract app name from the route line "- app-name: https://..."
- let app_name = output
- .lines()
- .find(|line| line.contains("http") && line.contains("-"))
- .and_then(|line| {
- // Extract the part between "- " and ":"
- line.trim_start_matches('-')
- .split(':')
- .next()
- .map(|s| s.trim().to_string())
- })
- .unwrap_or_else(|| "ftl-tool".to_string());
-
- Ok(DeploymentInfo {
- app_name,
- url: url.to_string(),
- })
-}
diff --git a/packages/ftl-cli/src/common/tool_paths.rs b/packages/ftl-cli/src/common/tool_paths.rs
deleted file mode 100644
index cc65e659..00000000
--- a/packages/ftl-cli/src/common/tool_paths.rs
+++ /dev/null
@@ -1,114 +0,0 @@
-use std::path::{Path, PathBuf};
-
-use anyhow::{Context, Result};
-
-/// Get the path to a tool's manifest file
-pub fn get_manifest_path>(tool_path: P) -> PathBuf {
- tool_path.as_ref().join("ftl.toml")
-}
-
-/// Get the path to a tool's .ftl directory
-pub fn get_ftl_dir>(tool_path: P) -> PathBuf {
- tool_path.as_ref().join(".ftl")
-}
-
-/// Get the path to a tool's WASM binary
-pub fn get_wasm_path>(tool_path: P, tool_name: &str, profile: &str) -> PathBuf {
- let wasm_filename = format!("{}.wasm", tool_name.replace('-', "_"));
- let profile_dir = get_profile_dir(profile);
-
- tool_path
- .as_ref()
- .join("target")
- .join("wasm32-wasip1")
- .join(profile_dir)
- .join(wasm_filename)
-}
-
-/// Get the path to a tool's WASM binary based on language
-pub fn get_wasm_path_for_language>(
- tool_path: P,
- tool_name: &str,
- profile: &str,
- language: crate::language::Language,
-) -> PathBuf {
- use crate::language::Language;
-
- match language {
- Language::Rust => {
- let wasm_filename = format!("{}.wasm", tool_name.replace('-', "_"));
- let profile_dir = get_profile_dir(profile);
- tool_path
- .as_ref()
- .join("target")
- .join("wasm32-wasip1")
- .join(profile_dir)
- .join(wasm_filename)
- }
- Language::JavaScript | Language::TypeScript => {
- // JavaScript/TypeScript tools output to dist directory
- tool_path
- .as_ref()
- .join("dist")
- .join(format!("{tool_name}.wasm"))
- }
- }
-}
-
-/// Get the profile directory name based on the profile
-pub fn get_profile_dir(profile: &str) -> &str {
- match profile {
- "dev" => "debug",
- profile => profile,
- }
-}
-
-/// Validate that a tool directory exists
-pub fn validate_tool_exists>(tool_path: P) -> Result<()> {
- let path = tool_path.as_ref();
- if !path.exists() {
- let display = path.display();
- anyhow::bail!("Tool directory '{display}' not found");
- }
-
- let manifest_path = get_manifest_path(path);
- if !manifest_path.exists() {
- let display = path.display();
- anyhow::bail!("No ftl.toml found in '{display}'. Is this an FTL tool directory?");
- }
-
- Ok(())
-}
-
-/// Get the path to spin.toml in the .ftl directory
-pub fn get_spin_toml_path>(tool_path: P) -> PathBuf {
- get_ftl_dir(tool_path).join("spin.toml")
-}
-
-/// Ensure the .ftl directory exists
-pub fn ensure_ftl_dir>(tool_path: P) -> Result {
- let ftl_dir = get_ftl_dir(tool_path);
- std::fs::create_dir_all(&ftl_dir).with_context(|| {
- let display = ftl_dir.display();
- format!("Failed to create .ftl directory at {display}")
- })?;
- Ok(ftl_dir)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_profile_dir_mapping() {
- assert_eq!(get_profile_dir("dev"), "debug");
- assert_eq!(get_profile_dir("release"), "release");
- assert_eq!(get_profile_dir("custom"), "custom");
- }
-
- #[test]
- fn test_wasm_filename() {
- let path = get_wasm_path(".", "my-tool", "release");
- assert!(path.to_str().unwrap().contains("my_tool.wasm"));
- }
-}
diff --git a/packages/ftl-cli/src/common/watch_utils.rs b/packages/ftl-cli/src/common/watch_utils.rs
deleted file mode 100644
index 9a40e914..00000000
--- a/packages/ftl-cli/src/common/watch_utils.rs
+++ /dev/null
@@ -1,140 +0,0 @@
-use std::{
- path::Path,
- sync::mpsc::Sender,
- time::{Duration, Instant},
-};
-
-use anyhow::Result;
-use notify::{Event, EventKind, RecursiveMode, Watcher};
-use tracing::debug;
-
-/// Check if a file system event should trigger a rebuild
-pub fn should_rebuild(event: &Event) -> bool {
- match event.kind {
- EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
- event.paths.iter().any(|path| {
- if let Some(ext) = path.extension() {
- matches!(ext.to_str(), Some("rs") | Some("toml") | Some("lock"))
- } else {
- false
- }
- })
- }
- _ => false,
- }
-}
-
-/// Set up file watcher for a tool directory
-pub fn setup_file_watcher>(
- tool_path: P,
- tx: Sender,
-) -> Result {
- let mut watcher = notify::recommended_watcher(move |res: Result| {
- if let Ok(event) = res {
- if should_rebuild(&event) {
- debug!("File change detected: {:?}", event.paths);
- let _ = tx.send(event);
- }
- }
- })?;
-
- let tool_path = tool_path.as_ref();
-
- // Watch source directory
- let src_dir = tool_path.join("src");
- if src_dir.exists() {
- watcher.watch(&src_dir, RecursiveMode::Recursive)?;
- }
-
- // Watch Cargo files
- let cargo_toml = tool_path.join("Cargo.toml");
- if cargo_toml.exists() {
- watcher.watch(&cargo_toml, RecursiveMode::NonRecursive)?;
- }
-
- let cargo_lock = tool_path.join("Cargo.lock");
- if cargo_lock.exists() {
- watcher.watch(&cargo_lock, RecursiveMode::NonRecursive)?;
- }
-
- // Watch ftl.toml
- let ftl_toml = tool_path.join("ftl.toml");
- if ftl_toml.exists() {
- watcher.watch(&ftl_toml, RecursiveMode::NonRecursive)?;
- }
-
- Ok(watcher)
-}
-
-/// Debouncer to prevent rapid rebuilds
-pub struct Debouncer {
- last_trigger: Instant,
- duration: Duration,
-}
-
-impl Debouncer {
- pub fn new(duration: Duration) -> Self {
- Self {
- last_trigger: Instant::now()
- .checked_sub(duration)
- .unwrap_or(Instant::now()),
- duration,
- }
- }
-
- /// Check if enough time has passed since last trigger
- pub fn should_trigger(&mut self) -> bool {
- let now = Instant::now();
- if now.duration_since(self.last_trigger) >= self.duration {
- self.last_trigger = now;
- true
- } else {
- false
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::path::PathBuf;
-
- use notify::event::ModifyKind;
-
- use super::*;
-
- #[test]
- fn test_should_rebuild_rust_files() {
- let event =
- Event::new(EventKind::Modify(ModifyKind::Any)).add_path(PathBuf::from("src/main.rs"));
- assert!(should_rebuild(&event));
- }
-
- #[test]
- fn test_should_rebuild_toml_files() {
- let event =
- Event::new(EventKind::Modify(ModifyKind::Any)).add_path(PathBuf::from("Cargo.toml"));
- assert!(should_rebuild(&event));
- }
-
- #[test]
- fn test_should_not_rebuild_other_files() {
- let event =
- Event::new(EventKind::Modify(ModifyKind::Any)).add_path(PathBuf::from("README.md"));
- assert!(!should_rebuild(&event));
- }
-
- #[test]
- fn test_debouncer() {
- let mut debouncer = Debouncer::new(Duration::from_millis(100));
-
- // First trigger should succeed
- assert!(debouncer.should_trigger());
-
- // Immediate second trigger should fail
- assert!(!debouncer.should_trigger());
-
- // After waiting, should succeed
- std::thread::sleep(Duration::from_millis(150));
- assert!(debouncer.should_trigger());
- }
-}
diff --git a/packages/ftl-cli/src/language.rs b/packages/ftl-cli/src/language.rs
index 916931f9..2a9235e9 100644
--- a/packages/ftl-cli/src/language.rs
+++ b/packages/ftl-cli/src/language.rs
@@ -1,33 +1,20 @@
-use std::{fmt, path::Path};
-
-use anyhow::Result;
use serde::{Deserialize, Serialize};
+use std::fmt;
-use crate::manifest::Manifest;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
+#[derive(Default)]
pub enum Language {
- #[default]
Rust,
JavaScript,
+ #[default]
TypeScript,
}
-impl fmt::Display for Language {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Language::Rust => write!(f, "rust"),
- Language::JavaScript => write!(f, "javascript"),
- Language::TypeScript => write!(f, "typescript"),
- }
- }
-}
-
impl Language {
pub fn from_str(s: &str) -> Option {
match s.to_lowercase().as_str() {
- "rust" => Some(Language::Rust),
+ "rust" | "rs" => Some(Language::Rust),
"javascript" | "js" => Some(Language::JavaScript),
"typescript" | "ts" => Some(Language::TypeScript),
_ => None,
@@ -35,51 +22,12 @@ impl Language {
}
}
-pub trait LanguageSupport: Send + Sync {
- fn new_project(&self, name: &str, description: &str, template: &str, path: &Path)
- -> Result<()>;
- fn build(&self, manifest: &Manifest, path: &Path) -> Result<()>;
- fn test(&self, manifest: &Manifest, path: &Path) -> Result<()>;
- fn validate_environment(&self) -> Result<()>;
-}
-
-pub mod javascript;
-pub mod rust;
-pub mod typescript;
-
-use self::{javascript::JavaScriptSupport, rust::RustSupport, typescript::TypeScriptSupport};
-
-pub fn get_language_support(language: Language) -> Box {
- match language {
- Language::Rust => Box::new(RustSupport::new()),
- Language::JavaScript => Box::new(JavaScriptSupport::new()),
- Language::TypeScript => Box::new(TypeScriptSupport::new()),
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum PackageManager {
- Npm,
- Yarn,
- Pnpm,
-}
-
-impl PackageManager {
- pub fn detect(path: &Path) -> Self {
- if path.join("pnpm-lock.yaml").exists() {
- PackageManager::Pnpm
- } else if path.join("yarn.lock").exists() {
- PackageManager::Yarn
- } else {
- PackageManager::Npm
- }
- }
-
- pub fn run_command(&self, script: &str) -> String {
+impl fmt::Display for Language {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
- PackageManager::Npm => format!("npm run {script}"),
- PackageManager::Yarn => format!("yarn {script}"),
- PackageManager::Pnpm => format!("pnpm {script}"),
+ Language::Rust => write!(f, "Rust"),
+ Language::JavaScript => write!(f, "JavaScript"),
+ Language::TypeScript => write!(f, "TypeScript"),
}
}
}
diff --git a/packages/ftl-cli/src/language/javascript.rs b/packages/ftl-cli/src/language/javascript.rs
deleted file mode 100644
index ec1b810a..00000000
--- a/packages/ftl-cli/src/language/javascript.rs
+++ /dev/null
@@ -1,241 +0,0 @@
-use std::{fs, path::Path, process::Command};
-
-use anyhow::{Context, Result};
-
-use crate::{
- common::spin_installer::check_and_install_spin,
- language::{LanguageSupport, PackageManager},
- manifest::Manifest,
-};
-
-pub struct JavaScriptSupport;
-
-impl JavaScriptSupport {
- pub fn new() -> Self {
- Self
- }
-
- fn render_template(&self, template_str: &str, name: &str, description: &str) -> Result {
- use handlebars::Handlebars;
- use serde_json::json;
-
- let handlebars = Handlebars::new();
-
- // Convert name to PascalCase for class name
- let tool_name_class = name
- .split(&['-', '_'][..])
- .map(|word| {
- let mut chars = word.chars();
- match chars.next() {
- None => String::new(),
- Some(first) => first.to_uppercase().collect::() + chars.as_str(),
- }
- })
- .collect::();
-
- // Get the SDK version from compile-time constant
- let sdk_version = env!("FTL_SDK_TS_VERSION");
-
- let data = json!({
- "name": name,
- "description": description,
- "tool_name_class": tool_name_class,
- "sdk_version": sdk_version,
- });
-
- handlebars
- .render_template(template_str, &data)
- .map_err(|e| anyhow::anyhow!("Template rendering failed: {e}"))
- }
-}
-
-impl LanguageSupport for JavaScriptSupport {
- fn new_project(
- &self,
- name: &str,
- description: &str,
- _template: &str,
- path: &Path,
- ) -> Result<()> {
- // Get spin path using blocking runtime
- let spin_path = tokio::runtime::Handle::try_current()
- .ok()
- .and_then(|handle| {
- tokio::task::block_in_place(|| handle.block_on(check_and_install_spin()).ok())
- })
- .unwrap_or_else(|| {
- // If no runtime exists, create one
- let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
- rt.block_on(check_and_install_spin())
- .expect("Failed to install Spin")
- });
-
- // Use spin new to create the project
- let output = Command::new(&spin_path)
- .args([
- "new",
- "-t",
- "http-js",
- "-o",
- path.to_str().unwrap(),
- "--accept-defaults",
- name,
- ])
- .output()
- .context("Failed to run spin new")?;
-
- if !output.status.success() {
- anyhow::bail!(
- "Failed to create JavaScript project with spin new:\n{}",
- String::from_utf8_lossy(&output.stderr)
- );
- }
-
- // Move spin.toml to .ftl directory
- let spin_toml_src = path.join("spin.toml");
- let ftl_dir = path.join(".ftl");
- fs::create_dir_all(&ftl_dir)?;
- let spin_toml_dest = ftl_dir.join("spin.toml");
-
- if spin_toml_src.exists() {
- fs::rename(&spin_toml_src, &spin_toml_dest)
- .context("Failed to move spin.toml to .ftl directory")?;
- }
-
- // Overlay FTL-specific files
-
- // 1. Add ftl.toml
- let ftl_toml = self.render_template(
- include_str!("../templates/javascript/ftl.toml.hbs"),
- name,
- description,
- )?;
- fs::write(path.join("ftl.toml"), ftl_toml)?;
-
- // 2. Replace src/index.js with MCP implementation
- let index_js = self.render_template(
- include_str!("../templates/javascript/index.js.hbs"),
- name,
- description,
- )?;
- fs::write(path.join("src/index.js"), index_js)?;
-
- // 3. Update package.json to include @ftl/sdk-js
- let package_json = self.render_template(
- include_str!("../templates/javascript/package.json.hbs"),
- name,
- description,
- )?;
- fs::write(path.join("package.json"), package_json)?;
-
- // 4. Replace webpack.config.js
- let webpack_config = include_str!("../templates/javascript/webpack.config.js");
- fs::write(path.join("webpack.config.js"), webpack_config)?;
-
- // 5. Create test directory and test file
- fs::create_dir_all(path.join("test"))?;
- let test_js = self.render_template(
- include_str!("../templates/javascript/tool.test.js.hbs"),
- name,
- description,
- )?;
- fs::write(path.join("test/tool.test.js"), test_js)?;
-
- // 6. Add vitest.config.js
- let vitest_config = include_str!("../templates/javascript/vitest.config.js");
- fs::write(path.join("vitest.config.js"), vitest_config)?;
-
- Ok(())
- }
-
- fn build(&self, _manifest: &Manifest, path: &Path) -> Result<()> {
- // Get spin path using blocking runtime
- let spin_path = tokio::runtime::Handle::try_current()
- .ok()
- .and_then(|handle| {
- tokio::task::block_in_place(|| handle.block_on(check_and_install_spin()).ok())
- })
- .unwrap_or_else(|| {
- // If no runtime exists, create one
- let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
- rt.block_on(check_and_install_spin())
- .expect("Failed to install Spin")
- });
-
- // Run spin build with spin.toml from .ftl directory
- let spin_toml_path = path.join(".ftl/spin.toml");
-
- // Ensure the spin.toml exists
- if !spin_toml_path.exists() {
- anyhow::bail!("spin.toml not found at: {}", spin_toml_path.display());
- }
-
- // Convert to absolute path to avoid issues with relative paths
- let absolute_spin_toml = spin_toml_path
- .canonicalize()
- .context("Failed to resolve spin.toml path")?;
-
- let output = Command::new(&spin_path)
- .args(["build", "-f", absolute_spin_toml.to_str().unwrap()])
- .current_dir(path)
- .output()
- .context("Failed to run spin build")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("Build failed:\n{stderr}");
- }
-
- Ok(())
- }
-
- fn test(&self, _manifest: &Manifest, path: &Path) -> Result<()> {
- let pm = PackageManager::detect(path);
- let test_cmd = pm.run_command("test");
- let mut cmd_parts = test_cmd.split_whitespace();
- let output = Command::new(cmd_parts.next().unwrap())
- .args(cmd_parts)
- .current_dir(path)
- .output()
- .context("Failed to run tests")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- let stdout = String::from_utf8_lossy(&output.stdout);
-
- if !stdout.is_empty() {
- println!("\nOutput:\n{stdout}");
- }
- if !stderr.is_empty() {
- println!("\nErrors:\n{stderr}");
- }
-
- anyhow::bail!("Tests failed");
- }
-
- println!("{}", String::from_utf8_lossy(&output.stdout));
- Ok(())
- }
-
- fn validate_environment(&self) -> Result<()> {
- // Check if Node.js is installed
- let output = Command::new("node")
- .arg("--version")
- .output()
- .context("Node.js is not installed. Please install Node.js from https://nodejs.org")?;
-
- let version = String::from_utf8_lossy(&output.stdout);
- let version_parts: Vec<&str> = version.trim().trim_start_matches('v').split('.').collect();
-
- if let Some(major) = version_parts.first().and_then(|v| v.parse::().ok()) {
- if major < 18 {
- let version = version.trim();
- anyhow::bail!(
- "Node.js version {version} is too old. Please install Node.js 18 or later."
- );
- }
- }
-
- Ok(())
- }
-}
diff --git a/packages/ftl-cli/src/language/rust.rs b/packages/ftl-cli/src/language/rust.rs
deleted file mode 100644
index 9a24b176..00000000
--- a/packages/ftl-cli/src/language/rust.rs
+++ /dev/null
@@ -1,122 +0,0 @@
-use std::{path::Path, process::Command};
-
-use anyhow::{Context, Result};
-
-use crate::{language::LanguageSupport, manifest::Manifest, templates};
-
-pub struct RustSupport;
-
-impl RustSupport {
- pub fn new() -> Self {
- Self
- }
-}
-
-impl LanguageSupport for RustSupport {
- fn new_project(
- &self,
- name: &str,
- description: &str,
- _template: &str,
- path: &Path,
- ) -> Result<()> {
- // Use existing template generation logic
- templates::create_tool(name, description, path)?;
- Ok(())
- }
-
- fn build(&self, manifest: &Manifest, path: &Path) -> Result<()> {
- // Check for Rust toolchain
- self.validate_environment()?;
-
- // Build the project
- let output = Command::new("cargo")
- .args(["build", "--target", "wasm32-wasip1", "--release"])
- .current_dir(path)
- .output()
- .context("Failed to execute cargo build")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("Build failed:\n{stderr}");
- }
-
- // Run wasm-opt if available
- let wasm_path = path
- .join("target/wasm32-wasip1/release")
- .join(format!("{}.wasm", manifest.tool.name.replace('-', "_")));
-
- if wasm_path.exists() {
- self.optimize_wasm(&wasm_path)?;
- }
-
- Ok(())
- }
-
- fn test(&self, _manifest: &Manifest, path: &Path) -> Result<()> {
- let output = Command::new("cargo")
- .arg("test")
- .current_dir(path)
- .output()
- .context("Failed to execute cargo test")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("Tests failed:\n{stderr}");
- }
-
- Ok(())
- }
-
- fn validate_environment(&self) -> Result<()> {
- // Check if Rust is installed
- Command::new("rustc")
- .arg("--version")
- .output()
- .context("Rust is not installed. Please install Rust from https://rustup.rs")?;
-
- // Check if the wasm32-wasip1 target is installed
- let output = Command::new("rustup")
- .args(["target", "list", "--installed"])
- .output()
- .context("Failed to check installed Rust targets")?;
-
- let installed_targets = String::from_utf8_lossy(&output.stdout);
- if !installed_targets.contains("wasm32-wasip1") {
- anyhow::bail!(
- "The wasm32-wasip1 target is not installed. Please run: rustup target add \
- wasm32-wasip1"
- );
- }
-
- Ok(())
- }
-}
-
-impl RustSupport {
- fn optimize_wasm(&self, wasm_path: &Path) -> Result<()> {
- // Check if wasm-opt is available
- if Command::new("wasm-opt").arg("--version").output().is_ok() {
- println!("Optimizing WASM with wasm-opt...");
-
- let output = Command::new("wasm-opt")
- .args([
- "-O3",
- "--enable-simd",
- "--enable-bulk-memory",
- wasm_path.to_str().unwrap(),
- "-o",
- wasm_path.to_str().unwrap(),
- ])
- .output()
- .context("Failed to run wasm-opt")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- eprintln!("Warning: wasm-opt optimization failed:\n{stderr}");
- }
- }
-
- Ok(())
- }
-}
diff --git a/packages/ftl-cli/src/language/typescript.rs b/packages/ftl-cli/src/language/typescript.rs
deleted file mode 100644
index 91c7eb95..00000000
--- a/packages/ftl-cli/src/language/typescript.rs
+++ /dev/null
@@ -1,247 +0,0 @@
-use std::{fs, path::Path, process::Command};
-
-use anyhow::{Context, Result};
-
-use crate::{
- common::spin_installer::check_and_install_spin,
- language::{LanguageSupport, PackageManager},
- manifest::Manifest,
-};
-
-pub struct TypeScriptSupport;
-
-impl TypeScriptSupport {
- pub fn new() -> Self {
- Self
- }
-
- fn render_template(&self, template_str: &str, name: &str, description: &str) -> Result {
- use handlebars::Handlebars;
- use serde_json::json;
-
- let handlebars = Handlebars::new();
-
- // Convert name to PascalCase for class name
- let tool_name_class = name
- .split(&['-', '_'][..])
- .map(|word| {
- let mut chars = word.chars();
- match chars.next() {
- None => String::new(),
- Some(first) => first.to_uppercase().collect::() + chars.as_str(),
- }
- })
- .collect::();
-
- // Get the SDK version from compile-time constant
- let sdk_version = env!("FTL_SDK_TS_VERSION");
-
- let data = json!({
- "name": name,
- "description": description,
- "tool_name_class": tool_name_class,
- "sdk_version": sdk_version,
- });
-
- handlebars
- .render_template(template_str, &data)
- .map_err(|e| anyhow::anyhow!("Template rendering failed: {e}"))
- }
-}
-
-impl LanguageSupport for TypeScriptSupport {
- fn new_project(
- &self,
- name: &str,
- description: &str,
- _template: &str,
- path: &Path,
- ) -> Result<()> {
- // Get spin path using blocking runtime
- let spin_path = tokio::runtime::Handle::try_current()
- .ok()
- .and_then(|handle| {
- tokio::task::block_in_place(|| handle.block_on(check_and_install_spin()).ok())
- })
- .unwrap_or_else(|| {
- // If no runtime exists, create one
- let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
- rt.block_on(check_and_install_spin())
- .expect("Failed to install Spin")
- });
-
- // Use spin new to create the project with TypeScript template
- let output = Command::new(&spin_path)
- .args([
- "new",
- "-t",
- "http-ts",
- "-o",
- path.to_str().unwrap(),
- "--accept-defaults",
- name,
- ])
- .output()
- .context("Failed to run spin new")?;
-
- if !output.status.success() {
- anyhow::bail!(
- "Failed to create TypeScript project with spin new:\n{}",
- String::from_utf8_lossy(&output.stderr)
- );
- }
-
- // Move spin.toml to .ftl directory
- let spin_toml_src = path.join("spin.toml");
- let ftl_dir = path.join(".ftl");
- fs::create_dir_all(&ftl_dir)?;
- let spin_toml_dest = ftl_dir.join("spin.toml");
-
- if spin_toml_src.exists() {
- fs::rename(&spin_toml_src, &spin_toml_dest)
- .context("Failed to move spin.toml to .ftl directory")?;
- }
-
- // Overlay FTL-specific files
-
- // 1. Add ftl.toml
- let ftl_toml = self.render_template(
- include_str!("../templates/typescript/ftl.toml.hbs"),
- name,
- description,
- )?;
- fs::write(path.join("ftl.toml"), ftl_toml)?;
-
- // 2. Replace src/index.ts with MCP implementation
- let index_ts = self.render_template(
- include_str!("../templates/typescript/index.ts.hbs"),
- name,
- description,
- )?;
- fs::write(path.join("src/index.ts"), index_ts)?;
-
- // 3. Update package.json to include @ftl/sdk-js and TypeScript dependencies
- let package_json = self.render_template(
- include_str!("../templates/typescript/package.json.hbs"),
- name,
- description,
- )?;
- fs::write(path.join("package.json"), package_json)?;
-
- // 4. Replace webpack.config.js with TypeScript version
- let webpack_config = include_str!("../templates/typescript/webpack.config.js");
- fs::write(path.join("webpack.config.js"), webpack_config)?;
-
- // 5. Add tsconfig.json
- let tsconfig = include_str!("../templates/typescript/tsconfig.json");
- fs::write(path.join("tsconfig.json"), tsconfig)?;
-
- // 6. Create test directory and test file
- fs::create_dir_all(path.join("test"))?;
- let test_ts = self.render_template(
- include_str!("../templates/typescript/tool.test.ts.hbs"),
- name,
- description,
- )?;
- fs::write(path.join("test/tool.test.ts"), test_ts)?;
-
- // 7. Add vitest.config.ts
- let vitest_config = include_str!("../templates/typescript/vitest.config.ts");
- fs::write(path.join("vitest.config.ts"), vitest_config)?;
-
- Ok(())
- }
-
- fn build(&self, _manifest: &Manifest, path: &Path) -> Result<()> {
- // Get spin path using blocking runtime
- let spin_path = tokio::runtime::Handle::try_current()
- .ok()
- .and_then(|handle| {
- tokio::task::block_in_place(|| handle.block_on(check_and_install_spin()).ok())
- })
- .unwrap_or_else(|| {
- // If no runtime exists, create one
- let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
- rt.block_on(check_and_install_spin())
- .expect("Failed to install Spin")
- });
-
- // Run spin build with spin.toml from .ftl directory
- let spin_toml_path = path.join(".ftl/spin.toml");
-
- // Ensure the spin.toml exists
- if !spin_toml_path.exists() {
- let display = spin_toml_path.display();
- anyhow::bail!("spin.toml not found at: {display}");
- }
-
- // Convert to absolute path to avoid issues with relative paths
- let absolute_spin_toml = spin_toml_path
- .canonicalize()
- .context("Failed to resolve spin.toml path")?;
-
- let output = Command::new(&spin_path)
- .args(["build", "-f", absolute_spin_toml.to_str().unwrap()])
- .current_dir(path)
- .output()
- .context("Failed to run spin build")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("Build failed:\n{stderr}");
- }
-
- Ok(())
- }
-
- fn test(&self, _manifest: &Manifest, path: &Path) -> Result<()> {
- let pm = PackageManager::detect(path);
- let test_cmd = pm.run_command("test");
- let mut cmd_parts = test_cmd.split_whitespace();
- let output = Command::new(cmd_parts.next().unwrap())
- .args(cmd_parts)
- .current_dir(path)
- .output()
- .context("Failed to run tests")?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- let stdout = String::from_utf8_lossy(&output.stdout);
-
- if !stdout.is_empty() {
- println!("\nOutput:\n{stdout}");
- }
- if !stderr.is_empty() {
- println!("\nErrors:\n{stderr}");
- }
-
- anyhow::bail!("Tests failed");
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- println!("{stdout}");
- Ok(())
- }
-
- fn validate_environment(&self) -> Result<()> {
- // Check if Node.js is installed
- let output = Command::new("node")
- .arg("--version")
- .output()
- .context("Node.js is not installed. Please install Node.js from https://nodejs.org")?;
-
- let version = String::from_utf8_lossy(&output.stdout);
- let version_parts: Vec<&str> = version.trim().trim_start_matches('v').split('.').collect();
-
- if let Some(major) = version_parts.first().and_then(|v| v.parse::().ok()) {
- if major < 18 {
- let version = version.trim();
- anyhow::bail!(
- "Node.js version {version} is too old. Please install Node.js 18 or later."
- );
- }
- }
-
- Ok(())
- }
-}
diff --git a/packages/ftl-cli/src/main.rs b/packages/ftl-cli/src/main.rs
index 12b97126..96f86b69 100644
--- a/packages/ftl-cli/src/main.rs
+++ b/packages/ftl-cli/src/main.rs
@@ -8,12 +8,10 @@ mod commands;
mod common;
mod language;
mod manifest;
-mod spin_generator;
-mod templates;
#[derive(Parser)]
#[command(name = "ftl")]
-#[command(about = "FTL - WebAssembly MCP tools")]
+#[command(about = "Build and deploy Model Context Protocol (MCP) servers on WebAssembly")]
#[command(version)]
#[command(author)]
struct Cli {
@@ -27,185 +25,181 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
- /// Create a new tool from template
- New {
- /// Name of the tool
- name: String,
- /// Description of the tool
- #[arg(short, long)]
- description: Option,
- /// Programming language to use (rust, javascript)
- #[arg(short, long)]
- language: Option,
+ /// Initialize a new MCP project
+ Init {
+ /// Name of the project
+ name: Option,
+
+ /// Create in current directory
+ #[arg(long)]
+ here: bool,
},
- /// Build a tool
- Build {
- /// Name of the tool to build (defaults to current directory)
+ /// Add a component to the current project
+ Add {
+ /// Name of the component
name: Option,
- /// Build profile to use
+
+ /// Component description
#[arg(short, long)]
- profile: Option,
- /// Start serving after build completes
+ description: Option,
+
+ /// Language (rust, typescript, javascript)
#[arg(short, long)]
- serve: bool,
- },
+ language: Option,
- /// Serve a tool locally
- Serve {
- /// Name of the tool to serve (defaults to current directory)
- name: Option,
- /// Port to serve on
- #[arg(short, long, default_value = "3000")]
- port: u16,
- /// Build before serving
+ /// HTTP route for the component
#[arg(short, long)]
- build: bool,
- },
+ route: Option,
- /// Run tests for a tool
- Test {
- /// Name of the tool to test (defaults to current directory)
- name: Option,
- },
+ /// Use a Git repository as the template source
+ #[arg(long, conflicts_with = "dir", conflicts_with = "tar")]
+ git: Option,
- /// Deploy a tool
- Deploy {
- /// Name of the tool to deploy (defaults to current directory)
- name: Option,
+ /// The branch to use from the Git repository
+ #[arg(long, requires = "git")]
+ branch: Option,
+
+ /// Use a local directory as the template source
+ #[arg(long, conflicts_with = "git", conflicts_with = "tar")]
+ dir: Option,
+
+ /// Use a tarball as the template source
+ #[arg(long, conflicts_with = "git", conflicts_with = "dir")]
+ tar: Option,
},
- /// Export a tool as a standalone WASM component
- Export {
- /// Name of the tool to export (defaults to current directory)
- name: Option,
- /// Output path for the component WASM file
+ /// Build the component or project
+ Build {
+ /// Build in release mode
#[arg(short, long)]
- output: Option,
- /// Build profile to use
+ release: bool,
+
+ /// Path to component (defaults to current directory)
#[arg(short, long)]
- profile: Option,
+ path: Option,
},
- /// Watch a tool for changes and rebuild
- Watch {
- /// Name of the tool to watch (defaults to current directory)
- name: Option,
- },
+ /// Run the component locally
+ Up {
+ /// Build before running
+ #[arg(long)]
+ build: bool,
- /// Validate tool configuration
- Validate {
- /// Name of the tool to validate (defaults to current directory)
- name: Option,
- },
+ /// Port to serve on
+ #[arg(short, long, default_value = "3000")]
+ port: u16,
- /// Show binary size information
- Size {
- /// Name of the tool (defaults to current directory)
- name: Option,
- /// Show detailed analysis including sections and imports
- #[arg(short, long)]
- verbose: bool,
+ /// Path to component (defaults to current directory)
+ #[arg(long)]
+ path: Option,
},
- /// List deployed tools and toolkits
- List,
-
- /// Login to FTL Edge
- Login,
+ /// Build and run the component, rebuilding on file changes
+ Watch {
+ /// Port to serve on
+ #[arg(short, long, default_value = "3000")]
+ port: u16,
- /// Get status of a deployed tool or toolkit
- Status {
- /// Name of the tool or toolkit (defaults to current directory)
- name: Option,
+ /// Path to component (defaults to current directory)
+ #[arg(long)]
+ path: Option,
},
- /// Delete a deployed tool or toolkit
- Delete {
- /// Name of the tool or toolkit (defaults to current directory)
- name: Option,
- /// Skip confirmation prompt
+ /// Run tests
+ Test {
+ /// Path to component (defaults to current directory)
#[arg(short, long)]
- yes: bool,
+ path: Option,
},
- /// View logs from a deployed tool or toolkit
- Logs {
- /// Name of the tool or toolkit (defaults to current directory)
- name: Option,
- /// Follow log output (not yet supported by spin)
- #[arg(short, long, hide = true)]
- follow: bool,
- /// Number of lines to show from the end
+ /// Publish component to registry
+ Publish {
+ /// Registry URL (defaults to ghcr.io)
#[arg(short, long)]
- tail: Option,
- },
+ registry: Option,
- /// Link current tool to an existing deployment
- Link {
- /// Name of the deployed tool to link to
- name: String,
- /// Path to the tool directory (defaults to current directory)
+ /// Tag/version to publish
#[arg(short, long)]
- path: Option,
+ tag: Option,
+
+ /// Path to component (defaults to current directory)
+ #[arg(long)]
+ path: Option,
},
- /// Unlink current tool from its deployment
- Unlink {
- /// Path to the tool directory (defaults to current directory)
+ /// Deploy the project to FTL
+ Deploy {
+ /// Environment to deploy to
#[arg(short, long)]
- path: Option,
+ environment: Option,
},
- /// Manage toolkits (collections of tools)
- Toolkit {
+ /// Interact with component registries
+ Registry {
#[command(subcommand)]
- command: ToolkitCommand,
+ command: RegistryCommand,
},
- /// Manage Spin installation
- Spin {
+ /// Setup and configure FTL
+ Setup {
#[command(subcommand)]
- command: SpinCommand,
+ command: SetupCommand,
},
}
#[derive(Subcommand)]
-enum SpinCommand {
- /// Install Spin
- Install,
- /// Update Spin to the latest supported version
- Update,
- /// Remove Spin (if installed by FTL)
- Remove,
- /// Show Spin installation info
+enum SetupCommand {
+ /// Install or update FTL templates
+ Templates {
+ /// Force reinstall even if already installed
+ #[arg(long)]
+ force: bool,
+
+ /// Use a Git repository as the template source
+ #[arg(long, conflicts_with = "dir", conflicts_with = "tar")]
+ git: Option,
+
+ /// The branch to use from the Git repository
+ #[arg(long, requires = "git")]
+ branch: Option,
+
+ /// Use a local directory as the template source
+ #[arg(long, conflicts_with = "git", conflicts_with = "tar")]
+ dir: Option,
+
+ /// Use a tarball as the template source
+ #[arg(long, conflicts_with = "git", conflicts_with = "dir")]
+ tar: Option,
+ },
+
+ /// Show current configuration
Info,
}
#[derive(Subcommand)]
-enum ToolkitCommand {
- /// Build a toolkit from multiple tools
- Build {
- /// Name of the toolkit
- #[arg(long)]
- name: String,
- /// Tools to include in the toolkit
- tools: Vec,
+enum RegistryCommand {
+ /// List available components
+ List {
+ /// Registry to list from
+ #[arg(short, long)]
+ registry: Option,
},
- /// Serve a toolkit locally
- Serve {
- /// Name of the toolkit
- name: String,
- /// Port to serve on
- #[arg(short, long, default_value = "3000")]
- port: u16,
+ /// Search for components
+ Search {
+ /// Search query
+ query: String,
+
+ /// Registry to search in
+ #[arg(short, long)]
+ registry: Option,
},
- /// Deploy a toolkit to FTL Edge
- Deploy {
- /// Name of the toolkit
- name: String,
+ /// Show component information
+ Info {
+ /// Component name or URL
+ component: String,
},
}
@@ -225,60 +219,55 @@ async fn main() -> Result<()> {
tracing_subscriber::fmt().with_env_filter(filter).init();
match cli.command {
- Command::New {
+ Command::Init { name, here } => commands::init::execute(name, here).await,
+ Command::Add {
name,
description,
language,
- } => commands::new::execute(name, description, language).await,
- Command::Build {
- name,
- profile,
- serve,
+ route,
+ git,
+ branch,
+ dir,
+ tar,
} => {
- if serve {
- commands::build::execute_and_serve(name, profile).await
- } else {
- commands::build::execute(name, profile).await
- }
- }
- Command::Serve { name, port, build } => {
- commands::serve::execute(name.unwrap_or_else(|| ".".to_string()), port, build).await
- }
- Command::Test { name } => commands::test::execute(name).await,
- Command::Deploy { name } => {
- commands::deploy::execute(name.unwrap_or_else(|| ".".to_string())).await
+ commands::add::execute(commands::add::AddOptions {
+ name,
+ description,
+ language,
+ route,
+ git,
+ branch,
+ dir,
+ tar,
+ })
+ .await
}
- Command::Export {
- name,
- output,
- profile,
- } => commands::export::execute(name, output, profile).await,
- Command::Watch { name } => {
- commands::watch::execute(name.unwrap_or_else(|| ".".to_string())).await
- }
- Command::Validate { name } => {
- commands::validate::execute(name.unwrap_or_else(|| ".".to_string())).await
- }
- Command::Size { name, verbose } => {
- commands::size::execute(name.unwrap_or_else(|| ".".to_string()), verbose).await
- }
- Command::List => commands::list::execute().await,
- Command::Login => commands::login::execute().await,
- Command::Status { name } => commands::status::execute(name).await,
- Command::Delete { name, yes } => commands::delete::execute(name, yes).await,
- Command::Logs { name, follow, tail } => commands::logs::execute(name, follow, tail).await,
- Command::Link { name, path } => commands::link::execute(name, path).await,
- Command::Unlink { path } => commands::unlink::execute(path).await,
- Command::Toolkit { command } => match command {
- ToolkitCommand::Build { name, tools } => commands::toolkit::build(name, tools).await,
- ToolkitCommand::Serve { name, port } => commands::toolkit::serve(name, port).await,
- ToolkitCommand::Deploy { name } => commands::toolkit::deploy(name).await,
+ Command::Build { release, path } => commands::build::execute(path, release).await,
+ Command::Up { build, port, path } => commands::up::execute(path, port, build).await,
+ Command::Watch { port, path } => commands::watch::execute(path, port).await,
+ Command::Test { path } => commands::test::execute(path).await,
+ Command::Publish {
+ registry,
+ tag,
+ path,
+ } => commands::publish::execute(path, registry, tag).await,
+ Command::Deploy { environment } => commands::deploy::execute(environment).await,
+ Command::Registry { command } => match command {
+ RegistryCommand::List { registry } => commands::registry::list(registry).await,
+ RegistryCommand::Search { query, registry } => {
+ commands::registry::search(query, registry).await
+ }
+ RegistryCommand::Info { component } => commands::registry::info(component).await,
},
- Command::Spin { command } => match command {
- SpinCommand::Install => commands::spin::install().await,
- SpinCommand::Update => commands::spin::update().await,
- SpinCommand::Remove => commands::spin::remove().await,
- SpinCommand::Info => commands::spin::info().await,
+ Command::Setup { command } => match command {
+ SetupCommand::Templates {
+ force,
+ git,
+ branch,
+ dir,
+ tar,
+ } => commands::setup::templates(force, git, branch, dir, tar).await,
+ SetupCommand::Info => commands::setup::info().await,
},
}
}
diff --git a/packages/ftl-cli/src/manifest.rs b/packages/ftl-cli/src/manifest.rs
index 9b7c805e..c43f86fc 100644
--- a/packages/ftl-cli/src/manifest.rs
+++ b/packages/ftl-cli/src/manifest.rs
@@ -5,165 +5,28 @@ use serde::{Deserialize, Serialize};
use crate::language::Language;
-// Type alias for backwards compatibility
-pub type Manifest = ToolManifest;
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ToolManifest {
- pub tool: ToolConfig,
- #[serde(default)]
- pub build: BuildConfig,
- #[serde(default)]
- pub optimization: OptimizationConfig,
- #[serde(default)]
- pub runtime: RuntimeConfig,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ToolConfig {
- pub name: String,
- pub version: String,
- pub description: String,
- #[serde(default)]
- pub language: Language,
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct BuildConfig {
- #[serde(default = "default_profile")]
- pub profile: String,
- #[serde(default)]
- pub features: Vec,
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct OptimizationConfig {
- #[serde(default)]
- pub flags: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct RuntimeConfig {
- #[serde(default)]
- pub allowed_hosts: Vec,
-}
-
+/// Component manifest (ftl.toml)
#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ToolkitManifest {
- pub toolkit: ToolkitConfig,
- pub tools: Vec,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub gateway: Option,
+pub struct ComponentManifest {
+ pub component: ComponentConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ToolkitConfig {
+pub struct ComponentConfig {
pub name: String,
pub version: String,
pub description: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ToolkitTool {
- pub name: String,
- pub route: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct GatewayConfig {
- #[serde(default = "default_gateway_enabled")]
- pub enabled: bool,
- #[serde(default = "default_gateway_route")]
- pub route: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub server_name: Option,
#[serde(skip_serializing_if = "Option::is_none")]
- pub server_version: Option,
+ pub language: Option,
}
-fn default_gateway_enabled() -> bool {
- false
-}
-
-fn default_gateway_route() -> String {
- "/gateway".to_string()
-}
-
-fn default_profile() -> String {
- "release".to_string()
-}
-
-impl ToolManifest {
- pub fn load>(path: P) -> Result {
- let content = std::fs::read_to_string(path.as_ref())
- .with_context(|| format!("Failed to read manifest from {:?}", path.as_ref()))?;
+impl ComponentManifest {
+ pub fn load>(base_path: P) -> Result