diff --git a/.github/DockerfileBackendTests b/.github/DockerfileBackendTests index 8783bb241c..b204856823 100644 --- a/.github/DockerfileBackendTests +++ b/.github/DockerfileBackendTests @@ -44,6 +44,10 @@ RUN /usr/local/bin/python3 -m pip install pip-tools # Bun COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun +# Install windmill CLI +RUN bun install -g windmill-cli \ + && ln -s $(bun pm bin -g)/wmill /usr/bin/wmill + ARG TARGETPLATFORM # Deno diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index a2eeaaa044..f3a7d1e883 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -19,7 +19,7 @@ defaults: jobs: cargo_test: - runs-on: ubicloud-standard-16 + runs-on: blacksmith-16vcpu-ubuntu-2404 services: postgres: image: postgres @@ -70,6 +70,16 @@ jobs: with: ruby-version: "3.3" bundler-cache: false + - name: Install windmill CLI from source + run: | + cd $GITHUB_WORKSPACE/cli + bash gen_wm_client.sh + bun install + mkdir -p "$HOME/.local/bin" + printf '#!/bin/sh\nexec bun run "%s/cli/src/main.ts" "$@"\n' "$GITHUB_WORKSPACE" > "$HOME/.local/bin/wmill" + chmod +x "$HOME/.local/bin/wmill" + echo "$HOME/.local/bin" >> $GITHUB_PATH + working-directory: / - name: Install PowerShell, mold and clang run: | sudo apt-get update && sudo apt-get install -y powershell mold clang libcurl4-openssl-dev @@ -78,6 +88,20 @@ jobs: with: cache: false toolchain: 1.93.0 + - name: Cache cargo target directory + uses: useblacksmith/stickydisk@v1 + with: + key: cargo-target + path: ./backend/target + - name: Cache cargo registry + uses: useblacksmith/cache@v1 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: cargo-registry-${{ hashFiles('backend/Cargo.lock') }} + restore-keys: | + cargo-registry- - name: Read EE repo commit hash run: | echo "ee_repo_ref=$(cat ./ee-repo-ref.txt)" >> "$GITHUB_ENV" @@ -165,6 +189,12 @@ jobs: fi echo "NPM_TOKEN=${NPM_TOKEN}" >> $GITHUB_ENV + { + echo "TEST_NPMRC<> $GITHUB_ENV echo "Got NPM token successfully: ${NPM_TOKEN:0:10}..." # Configure npm globally with the auth token @@ -199,7 +229,7 @@ jobs: fi echo "Verified: Package requires authentication for @windmill-test/private-pkg" - name: Cache DuckDB FFI module build - uses: actions/cache@v3 + uses: useblacksmith/cache@v1 with: path: ./backend/windmill-duckdb-ffi-internal/target key: ${{ runner.os }}-duckdb-ffi-${{ hashFiles('./backend/windmill-duckdb-ffi-internal/src/**/*.rs', './backend/windmill-duckdb-ffi-internal/Cargo.toml', './backend/windmill-duckdb-ffi-internal/Cargo.lock') }} @@ -215,6 +245,7 @@ jobs: RUST_LOG_STYLE: never CARGO_NET_GIT_FETCH_WITH_CLI: true CARGO_BUILD_JOBS: 12 + CARGO_INCREMENTAL: 1 WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1 WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1 WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1 diff --git a/.github/workflows/build-publish-rh-image.yml b/.github/workflows/build-publish-rh-image.yml index c6bcea9f9c..e36473f2aa 100644 --- a/.github/workflows/build-publish-rh-image.yml +++ b/.github/workflows/build-publish-rh-image.yml @@ -9,7 +9,7 @@ permissions: write-all jobs: build_ee: - runs-on: ubicloud + runs-on: ubicloud-standard-4 steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/build-publish-rh8-image.yml b/.github/workflows/build-publish-rh8-image.yml index fc35b4c327..b7a7196077 100644 --- a/.github/workflows/build-publish-rh8-image.yml +++ b/.github/workflows/build-publish-rh8-image.yml @@ -9,7 +9,7 @@ permissions: write-all jobs: build_ee: - runs-on: ubicloud + runs-on: ubicloud-standard-4 steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index c430ae203e..d00646962c 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -23,16 +23,16 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Deno - uses: denoland/setup-deno@v2 - with: - deno-version: v2.x - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Generate Windmill client working-directory: cli run: ./gen_wm_client.sh @@ -69,11 +69,6 @@ jobs: cache: true cache-workspaces: backend - - name: Setup Deno - uses: denoland/setup-deno@v2 - with: - deno-version: v2.x - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -90,6 +85,10 @@ jobs: - name: Symlink Node to /usr/bin/node run: sudo ln -sf $(which node) /usr/bin/node + - name: Install dependencies + working-directory: cli + run: bun install + - name: Generate Windmill clients working-directory: cli run: | @@ -101,12 +100,10 @@ jobs: env: DATABASE_URL: postgres://postgres:changeme@localhost:5432 CI_MINIMAL_FEATURES: "true" - run: | - deno test --no-check --allow-all test/ \ - --ignore=test/cargo_backend_example.test.ts + run: bun test --timeout 120000 test/ test-windows: - runs-on: windows-latest + runs-on: blacksmith-16vcpu-windows-2025 steps: - name: Checkout code @@ -126,11 +123,6 @@ jobs: cache: true cache-workspaces: backend - - name: Setup Deno - uses: denoland/setup-deno@v2 - with: - deno-version: v2.x - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -150,6 +142,10 @@ jobs: echo "BUN_PATH=$bunPath" >> $env:GITHUB_OUTPUT echo "NODE_BIN_PATH=$nodePath" >> $env:GITHUB_OUTPUT + - name: Install dependencies + working-directory: cli + run: bun install + - name: Generate Windmill clients working-directory: cli shell: bash @@ -165,9 +161,12 @@ jobs: CI_MINIMAL_FEATURES: "true" BUN_PATH: ${{ steps.runtime-paths.outputs.BUN_PATH }} NODE_BIN_PATH: ${{ steps.runtime-paths.outputs.NODE_BIN_PATH }} - run: | - deno test --no-check --allow-all test/ ` - --ignore=test/cargo_backend_example.test.ts + run: bun test --timeout 120000 test/ + + - name: Keep runner alive for SSH debug + if: failure() + shell: pwsh + run: Start-Sleep -Seconds 3600 # Combined summary job for branch protection test-summary: diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml index 525c343fe6..5eb48570e7 100644 --- a/.github/workflows/discord-notification.yml +++ b/.github/workflows/discord-notification.yml @@ -6,6 +6,12 @@ on: - opened - ready_for_review - closed + issue_comment: + types: + - created + pull_request_review_comment: + types: + - created jobs: notify_discord_when_pr_opened: @@ -33,3 +39,38 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} secrets: DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} + + notify_discord_on_comment: + if: > + github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.user.login != 'cloudflare-workers-and-pages[bot]' + && github.event.comment.user.login != 'ellipsis-dev[bot]' + uses: ./.github/workflows/shareable-discord-notification.yml + with: + PR_STATUS: "comment" + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + COMMENT_URL: ${{ github.event.comment.html_url }} + DISCORD_CHANNEL_ID: "1372204995868491786" + DISCORD_GUILD_ID: "930051556043276338" + secrets: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} + + notify_discord_on_review_comment: + if: > + github.event_name == 'pull_request_review_comment' + && github.event.comment.user.login != 'cloudflare-workers-and-pages[bot]' + && github.event.comment.user.login != 'ellipsis-dev[bot]' + uses: ./.github/workflows/shareable-discord-notification.yml + with: + PR_STATUS: "comment" + PR_NUMBER: ${{ github.event.pull_request.number }} + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + COMMENT_URL: ${{ github.event.comment.html_url }} + DISCORD_CHANNEL_ID: "1372204995868491786" + DISCORD_GUILD_ID: "930051556043276338" + secrets: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 18c52cb38d..6aa537060e 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -25,9 +25,9 @@ jobs: with: node-version: "20.x" registry-url: "https://registry.npmjs.org" - - uses: denoland/setup-deno@v2 + - uses: oven-sh/setup-bun@v2 with: - deno-version: v2.x + bun-version: latest - run: cd cli && ./build.sh && cd npm && npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/shareable-discord-notification.yml b/.github/workflows/shareable-discord-notification.yml index 1b7936330f..cf8c7d9078 100644 --- a/.github/workflows/shareable-discord-notification.yml +++ b/.github/workflows/shareable-discord-notification.yml @@ -24,9 +24,22 @@ on: DISCORD_GUILD_ID: description: "The Discord guild ID" type: string + COMMENT_BODY: + description: "The comment body" + type: string + default: "" + COMMENT_AUTHOR: + description: "The comment author" + type: string + default: "" + COMMENT_URL: + description: "The comment URL" + type: string + default: "" secrets: DISCORD_WEBHOOK_URL: description: "Discord Webhook URL" + required: false DISCORD_BOT_TOKEN: description: "Discord Bot Token" @@ -117,3 +130,54 @@ jobs: curl -X PUT \ -H "Authorization: Bot $BOT_TOKEN" \ "https://discord.com/api/v10/channels/$thread_id/messages/$message_id/reactions/%E2%9C%85/@me" + + post_comment: + runs-on: ubuntu-latest + if: ${{ inputs.PR_STATUS == 'comment' }} + steps: + - name: Post comment to Discord thread + env: + BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + CHANNEL_ID: ${{ inputs.DISCORD_CHANNEL_ID }} + GUILD_ID: ${{ inputs.DISCORD_GUILD_ID }} + PR_NUMBER: ${{ inputs.PR_NUMBER }} + COMMENT_BODY: ${{ inputs.COMMENT_BODY }} + COMMENT_AUTHOR: ${{ inputs.COMMENT_AUTHOR }} + COMMENT_URL: ${{ inputs.COMMENT_URL }} + run: | + # 1) Find the thread by PR number + threads=$(curl -s -H "Authorization: Bot $BOT_TOKEN" \ + "https://discord.com/api/v10/guilds/${GUILD_ID}/threads/active") + thread_id=$(echo "$threads" | jq -r \ + --arg cid "$CHANNEL_ID" \ + --arg pref "#${PR_NUMBER}:" \ + '.threads[] | select(.parent_id == $cid and (.name | startswith($pref))) | .id') + + if [ -z "$thread_id" ]; then + echo "Thread not found for PR #${PR_NUMBER}, skipping" + exit 0 + fi + + # 2) Truncate comment body to fit Discord's 2000 char limit + # Reserve space for the author line + link (~100 chars) + max_body=1800 + if [ ${#COMMENT_BODY} -gt $max_body ]; then + # For bot comments, show the tail (conclusions/code tend to be at the end) + if [[ "$COMMENT_AUTHOR" == *"[bot]"* ]] || [[ "$COMMENT_AUTHOR" == *"-bot"* ]]; then + truncated_body="...${COMMENT_BODY: -$max_body}" + else + truncated_body="${COMMENT_BODY:0:$max_body}..." + fi + else + truncated_body="$COMMENT_BODY" + fi + + # 3) Post the comment to the thread + message=$(printf '**%s** [commented](%s):\n%s' "$COMMENT_AUTHOR" "$COMMENT_URL" "$truncated_body") + payload=$(jq -n --arg content "$message" '{content: $content, flags: 4, allowed_mentions: {parse: []}}') + + curl -s -X POST \ + -H "Authorization: Bot $BOT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload" \ + "https://discord.com/api/v10/channels/${thread_id}/messages" diff --git a/.gitignore b/.gitignore index b81d183fff..2c3dae5c35 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,13 @@ backend/.minio-data !.aiderignore rust-client/Cargo.toml +# Worktree-generated port isolation +.env.local + # Symlinked cache directories (for git worktrees) backend/target frontend/node_modules typescript-client/node_modules frontend/.svelte-kit backend/chrome_profiler.json +.fast-check/ diff --git a/.workmux.yaml b/.workmux.yaml new file mode 100644 index 0000000000..58c85be4ad --- /dev/null +++ b/.workmux.yaml @@ -0,0 +1,63 @@ +main_branch: main + +merge_strategy: rebase +# worktree_dir: .worktrees + +worktree_naming: basename + +worktree_prefix: "" + +# Default: "wm-" +window_prefix: "wm-" + +auto_name: + model: "claude-sonnet-4.6" + system_prompt: | + Generate a concise git branch name based on the task description. + + Rules: + - Use kebab-case (lowercase with hyphens) + - Keep it short: 1-3 words, max 4 if necessary + - Focus on the core task/feature, not implementation details + - No prefixes like feat/, fix/, chore/ + + Examples of good branch names: + - "Add dark mode toggle" → dark-mode + - "Fix the search results not showing" → fix-search + - "Refactor the authentication module" → auth-refactor + - "Add CSV export to reports" → export-csv + - "Shell completion is broken" → shell-completion + + Output ONLY the branch name, nothing else. + background: true + + +# Commands to run in new worktree before tmux window opens. +# These block window creation - use for short tasks only. +# Use "" to inherit from global config. +# Set to empty list to disable: `post_create: []` +# post_create: +# - "" +# - mise use +post_create: + - ./scripts/worktree-env + +pre_remove: + - ./scripts/worktree-cleanup + +panes: + - command: + focus: true + - command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/backend" && PORT=${BACKEND_PORT:-8000} cargo watch -x run' + split: horizontal + - command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm install && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000}' + split: vertical + +files: + copy: + - backend/.env + - scripts/ + +sandbox: + enabled: false + toolchain: off diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bf856bf1..bb041998cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,71 @@ # Changelog +## [1.642.0](https://github.com/windmill-labs/windmill/compare/v1.641.0...v1.642.0) (2026-02-22) + + +### Features + +* **cli:** add consistent get/list/new subcommands for all item types ([#8047](https://github.com/windmill-labs/windmill/issues/8047)) ([4fedfdf](https://github.com/windmill-labs/windmill/commit/4fedfdfd11aa8ca7fff6f7aed5ae2b313888f878)) + + +### Bug Fixes + +* make WM_FLOW_PATH available in flow step previews ([#8042](https://github.com/windmill-labs/windmill/issues/8042)) ([a91c532](https://github.com/windmill-labs/windmill/commit/a91c532ecadce63cea965c497351fa1a6f39697a)) +* preserve debouncing settings for flows with preprocessors ([#8043](https://github.com/windmill-labs/windmill/issues/8043)) ([a00927b](https://github.com/windmill-labs/windmill/commit/a00927b3008a2d953fde1d461723a3c92f375eb4)) + +## [1.641.0](https://github.com/windmill-labs/windmill/compare/v1.640.0...v1.641.0) (2026-02-21) + + +### Features + +* add .npmrc support for private npm registries ([#8039](https://github.com/windmill-labs/windmill/issues/8039)) ([9eb1531](https://github.com/windmill-labs/windmill/commit/9eb15312f663aa6d700e8ac562d7b5c75c2221f7)) + + +### Bug Fixes + +* add created_by ownership check to update/delete saved inputs ([#8038](https://github.com/windmill-labs/windmill/issues/8038)) ([e8a13ed](https://github.com/windmill-labs/windmill/commit/e8a13edde7c0ba2ef80344ab7c7288e7bb2eb6b5)) +* run substitute_ee_code.sh after creating EE worktree ([b330f38](https://github.com/windmill-labs/windmill/commit/b330f388894ecd9cc6b64297420ac6f032d32f72)) +* tag bunnative dependency jobs as bun instead of nativets ([#8045](https://github.com/windmill-labs/windmill/issues/8045)) ([fd5ebc2](https://github.com/windmill-labs/windmill/commit/fd5ebc2fda589c022074c3bb4dcdb447c7f86cf0)) + +## [1.640.0](https://github.com/windmill-labs/windmill/compare/v1.639.0...v1.640.0) (2026-02-20) + + +### Features + +* add windmill-ee-private worktree support to workmux ([#8034](https://github.com/windmill-labs/windmill/issues/8034)) ([9f3dd0b](https://github.com/windmill-labs/windmill/commit/9f3dd0bf2b2ba7c622093c54b7b6b5e7ebb26b74)) +* **cli:** add --locks-required flag to wmill lint and sync push ([#8026](https://github.com/windmill-labs/windmill/issues/8026)) ([4abe589](https://github.com/windmill-labs/windmill/commit/4abe58939787f375ccfef5b2dbcfbd7e86cff076)) +* dedicated nativets ([#8021](https://github.com/windmill-labs/windmill/issues/8021)) ([37c9acb](https://github.com/windmill-labs/windmill/commit/37c9acb232c64c98ecfb64754f5b69b31047c625)) +* Support column detection on S3 objects in DuckDB ([#8018](https://github.com/windmill-labs/windmill/issues/8018)) ([87f3de9](https://github.com/windmill-labs/windmill/commit/87f3de9ae5975c88b6748e297f84a539aec4c0ca)) + + +### Bug Fixes + +* Fix DuckDB incorrect pg password encoding ([#8028](https://github.com/windmill-labs/windmill/issues/8028)) ([90b1a7a](https://github.com/windmill-labs/windmill/commit/90b1a7a531bce5621ea4de4792a8c9d3d3beec3d)) +* **frontend:** use completed_at instead of created_at for job history ([#8022](https://github.com/windmill-labs/windmill/issues/8022)) ([24d7921](https://github.com/windmill-labs/windmill/commit/24d7921bcf23543759719ffd2463959c627b61b8)) + + +### Performance Improvements + +* lazy-load JSZip in RawAppEditorHeader ([#8012](https://github.com/windmill-labs/windmill/issues/8012)) ([a1ba10a](https://github.com/windmill-labs/windmill/commit/a1ba10a29e12ab5f553bd9aad74067cc5b3ead9e)) + +## [1.639.0](https://github.com/windmill-labs/windmill/compare/v1.638.4...v1.639.0) (2026-02-18) + + +### Features + +* improve FolderPicker with edit icon pattern ([#7995](https://github.com/windmill-labs/windmill/issues/7995)) ([db8aa8a](https://github.com/windmill-labs/windmill/commit/db8aa8a0839b5729f0bb847e7a71766c7883ff36)) + + +### Bug Fixes + +* default automate_username_creation to true when setting is missing ([#8006](https://github.com/windmill-labs/windmill/issues/8006)) ([d2d08f8](https://github.com/windmill-labs/windmill/commit/d2d08f8817e6e7818eb4b6f092e66ae039f0c756)) +* handle raw app folder deletion in sync push without yaml parse error ([#7994](https://github.com/windmill-labs/windmill/issues/7994)) ([f6d99dd](https://github.com/windmill-labs/windmill/commit/f6d99dd18c06a7f5aea93122276dd68c45772b43)) + + +### Performance Improvements + +* **cli:** skip relock more accurate ([#7993](https://github.com/windmill-labs/windmill/issues/7993)) ([cd4151a](https://github.com/windmill-labs/windmill/commit/cd4151a84b2c1e0f2e616079091d0429bf469f4e)) + ## [1.638.4](https://github.com/windmill-labs/windmill/compare/v1.638.3...v1.638.4) (2026-02-17) diff --git a/Dockerfile b/Dockerfile index 16647165db..3b9588a697 100644 --- a/Dockerfile +++ b/Dockerfile @@ -258,6 +258,10 @@ COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun +# Install windmill CLI +RUN bun install -g windmill-cli \ + && ln -s $(bun pm bin -g)/wmill /usr/bin/wmill + COPY --from=php:8.3.7-cli /usr/local/bin/php /usr/bin/php COPY --from=composer:2.7.6 /usr/bin/composer /usr/bin/composer diff --git a/README_WORKMUX_DEV.md b/README_WORKMUX_DEV.md new file mode 100644 index 0000000000..dc2f287bee --- /dev/null +++ b/README_WORKMUX_DEV.md @@ -0,0 +1,177 @@ +# Windmill Development with workmux + +This guide covers the workmux-based development setup for Windmill. Each worktree gets its own tmux window with a Claude Code agent, a backend server (with auto-reload), and a frontend dev server — all on isolated ports. + +## Prerequisites + +- tmux +- Rust toolchain (rustup) +- Node.js + npm +- PostgreSQL running locally (see `backend/.env`) + +## Installation + +### 1. Install workmux + +```bash +cargo install workmux +``` + +### 2. Install the Claude Code plugin + +```bash +workmux claude install +``` + +This lets workmux manage Claude Code agents in worktree panes. + +### 3. Install cargo-watch + +Used for auto-recompiling the backend on file changes: + +```bash +cargo install cargo-watch +``` + +### 4. Install llm CLI (required for auto branch naming) + +workmux uses the `llm` CLI to automatically generate branch names from prompts. Install it with: + +```bash +uv tool install llm +llm install llm-anthropic +``` + +Then set your Anthropic API key: + +```bash +llm keys set anthropic +# paste your API key when prompted +``` + +### 5. Recommended: shell alias and autocomplete + +Set up a `wm` alias for convenience: + +```bash +# Add to your ~/.zshrc +alias wm="workmux" +``` + +Setting up zsh autocomplete is also recommended — see the [workmux docs](https://github.com/rubenfiszel/workmux) for instructions. + +## Port Slot System + +Each worktree is assigned a **slot** that determines its ports: + +| Slot | Backend | Frontend | +|------|---------|----------| +| 0 | 8000 | 3000 | +| 1 | 8010 | 3010 | +| 2 | 8020 | 3020 | +| 3 | 8030 | 3030 | +| ... | ... | ... | + +- **Slot 0** is reserved for the main worktree (default `cargo run` / `npm run dev`). +- Without `WM_SLOT`, the script auto-assigns the first available slot (starting from 1) and prints it. +- With `WM_SLOT=N`, it uses that slot and errors if the ports are taken. + +## SSH Port Forwarding + +If you develop over SSH, add this to `~/.ssh/config` on your **local machine** to pre-configure tunnels for each slot: + +``` +Host windmill-dev + HostName + User + # Slot 0 (main worktree) + LocalForward 8000 localhost:8000 + LocalForward 3000 localhost:3000 + # Slot 1 + LocalForward 8010 localhost:8010 + LocalForward 3010 localhost:3010 + # Slot 2 + LocalForward 8020 localhost:8020 + LocalForward 3020 localhost:3020 + # Slot 3 + LocalForward 8030 localhost:8030 + LocalForward 3030 localhost:3030 +``` + +Then connect once and all tunnels are active: + +```bash +ssh windmill-dev +``` + +Access the frontend at `http://localhost:` in your local browser. + +## Quickstart + +```bash +# Create a new worktree (auto-assigns slot, prints ports) +workmux add my-feature + +# Or with an explicit slot +WM_SLOT=2 workmux add my-feature + +# Create a worktree and immediately send a prompt to the agent +workmux add -A -p "fix the login bug in auth.rs" +``` + +The `add` command creates the worktree but does **not** open it. To open the tmux window and start working: + +```bash +workmux open my-feature +``` + +This will open a tmux window with three panes: + +- **Claude Code agent** (focused) +- **Backend**: `cargo watch -x run` on the assigned port (auto-reloads on save) +- **Frontend**: `npm run dev` proxying to the backend + +When using `-A` with `add`, the worktree is created and opened automatically, and the prompt is sent to the agent right away. + +Check which ports were assigned: + +```bash +cat /.env.local +``` + +### Sending work to the agent + +```bash +# Send a prompt to the agent in a worktree +workmux send my-feature "fix the login bug in auth.rs" + +# Check agent status +workmux status +``` + +### Merging and cleaning up + +We never merge worktrees directly — always create a PR on GitHub and let it be merged there. Once the PR is merged, clean up the worktree: + +```bash +# Close the tmux window but keep the worktree +workmux close my-feature + +# After your PR is merged, remove the worktree, branch, and tmux window +workmux rm my-feature +``` + +> **Note**: Do not use `workmux merge`. Always go through a PR to get your changes into main. You can ask the Claude Code agent in the worktree to create the PR for you. + +## Configuration + +The setup is defined in `.workmux.yaml` at the repo root. Key sections: + +- **`post_create`**: Runs `scripts/worktree-env` to generate `.env.local` with port assignments +- **`panes`**: Defines the tmux layout (agent, backend, frontend) +- **`files.copy`**: Copies `backend/.env` and `scripts/` into each worktree +- **`files.symlink`**: Symlinks `node_modules` and `.svelte-kit` to avoid reinstalling per worktree + +## Login + +Default credentials: `admin@windmill.dev` / `changeme` diff --git a/backend/.sqlx/query-0681b850c033619e1b9498376263681f875a5aba22170ca50ec8b578f7fa478b.json b/backend/.sqlx/query-0681b850c033619e1b9498376263681f875a5aba22170ca50ec8b578f7fa478b.json new file mode 100644 index 0000000000..91f8c2ce0f --- /dev/null +++ b/backend/.sqlx/query-0681b850c033619e1b9498376263681f875a5aba22170ca50ec8b578f7fa478b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag)\n SELECT unnest($1::uuid[]), 'test-workspace', now(), 'flow'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "0681b850c033619e1b9498376263681f875a5aba22170ca50ec8b578f7fa478b" +} diff --git a/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json b/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json index bed99ef1b7..933d870b73 100644 --- a/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json +++ b/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json @@ -43,7 +43,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346.json b/backend/.sqlx/query-1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346.json new file mode 100644 index 0000000000..911d6c3b07 --- /dev/null +++ b/backend/.sqlx/query-1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH completed AS (\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result,\n flow_status, workflow_as_code_status, status, worker)\n SELECT\n q.workspace_id, q.id, q.started_at,\n (EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000,\n CASE WHEN q.running\n THEN $3::text::jsonb\n ELSE $4::text::jsonb\n END,\n s.flow_status,\n s.workflow_as_code_status,\n 'skipped'::job_status,\n q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_status s ON s.id = q.id\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = EXCLUDED.result\n RETURNING 1 AS x\n ), _deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n ), _logged AS (\n INSERT INTO job_logs (logs, job_id, workspace_id)\n VALUES ($5, $1, $2)\n ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)\n )\n SELECT x FROM completed\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "x", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346" +} diff --git a/backend/.sqlx/query-18b6262a60400f2b58ab26615466c23b4c1a7805c66b70b0fcfb7d33b122a7bf.json b/backend/.sqlx/query-18b6262a60400f2b58ab26615466c23b4c1a7805c66b70b0fcfb7d33b122a7bf.json new file mode 100644 index 0000000000..05e9f4c7d8 --- /dev/null +++ b/backend/.sqlx/query-18b6262a60400f2b58ab26615466c23b4c1a7805c66b70b0fcfb7d33b122a7bf.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT result::text FROM v2_job_completed WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "result", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "18b6262a60400f2b58ab26615466c23b4c1a7805c66b70b0fcfb7d33b122a7bf" +} diff --git a/backend/.sqlx/query-1af6885dbc5055281acb82b3e57f7dba2e4b04d9535058fab695660a14bf8890.json b/backend/.sqlx/query-1af6885dbc5055281acb82b3e57f7dba2e4b04d9535058fab695660a14bf8890.json new file mode 100644 index 0000000000..780bebfe88 --- /dev/null +++ b/backend/.sqlx/query-1af6885dbc5055281acb82b3e57f7dba2e4b04d9535058fab695660a14bf8890.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag)\n VALUES ($1, $2, now(), 'flow')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1af6885dbc5055281acb82b3e57f7dba2e4b04d9535058fab695660a14bf8890" +} diff --git a/backend/.sqlx/query-1ba2e23d4ba816048ec1e88af9e342867fc0443cabea16d111afa2b91d3fe03b.json b/backend/.sqlx/query-1ba2e23d4ba816048ec1e88af9e342867fc0443cabea16d111afa2b91d3fe03b.json deleted file mode 100644 index 3552ba7a79..0000000000 --- a/backend/.sqlx/query-1ba2e23d4ba816048ec1e88af9e342867fc0443cabea16d111afa2b91d3fe03b.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, is_workspace_integration FROM account WHERE workspace_id = $1 AND id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "client", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "refresh_token", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "grant_type", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "cc_client_id", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "cc_client_secret", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "cc_token_url", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "mcp_server_url", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "is_workspace_integration", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Int4" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - true, - true, - false - ] - }, - "hash": "1ba2e23d4ba816048ec1e88af9e342867fc0443cabea16d111afa2b91d3fe03b" -} diff --git a/backend/.sqlx/query-27f70ebe788cca2e88732d8bf978883037bebca4cf75ba459858e4fb197f940b.json b/backend/.sqlx/query-27f70ebe788cca2e88732d8bf978883037bebca4cf75ba459858e4fb197f940b.json new file mode 100644 index 0000000000..3a771e5b19 --- /dev/null +++ b/backend/.sqlx/query-27f70ebe788cca2e88732d8bf978883037bebca4cf75ba459858e4fb197f940b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path)\n VALUES ($1, 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "27f70ebe788cca2e88732d8bf978883037bebca4cf75ba459858e4fb197f940b" +} diff --git a/backend/.sqlx/query-2a95f18e80c55a7e8178a4bd2b781d41fa47efd4da5bb9bc2d72b9aa1e33617f.json b/backend/.sqlx/query-2a95f18e80c55a7e8178a4bd2b781d41fa47efd4da5bb9bc2d72b9aa1e33617f.json new file mode 100644 index 0000000000..84a9a67204 --- /dev/null +++ b/backend/.sqlx/query-2a95f18e80c55a7e8178a4bd2b781d41fa47efd4da5bb9bc2d72b9aa1e33617f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "debounce_batch", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2a95f18e80c55a7e8178a4bd2b781d41fa47efd4da5bb9bc2d72b9aa1e33617f" +} diff --git a/backend/.sqlx/query-4010328a9f1611064f497726b69c08625a55a4dab25c3d9b5ece07e44d14915b.json b/backend/.sqlx/query-4010328a9f1611064f497726b69c08625a55a4dab25c3d9b5ece07e44d14915b.json new file mode 100644 index 0000000000..fd9c9fe2a6 --- /dev/null +++ b/backend/.sqlx/query-4010328a9f1611064f497726b69c08625a55a4dab25c3d9b5ece07e44d14915b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path)\n VALUES ($1, 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', 'ws2', 'f/test/flow')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "4010328a9f1611064f497726b69c08625a55a4dab25c3d9b5ece07e44d14915b" +} diff --git a/backend/.sqlx/query-454ace9ce391725ef4f4c129cd66e4c12a5c40f512b70551958178c8b4d6c183.json b/backend/.sqlx/query-454ace9ce391725ef4f4c129cd66e4c12a5c40f512b70551958178c8b4d6c183.json deleted file mode 100644 index 14a6ab4a40..0000000000 --- a/backend/.sqlx/query-454ace9ce391725ef4f4c129cd66e4c12a5c40f512b70551958178c8b4d6c183.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id, -- replace current job with new one \n debounced_times = debounce_key.debounced_times + 1 -- evaluated only if conflict,\n -- conflict means there is already existing value,\n -- which means overriding it will also imply adding new entry to v2_job_debounce_batch and thus debouncing the job\n -- so the counter should be incremented\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "debounced_times", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "first_started_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "job_id_to_debounce", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "454ace9ce391725ef4f4c129cd66e4c12a5c40f512b70551958178c8b4d6c183" -} diff --git a/backend/.sqlx/query-48536968f4173715d4ef8293683c2a3eb4bd22fbe18c34890a3dc4e96e4e6133.json b/backend/.sqlx/query-48536968f4173715d4ef8293683c2a3eb4bd22fbe18c34890a3dc4e96e4e6133.json new file mode 100644 index 0000000000..a6a61d6868 --- /dev/null +++ b/backend/.sqlx/query-48536968f4173715d4ef8293683c2a3eb4bd22fbe18c34890a3dc4e96e4e6133.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH ids AS (\n SELECT id as job_id FROM v2_job_debounce_batch WHERE debounce_batch = (\n SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1\n )\n ) SELECT args->>'items' FROM ids LEFT JOIN v2_job ON v2_job.id = ids.job_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "48536968f4173715d4ef8293683c2a3eb4bd22fbe18c34890a3dc4e96e4e6133" +} diff --git a/backend/.sqlx/query-48a41e1ad0ad8fc51624af4de34964c72fe4b3cf2497644d514c5c00d6d71bf3.json b/backend/.sqlx/query-48a41e1ad0ad8fc51624af4de34964c72fe4b3cf2497644d514c5c00d6d71bf3.json index 358758aff7..aeb1091238 100644 --- a/backend/.sqlx/query-48a41e1ad0ad8fc51624af4de34964c72fe4b3cf2497644d514c5c00d6d71bf3.json +++ b/backend/.sqlx/query-48a41e1ad0ad8fc51624af4de34964c72fe4b3cf2497644d514c5c00d6d71bf3.json @@ -42,7 +42,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-50f81c128d644c60837e603099e169fbc7a500c11e79d2c78d6eccccf6491aec.json b/backend/.sqlx/query-50f81c128d644c60837e603099e169fbc7a500c11e79d2c78d6eccccf6491aec.json index 63243f0b4f..58bac5a002 100644 --- a/backend/.sqlx/query-50f81c128d644c60837e603099e169fbc7a500c11e79d2c78d6eccccf6491aec.json +++ b/backend/.sqlx/query-50f81c128d644c60837e603099e169fbc7a500c11e79d2c78d6eccccf6491aec.json @@ -38,7 +38,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-b45e17ad532a23b394226c9a5d7ab5a21e20202dbbf9c67831cc62eb067cd2ba.json b/backend/.sqlx/query-53648c069749df45c0459d733b3e429af20c69c841fb0c3bceafe3ea6c3f5329.json similarity index 79% rename from backend/.sqlx/query-b45e17ad532a23b394226c9a5d7ab5a21e20202dbbf9c67831cc62eb067cd2ba.json rename to backend/.sqlx/query-53648c069749df45c0459d733b3e429af20c69c841fb0c3bceafe3ea6c3f5329.json index c3623a2b7e..3e31735566 100644 --- a/backend/.sqlx/query-b45e17ad532a23b394226c9a5d7ab5a21e20202dbbf9c67831cc62eb067cd2ba.json +++ b/backend/.sqlx/query-53648c069749df45c0459d733b3e429af20c69c841fb0c3bceafe3ea6c3f5329.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval", + "query": "SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind", "describe": { "columns": [ { @@ -36,5 +36,5 @@ false ] }, - "hash": "b45e17ad532a23b394226c9a5d7ab5a21e20202dbbf9c67831cc62eb067cd2ba" + "hash": "53648c069749df45c0459d733b3e429af20c69c841fb0c3bceafe3ea6c3f5329" } diff --git a/backend/.sqlx/query-539d661500254e2e346490710f5772cb88a1ab6bbddd97a77e06644ac0f61762.json b/backend/.sqlx/query-539d661500254e2e346490710f5772cb88a1ab6bbddd97a77e06644ac0f61762.json new file mode 100644 index 0000000000..13d27c1ab2 --- /dev/null +++ b/backend/.sqlx/query-539d661500254e2e346490710f5772cb88a1ab6bbddd97a77e06644ac0f61762.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag)\n SELECT unnest($1::uuid[]), 'test-workspace', now(), 'deno'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "539d661500254e2e346490710f5772cb88a1ab6bbddd97a77e06644ac0f61762" +} diff --git a/backend/.sqlx/query-5b8c1803f0ccead11517fbc8a9bdc0227dc3922217fa18f0b71ff0484d65838c.json b/backend/.sqlx/query-5b8c1803f0ccead11517fbc8a9bdc0227dc3922217fa18f0b71ff0484d65838c.json deleted file mode 100644 index 8b07d39a36..0000000000 --- a/backend/.sqlx/query-5b8c1803f0ccead11517fbc8a9bdc0227dc3922217fa18f0b71ff0484d65838c.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH job_result AS (\n SELECT result\n FROM v2_job_completed\n WHERE id = $1\n ),\n updated_queue AS (\n UPDATE v2_job_queue\n SET running = false,\n tag = COALESCE($3, tag)\n WHERE id = $2\n )\n UPDATE v2_job\n SET\n tag = COALESCE($3, tag),\n concurrent_limit = COALESCE($4, concurrent_limit),\n concurrency_time_window_s = COALESCE($5, concurrency_time_window_s),\n args = COALESCE(\n CASE\n WHEN job_result.result IS NULL THEN NULL\n WHEN jsonb_typeof(job_result.result) = 'object'\n THEN job_result.result\n WHEN jsonb_typeof(job_result.result) = 'null'\n THEN NULL\n ELSE jsonb_build_object('value', job_result.result)\n END,\n '{}'::jsonb\n ),\n preprocessed = TRUE\n FROM job_result\n WHERE v2_job.id = $2;\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Varchar", - "Int4", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "5b8c1803f0ccead11517fbc8a9bdc0227dc3922217fa18f0b71ff0484d65838c" -} diff --git a/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json index 9422e3e8d0..19b977d8da 100644 --- a/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json +++ b/backend/.sqlx/query-5bf200f2c8db25ddf231b564503c6c70f7f3958564a79bb0c6b3863b1ebb0cbf.json @@ -77,7 +77,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-66342c32f7ae0238803cb1896d9f23a74b64573f77dd32189a25b6e8369f147b.json b/backend/.sqlx/query-66342c32f7ae0238803cb1896d9f23a74b64573f77dd32189a25b6e8369f147b.json new file mode 100644 index 0000000000..046f84c48d --- /dev/null +++ b/backend/.sqlx/query-66342c32f7ae0238803cb1896d9f23a74b64573f77dd32189a25b6e8369f147b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE debounce_key SET first_started_at = now() - interval '20 seconds' WHERE key = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "66342c32f7ae0238803cb1896d9f23a74b64573f77dd32189a25b6e8369f147b" +} diff --git a/backend/.sqlx/query-66faba2137791e0cb1353545c06f9f7c23a1559e7a761db7c2195736b8b30709.json b/backend/.sqlx/query-66faba2137791e0cb1353545c06f9f7c23a1559e7a761db7c2195736b8b30709.json new file mode 100644 index 0000000000..a8f948ff21 --- /dev/null +++ b/backend/.sqlx/query-66faba2137791e0cb1353545c06f9f7c23a1559e7a761db7c2195736b8b30709.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path)\n SELECT unnest($1::uuid[]), 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace', 'f/test/flow'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "66faba2137791e0cb1353545c06f9f7c23a1559e7a761db7c2195736b8b30709" +} diff --git a/backend/.sqlx/query-79b437ad31ddab94310989b8fb6a1c130b9be1ab4b6a100fffffd687677b9c92.json b/backend/.sqlx/query-79b437ad31ddab94310989b8fb6a1c130b9be1ab4b6a100fffffd687677b9c92.json new file mode 100644 index 0000000000..dd75f876e5 --- /dev/null +++ b/backend/.sqlx/query-79b437ad31ddab94310989b8fb6a1c130b9be1ab4b6a100fffffd687677b9c92.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH job_result AS (\n SELECT result\n FROM v2_job_completed\n WHERE id = $1\n ),\n updated_queue AS (\n UPDATE v2_job_queue\n SET running = false,\n tag = COALESCE($3, tag),\n scheduled_for = COALESCE($6, scheduled_for)\n WHERE id = $2\n )\n UPDATE v2_job\n SET\n tag = COALESCE($3, tag),\n concurrent_limit = COALESCE($4, concurrent_limit),\n concurrency_time_window_s = COALESCE($5, concurrency_time_window_s),\n args = COALESCE(\n CASE\n WHEN job_result.result IS NULL THEN NULL\n WHEN jsonb_typeof(job_result.result) = 'object'\n THEN job_result.result\n WHEN jsonb_typeof(job_result.result) = 'null'\n THEN NULL\n ELSE jsonb_build_object('value', job_result.result)\n END,\n '{}'::jsonb\n ),\n preprocessed = TRUE\n FROM job_result\n WHERE v2_job.id = $2;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Varchar", + "Int4", + "Int4", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "79b437ad31ddab94310989b8fb6a1c130b9be1ab4b6a100fffffd687677b9c92" +} diff --git a/backend/.sqlx/query-7bb8ff3426ec73672fb572cd1ce36f495a84e0ad7d60fd39eddcccbc129d43c8.json b/backend/.sqlx/query-7bb8ff3426ec73672fb572cd1ce36f495a84e0ad7d60fd39eddcccbc129d43c8.json index 66baa9f216..5d0d2f6938 100644 --- a/backend/.sqlx/query-7bb8ff3426ec73672fb572cd1ce36f495a84e0ad7d60fd39eddcccbc129d43c8.json +++ b/backend/.sqlx/query-7bb8ff3426ec73672fb572cd1ce36f495a84e0ad7d60fd39eddcccbc129d43c8.json @@ -44,7 +44,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-7ca599330c9913c7e66b27e2ffcfa18d53cbdd16e179749f0aea7980a901b23c.json b/backend/.sqlx/query-7ca599330c9913c7e66b27e2ffcfa18d53cbdd16e179749f0aea7980a901b23c.json new file mode 100644 index 0000000000..d15117ba0a --- /dev/null +++ b/backend/.sqlx/query-7ca599330c9913c7e66b27e2ffcfa18d53cbdd16e179749f0aea7980a901b23c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT logs as \"logs!\" FROM job_logs WHERE job_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "logs!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "7ca599330c9913c7e66b27e2ffcfa18d53cbdd16e179749f0aea7980a901b23c" +} diff --git a/backend/.sqlx/query-7ca7dabfe360845a5b57552b0d02267d5dbbc488bc7ab990c0bda1594bf5ef3a.json b/backend/.sqlx/query-7ca7dabfe360845a5b57552b0d02267d5dbbc488bc7ab990c0bda1594bf5ef3a.json new file mode 100644 index 0000000000..0a33d674b5 --- /dev/null +++ b/backend/.sqlx/query-7ca7dabfe360845a5b57552b0d02267d5dbbc488bc7ab990c0bda1594bf5ef3a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag)\n VALUES ($1, $2, now(), 'deno')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "7ca7dabfe360845a5b57552b0d02267d5dbbc488bc7ab990c0bda1594bf5ef3a" +} diff --git a/backend/.sqlx/query-805d633de90fee335f1726284eda0dbc200d45960fb8dea867492c8c7dd096d5.json b/backend/.sqlx/query-805d633de90fee335f1726284eda0dbc200d45960fb8dea867492c8c7dd096d5.json index 1ab5ea959b..3c24c19c8d 100644 --- a/backend/.sqlx/query-805d633de90fee335f1726284eda0dbc200d45960fb8dea867492c8c7dd096d5.json +++ b/backend/.sqlx/query-805d633de90fee335f1726284eda0dbc200d45960fb8dea867492c8c7dd096d5.json @@ -42,7 +42,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-8360ab72d60f07dde6ecae599e6531b5b86862029ab51fdbdd44ec16239108e2.json b/backend/.sqlx/query-8360ab72d60f07dde6ecae599e6531b5b86862029ab51fdbdd44ec16239108e2.json deleted file mode 100644 index 70a904cfc3..0000000000 --- a/backend/.sqlx/query-8360ab72d60f07dde6ecae599e6531b5b86862029ab51fdbdd44ec16239108e2.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n -- if it the first one, nextval will be evaluated, otherwise take from the job we will debounce\n SELECT\n $2,\n COALESCE(\n (\n SELECT debounce_batch\n FROM v2_job_debounce_batch\n WHERE id = $1\n LIMIT 1\n ), -- maybe use current batch\n nextval('debounce_batch_seq')\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "8360ab72d60f07dde6ecae599e6531b5b86862029ab51fdbdd44ec16239108e2" -} diff --git a/backend/.sqlx/query-8cf5af21cde4e4de45f995efa2a9b56ce20c26869ca78d7e17b3504b92ae85b1.json b/backend/.sqlx/query-8cf5af21cde4e4de45f995efa2a9b56ce20c26869ca78d7e17b3504b92ae85b1.json new file mode 100644 index 0000000000..347b5a0d11 --- /dev/null +++ b/backend/.sqlx/query-8cf5af21cde4e4de45f995efa2a9b56ce20c26869ca78d7e17b3504b92ae85b1.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings (workspace_id) VALUES ('ws2')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "8cf5af21cde4e4de45f995efa2a9b56ce20c26869ca78d7e17b3504b92ae85b1" +} diff --git a/backend/.sqlx/query-8d444380516faf56369cdbff645904e20477aa597e30891926af42eee56ab081.json b/backend/.sqlx/query-8d444380516faf56369cdbff645904e20477aa597e30891926af42eee56ab081.json index 497bb3ac8f..4840a3c28d 100644 --- a/backend/.sqlx/query-8d444380516faf56369cdbff645904e20477aa597e30891926af42eee56ab081.json +++ b/backend/.sqlx/query-8d444380516faf56369cdbff645904e20477aa597e30891926af42eee56ab081.json @@ -102,7 +102,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-8f442110817244aa9533b014aa3d74a6582937dfe4759932b63b8e531984008e.json b/backend/.sqlx/query-8f442110817244aa9533b014aa3d74a6582937dfe4759932b63b8e531984008e.json new file mode 100644 index 0000000000..5d29c56fab --- /dev/null +++ b/backend/.sqlx/query-8f442110817244aa9533b014aa3d74a6582937dfe4759932b63b8e531984008e.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_id, previous_job_id, debounced_times FROM debounce_key WHERE key = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "previous_job_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "debounced_times", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "8f442110817244aa9533b014aa3d74a6582937dfe4759932b63b8e531984008e" +} diff --git a/backend/.sqlx/query-92c7c961198e506426bf3f97a8ddbb34af450041c675b30b708fed3ef9e01d2d.json b/backend/.sqlx/query-92c7c961198e506426bf3f97a8ddbb34af450041c675b30b708fed3ef9e01d2d.json index f39f1be51b..51dec42770 100644 --- a/backend/.sqlx/query-92c7c961198e506426bf3f97a8ddbb34af450041c675b30b708fed3ef9e01d2d.json +++ b/backend/.sqlx/query-92c7c961198e506426bf3f97a8ddbb34af450041c675b30b708fed3ef9e01d2d.json @@ -32,7 +32,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json b/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json new file mode 100644 index 0000000000..7dd6e9ac5d --- /dev/null +++ b/backend/.sqlx/query-98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "debounced_times", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "first_started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "job_id_to_debounce", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9" +} diff --git a/backend/.sqlx/query-9f50ec7681a1fcd11cb452c7aba7e8897e49a4b6affa4e9976680336b6bd3115.json b/backend/.sqlx/query-9f50ec7681a1fcd11cb452c7aba7e8897e49a4b6affa4e9976680336b6bd3115.json new file mode 100644 index 0000000000..13006d0b30 --- /dev/null +++ b/backend/.sqlx/query-9f50ec7681a1fcd11cb452c7aba7e8897e49a4b6affa4e9976680336b6bd3115.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id)\n VALUES ($1, 'noop', 'deno', 'test-user', 'u/test-user', 'test@windmill.dev', $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "9f50ec7681a1fcd11cb452c7aba7e8897e49a4b6affa4e9976680336b6bd3115" +} diff --git a/backend/.sqlx/query-a057ff9f5998a162ae6de05f6127b7eefc826af7bf1bb89fd758f7c03c881033.json b/backend/.sqlx/query-a057ff9f5998a162ae6de05f6127b7eefc826af7bf1bb89fd758f7c03c881033.json new file mode 100644 index 0000000000..383ec3eed5 --- /dev/null +++ b/backend/.sqlx/query-a057ff9f5998a162ae6de05f6127b7eefc826af7bf1bb89fd758f7c03c881033.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace (id, name, owner) VALUES ('ws2', 'Workspace 2', 'test-user')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a057ff9f5998a162ae6de05f6127b7eefc826af7bf1bb89fd758f7c03c881033" +} diff --git a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json index 33636da608..9ba91640e8 100644 --- a/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json +++ b/backend/.sqlx/query-a1745a4f525b251d2f5a602ab2b2ede46b4471e21b11f607573a844013911abe.json @@ -72,7 +72,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json index 03ee58ca3f..43b1e07e2c 100644 --- a/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json +++ b/backend/.sqlx/query-a4b6371d33206010b2f3ffd2b09e33244fe8ab9a803248fc23f334034d24aad4.json @@ -77,7 +77,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-a68754521bf751450602f04dd4243199a18885e1739a5a0e7f6100eab6f3c803.json b/backend/.sqlx/query-a68754521bf751450602f04dd4243199a18885e1739a5a0e7f6100eab6f3c803.json new file mode 100644 index 0000000000..8d6e6a2416 --- /dev/null +++ b/backend/.sqlx/query-a68754521bf751450602f04dd4243199a18885e1739a5a0e7f6100eab6f3c803.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job_runtime (id) VALUES ($1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "a68754521bf751450602f04dd4243199a18885e1739a5a0e7f6100eab6f3c803" +} diff --git a/backend/.sqlx/query-abb56f78aa39c6b6ae8b0ccb7b724c1f80d717e7c9d76b287da63cb5ee8e8b25.json b/backend/.sqlx/query-abb56f78aa39c6b6ae8b0ccb7b724c1f80d717e7c9d76b287da63cb5ee8e8b25.json new file mode 100644 index 0000000000..09862ff013 --- /dev/null +++ b/backend/.sqlx/query-abb56f78aa39c6b6ae8b0ccb7b724c1f80d717e7c9d76b287da63cb5ee8e8b25.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue SET runnable_settings_handle = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "abb56f78aa39c6b6ae8b0ccb7b724c1f80d717e7c9d76b287da63cb5ee8e8b25" +} diff --git a/backend/.sqlx/query-adb98040c8039e5cc27fe0579941f723b38d1784bd2f1b16d8724f1f1612dcbf.json b/backend/.sqlx/query-adb98040c8039e5cc27fe0579941f723b38d1784bd2f1b16d8724f1f1612dcbf.json new file mode 100644 index 0000000000..df7f24efe3 --- /dev/null +++ b/backend/.sqlx/query-adb98040c8039e5cc27fe0579941f723b38d1784bd2f1b16d8724f1f1612dcbf.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT 1 as x FROM v2_job_completed WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "x", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "adb98040c8039e5cc27fe0579941f723b38d1784bd2f1b16d8724f1f1612dcbf" +} diff --git a/backend/.sqlx/query-b23784ed2737c01aaf05efe42bcdcfc9579aa203005ead60322fb71fd33b15b0.json b/backend/.sqlx/query-b23784ed2737c01aaf05efe42bcdcfc9579aa203005ead60322fb71fd33b15b0.json index 054521168e..36f9127807 100644 --- a/backend/.sqlx/query-b23784ed2737c01aaf05efe42bcdcfc9579aa203005ead60322fb71fd33b15b0.json +++ b/backend/.sqlx/query-b23784ed2737c01aaf05efe42bcdcfc9579aa203005ead60322fb71fd33b15b0.json @@ -102,7 +102,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json index 4fa7da00e0..f06151fec9 100644 --- a/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json +++ b/backend/.sqlx/query-b3771b690c5966272b1f42c9965bb6a8f961c119516e4c33dc928cd3b4f4edbc.json @@ -72,7 +72,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-b4a9abcb38997587b28655b0f4a212a5bd4039b57fab20b163617e33a4c9dd46.json b/backend/.sqlx/query-b4a9abcb38997587b28655b0f4a212a5bd4039b57fab20b163617e33a4c9dd46.json new file mode 100644 index 0000000000..a49baeefaf --- /dev/null +++ b/backend/.sqlx/query-b4a9abcb38997587b28655b0f4a212a5bd4039b57fab20b163617e33a4c9dd46.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "b4a9abcb38997587b28655b0f4a212a5bd4039b57fab20b163617e33a4c9dd46" +} diff --git a/backend/.sqlx/query-b795dc228f93c8b9bedb4a3e7467d941819a7202214e0634580bdc2ec30f0b70.json b/backend/.sqlx/query-b795dc228f93c8b9bedb4a3e7467d941819a7202214e0634580bdc2ec30f0b70.json new file mode 100644 index 0000000000..69c8dbb1ec --- /dev/null +++ b/backend/.sqlx/query-b795dc228f93c8b9bedb4a3e7467d941819a7202214e0634580bdc2ec30f0b70.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT 1 as x FROM v2_job_queue WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "x", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b795dc228f93c8b9bedb4a3e7467d941819a7202214e0634580bdc2ec30f0b70" +} diff --git a/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json b/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json index 4118760af2..dde67a2606 100644 --- a/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json +++ b/backend/.sqlx/query-be6d2c92a62b7b284651c45af809746147aa9b8d0a81642a7b7cb4738a0cad66.json @@ -41,7 +41,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-c1a1ae759ebb84fde3e6d2727991b2a1fcc73e520fee79653bb960dc00c3e2db.json b/backend/.sqlx/query-c1a1ae759ebb84fde3e6d2727991b2a1fcc73e520fee79653bb960dc00c3e2db.json new file mode 100644 index 0000000000..b7c771780b --- /dev/null +++ b/backend/.sqlx/query-c1a1ae759ebb84fde3e6d2727991b2a1fcc73e520fee79653bb960dc00c3e2db.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) VALUES ($1, 'ws2', now(), 'flow')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c1a1ae759ebb84fde3e6d2727991b2a1fcc73e520fee79653bb960dc00c3e2db" +} diff --git a/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json b/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json new file mode 100644 index 0000000000..7d7842d7f4 --- /dev/null +++ b/backend/.sqlx/query-c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "debounced_times", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "first_started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "job_id_to_debounce", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Varchar" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b" +} diff --git a/backend/.sqlx/query-c63a1949247f1618f6b6acee9bf6b4d3081dfed2e6ff533dbff0bdfd52687cbb.json b/backend/.sqlx/query-c63a1949247f1618f6b6acee9bf6b4d3081dfed2e6ff533dbff0bdfd52687cbb.json new file mode 100644 index 0000000000..5d761c2153 --- /dev/null +++ b/backend/.sqlx/query-c63a1949247f1618f6b6acee9bf6b4d3081dfed2e6ff533dbff0bdfd52687cbb.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) as \"count!\" FROM v2_job_queue WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c63a1949247f1618f6b6acee9bf6b4d3081dfed2e6ff533dbff0bdfd52687cbb" +} diff --git a/backend/.sqlx/query-c6d963e5cefeea728414892df9f28a89f435fc0fb7e55f243b021200f33d2151.json b/backend/.sqlx/query-c6d963e5cefeea728414892df9f28a89f435fc0fb7e55f243b021200f33d2151.json new file mode 100644 index 0000000000..94fb53c985 --- /dev/null +++ b/backend/.sqlx/query-c6d963e5cefeea728414892df9f28a89f435fc0fb7e55f243b021200f33d2151.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id)\n SELECT unnest($1::uuid[]), 'noop', 'deno', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [] + }, + "hash": "c6d963e5cefeea728414892df9f28a89f435fc0fb7e55f243b021200f33d2151" +} diff --git a/backend/.sqlx/query-c9530931f670eab1208c4a284a55afdc3fcbb0eb5f98fd63e2ec89442becbfaa.json b/backend/.sqlx/query-c9530931f670eab1208c4a284a55afdc3fcbb0eb5f98fd63e2ec89442becbfaa.json new file mode 100644 index 0000000000..488d3c42bd --- /dev/null +++ b/backend/.sqlx/query-c9530931f670eab1208c4a284a55afdc3fcbb0eb5f98fd63e2ec89442becbfaa.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH _ AS (\n UPDATE debounce_key\n SET debounced_times = 0,\n first_started_at = now(),\n previous_job_id = NULL\n WHERE job_id = $1\n )\n UPDATE v2_job_debounce_batch\n SET debounce_batch = nextval('debounce_batch_seq')\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c9530931f670eab1208c4a284a55afdc3fcbb0eb5f98fd63e2ec89442becbfaa" +} diff --git a/backend/.sqlx/query-cc309de42a3b630bb83d1b2437633ef0b98ce5e5fba1f5c1dda3ddd874ff3a39.json b/backend/.sqlx/query-cc309de42a3b630bb83d1b2437633ef0b98ce5e5fba1f5c1dda3ddd874ff3a39.json new file mode 100644 index 0000000000..b2e8faee5d --- /dev/null +++ b/backend/.sqlx/query-cc309de42a3b630bb83d1b2437633ef0b98ce5e5fba1f5c1dda3ddd874ff3a39.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) as \"count!\" FROM v2_job_completed WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "cc309de42a3b630bb83d1b2437633ef0b98ce5e5fba1f5c1dda3ddd874ff3a39" +} diff --git a/backend/.sqlx/query-ccfed494a8d89eb2c88d72738c341a4dd87701b0636eb9fa001cd1d9cbcd663b.json b/backend/.sqlx/query-ccfed494a8d89eb2c88d72738c341a4dd87701b0636eb9fa001cd1d9cbcd663b.json new file mode 100644 index 0000000000..17034beb5c --- /dev/null +++ b/backend/.sqlx/query-ccfed494a8d89eb2c88d72738c341a4dd87701b0636eb9fa001cd1d9cbcd663b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "debounce_batch", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ccfed494a8d89eb2c88d72738c341a4dd87701b0636eb9fa001cd1d9cbcd663b" +} diff --git a/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json b/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json index 9d2f8d9a9f..4160b79be6 100644 --- a/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json +++ b/backend/.sqlx/query-d41ea93fd58381b89e151c965eae1ea2fe96a1b94f5a92953fb1c1642d15c016.json @@ -41,7 +41,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-d9400849888dd021b0504b93004ab5e76296ed437c210942363ba06845f9f963.json b/backend/.sqlx/query-d9400849888dd021b0504b93004ab5e76296ed437c210942363ba06845f9f963.json new file mode 100644 index 0000000000..5e79947b1d --- /dev/null +++ b/backend/.sqlx/query-d9400849888dd021b0504b93004ab5e76296ed437c210942363ba06845f9f963.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = ANY($1) ORDER BY debounce_batch", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "debounce_batch", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d9400849888dd021b0504b93004ab5e76296ed437c210942363ba06845f9f963" +} diff --git a/backend/.sqlx/query-e1f10f940d7ba6b1652e3d6505e12b16394f9632fd535febb008ebfe9fe0b7c8.json b/backend/.sqlx/query-e1f10f940d7ba6b1652e3d6505e12b16394f9632fd535febb008ebfe9fe0b7c8.json index 18dbf7953e..d7837e7f79 100644 --- a/backend/.sqlx/query-e1f10f940d7ba6b1652e3d6505e12b16394f9632fd535febb008ebfe9fe0b7c8.json +++ b/backend/.sqlx/query-e1f10f940d7ba6b1652e3d6505e12b16394f9632fd535febb008ebfe9fe0b7c8.json @@ -31,7 +31,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-e2905bca184696a80357d8e4126832a902b3088d91fbbb858f6c0aa9de8a5ff7.json b/backend/.sqlx/query-e2905bca184696a80357d8e4126832a902b3088d91fbbb858f6c0aa9de8a5ff7.json index cab697d254..ef7541aa28 100644 --- a/backend/.sqlx/query-e2905bca184696a80357d8e4126832a902b3088d91fbbb858f6c0aa9de8a5ff7.json +++ b/backend/.sqlx/query-e2905bca184696a80357d8e4126832a902b3088d91fbbb858f6c0aa9de8a5ff7.json @@ -37,7 +37,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json index 7309b03a02..e5bf2cbffc 100644 --- a/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json +++ b/backend/.sqlx/query-e4d71278fb80126a7a9da73f1889352d4d1e3cb3a8a08f1c9c03055a1cab1235.json @@ -77,7 +77,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.sqlx/query-f8cec94b94098e752f7c71cbe5e9996410a07bacebc37ed21e0bedf1a33a8fdc.json b/backend/.sqlx/query-f8cec94b94098e752f7c71cbe5e9996410a07bacebc37ed21e0bedf1a33a8fdc.json new file mode 100644 index 0000000000..30127cd1ed --- /dev/null +++ b/backend/.sqlx/query-f8cec94b94098e752f7c71cbe5e9996410a07bacebc37ed21e0bedf1a33a8fdc.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path, args)\n VALUES ($1, 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "f8cec94b94098e752f7c71cbe5e9996410a07bacebc37ed21e0bedf1a33a8fdc" +} diff --git a/backend/.sqlx/query-fbd38c9c4f4ecba2d3e3d79433327fd4280466eec443aff4f1e8c1ab810cca7e.json b/backend/.sqlx/query-fbd38c9c4f4ecba2d3e3d79433327fd4280466eec443aff4f1e8c1ab810cca7e.json index d8855042a2..3c8487cec3 100644 --- a/backend/.sqlx/query-fbd38c9c4f4ecba2d3e3d79433327fd4280466eec443aff4f1e8c1ab810cca7e.json +++ b/backend/.sqlx/query-fbd38c9c4f4ecba2d3e3d79433327fd4280466eec443aff4f1e8c1ab810cca7e.json @@ -32,7 +32,8 @@ "aiagent", "unassigned_script", "unassigned_flow", - "unassigned_singlestepflow" + "unassigned_singlestepflow", + "snapshotbuild" ] } } diff --git a/backend/.workmux.yaml b/backend/.workmux.yaml new file mode 100644 index 0000000000..4091eccbb2 --- /dev/null +++ b/backend/.workmux.yaml @@ -0,0 +1,8 @@ +panes: + # Pane 1: Install dependencies, then start dev server + - command: cargo run + + # Pane 2: AI agent + - command: + split: horizontal + focus: true diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 54550c2dcb..1e4f7e760c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -44,11 +44,22 @@ Windmill uses a workspace-based architecture with multiple crates: ## Enterprise Features - Enterprise files use the `*_ee.rs` suffix -- Enterprise source is in `windmill-ee-private` folder (sibling directory at `../../windmill-ee-private`), symlinked into each crate's `src/` +- Enterprise source is in `windmill-ee-private` folder (sibling directory at `../../windmill-ee-private` or `~/windmill-ee-private`), symlinked into each crate's `src/` +- The `_ee.rs` files are gitignored in the main repo — they are tracked only in the `windmill-ee-private` repo - You can and should modify `windmill-ee-private` directly when needed (e.g., when creating new crates that need EE code, mirror the package structure there) - Use feature flags: `#[cfg(feature = "enterprise")]` - Isolate enterprise code in separate modules +### EE PR Workflow (MUST DO when modifying `*_ee.rs` files) + +When you modify any `*_ee.rs` file and create a PR on the windmill repo, you **MUST** also: + +1. **Create a matching branch** in the `windmill-ee-private` repo (use the same branch name). If using worktrees, the EE worktree is at `~/windmill-ee-private__worktrees//` +2. **Commit and push** the `_ee.rs` changes in that branch +3. **Create a PR** on `windmill-ee-private` with a link to the companion windmill PR +4. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` to write the latest EE commit hash. **Important**: the script may fall back to `~/windmill-ee-private` (main branch) instead of the worktree — verify it wrote the correct commit hash from your branch, not from main. If wrong, manually write the correct hash. +5. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref + ## Code Validation (MUST DO) After making backend changes, you MUST run `cargo check` and fix all errors and warnings before considering the work done. diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e27fdc1d92..c54583f985 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -219,9 +219,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ar_archive_writer" @@ -554,7 +554,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure 0.13.2", ] @@ -577,7 +577,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -589,7 +589,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -730,7 +730,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -752,7 +752,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -763,7 +763,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -860,9 +860,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "zeroize", @@ -1473,7 +1473,7 @@ checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1613,7 +1613,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.116", + "syn 2.0.117", "which 4.4.2", ] @@ -1634,7 +1634,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1654,7 +1654,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1672,7 +1672,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1880,7 +1880,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1903,7 +1903,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1913,7 +1913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17d4f95e880cfd28c4ca5a006cf7f6af52b4bcb7b5866f573b2faa126fb7affb" dependencies = [ "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -1991,9 +1991,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" dependencies = [ "allocator-api2", ] @@ -2049,7 +2049,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2194,7 +2194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b4a6cae9efc04cc6cbb8faf338d2c497c165c83e74509cf4dbedea948bbf6e5" dependencies = [ "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2323,9 +2323,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.59" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -2333,9 +2333,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.59" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -2352,7 +2352,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2407,7 +2407,7 @@ dependencies = [ "nom 7.1.3", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2811,7 +2811,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2914,7 +2914,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2928,7 +2928,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2941,7 +2941,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2974,7 +2974,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2985,7 +2985,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -2996,7 +2996,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3488,7 +3488,7 @@ checksum = "df6f88d7ee27daf8b108ba910f9015176b36fbc72902b1ca5c2a5f1d1717e1a1" dependencies = [ "datafusion-expr", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -3926,7 +3926,7 @@ checksum = "babccedee31ce7e57c3e6dff2cb3ab8d68c49d0df8222fe0d11d628e65192790" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4317,7 +4317,7 @@ dependencies = [ "stringcase", "strum 0.25.0", "strum_macros 0.25.3", - "syn 2.0.116", + "syn 2.0.117", "thiserror 2.0.18", ] @@ -4833,7 +4833,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4887,7 +4887,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4916,7 +4916,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -4928,7 +4928,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5054,7 +5054,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5089,7 +5089,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5295,7 +5295,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5361,7 +5361,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5381,7 +5381,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5401,7 +5401,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5746,7 +5746,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5784,7 +5784,7 @@ checksum = "32016f1242eb82af5474752d00fd8ebcd9004bd69b462b1c91de833972d08ed4" dependencies = [ "proc-macro2", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -5919,7 +5919,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6224,7 +6224,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -6562,7 +6562,7 @@ checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7389,9 +7389,9 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] name = "inventory" -version = "0.3.21" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" dependencies = [ "rustversion", ] @@ -7453,7 +7453,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7820,7 +7820,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -7876,7 +7876,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8520,7 +8520,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8641,7 +8641,7 @@ checksum = "c402a4092d5e204f32c9e155431046831fa712637043c58cb73bc6bc6c9663b5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8686,7 +8686,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "termcolor", "thiserror 2.0.18", ] @@ -8789,7 +8789,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -8986,7 +8986,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9254,7 +9254,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9471,7 +9471,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -9969,7 +9969,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10050,7 +10050,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10100,7 +10100,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10312,7 +10312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10386,7 +10386,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10397,7 +10397,7 @@ checksum = "07c277e4e643ef00c1233393c673f655e3672cf7eb3ba08a00bdd0ea59139b5f" dependencies = [ "proc-macro-rules-macros", "proc-macro2", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10409,7 +10409,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10506,7 +10506,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.116", + "syn 2.0.117", "tempfile", ] @@ -10520,7 +10520,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -10975,7 +10975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" dependencies = [ "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -11035,7 +11035,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -11360,7 +11360,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -11410,7 +11410,7 @@ dependencies = [ "proc-macro2", "quote", "rquickjs-core", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -11497,7 +11497,7 @@ dependencies = [ "quote", "rust-embed-utils", "shellexpand", - "syn 2.0.116", + "syn 2.0.117", "walkdir", ] @@ -12025,7 +12025,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12037,7 +12037,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12132,9 +12132,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.16.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -12226,7 +12226,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12237,7 +12237,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12289,7 +12289,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12356,7 +12356,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12809,7 +12809,7 @@ checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12874,7 +12874,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -12897,7 +12897,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.116", + "syn 2.0.117", "tokio", "url", ] @@ -13061,7 +13061,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13130,7 +13130,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13142,7 +13142,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13261,7 +13261,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13310,7 +13310,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13395,7 +13395,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13502,7 +13502,7 @@ checksum = "63db0adcff29d220c3d151c5b25c0eabe7e32dd936212b84cdaa1392e3130497" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13513,7 +13513,7 @@ checksum = "f486687bfb7b5c560868f69ed2d458b880cebc9babebcb67e49f31b55c5bf847" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13536,7 +13536,7 @@ dependencies = [ "proc-macro2", "quote", "swc_macros_common", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13552,9 +13552,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.116" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -13590,7 +13590,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13663,9 +13663,9 @@ dependencies = [ [[package]] name = "systemstat" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5021f5184d44b26fb184acd689671bbe1e4bbd24bbdaa6bc7ec383fad32d2033" +checksum = "a6e89b75de097d0c52a1dc2114e19439d55f0e2e42d32168c6df44f139dfb66f" dependencies = [ "bytesize", "lazy_static", @@ -13913,7 +13913,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -13924,7 +13924,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -14184,7 +14184,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -14617,7 +14617,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -14877,7 +14877,7 @@ checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -15355,7 +15355,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -15390,7 +15390,7 @@ checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -15425,7 +15425,7 @@ checksum = "a369369e4360c2884c3168d22bded735c43cccae97bbc147586d4b480edd138d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -15725,7 +15725,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-nats", @@ -15778,6 +15778,7 @@ dependencies = [ "windmill-indexer", "windmill-object-store", "windmill-operator", + "windmill-parser-ts", "windmill-queue", "windmill-runtime-nativets", "windmill-test-utils", @@ -15788,7 +15789,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15801,7 +15802,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "argon2", @@ -15939,7 +15940,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15962,7 +15963,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15975,7 +15976,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16001,7 +16002,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.638.4" +version = "1.642.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16011,7 +16012,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16028,7 +16029,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16051,7 +16052,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16074,7 +16075,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16090,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16110,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16130,7 +16131,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16144,7 +16145,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-nats", @@ -16170,7 +16171,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16195,10 +16196,11 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "flate2", + "reqwest 0.13.1", "serde", "serde_json", "sqlx", @@ -16211,7 +16213,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16232,7 +16234,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16252,7 +16254,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16282,7 +16284,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16309,7 +16311,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.638.4" +version = "1.642.0" dependencies = [ "lazy_static", "serde", @@ -16321,7 +16323,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.638.4" +version = "1.642.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16344,7 +16346,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16358,7 +16360,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.638.4" +version = "1.642.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16388,7 +16390,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.638.4" +version = "1.642.0" dependencies = [ "chrono", "lazy_static", @@ -16402,7 +16404,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16421,7 +16423,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.638.4" +version = "1.642.0" dependencies = [ "aes-gcm", "anyhow", @@ -16520,7 +16522,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.638.4" +version = "1.642.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16539,7 +16541,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.638.4" +version = "1.642.0" dependencies = [ "regex", "serde", @@ -16554,7 +16556,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16578,7 +16580,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "futures", @@ -16595,7 +16597,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.638.4" +version = "1.642.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16606,12 +16608,12 @@ dependencies = [ "serde", "serde_derive", "serde_yml", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] name = "windmill-mcp" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -16632,7 +16634,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -16663,7 +16665,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-oauth2", @@ -16687,7 +16689,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.638.3" +version = "1.642.0" dependencies = [ "anyhow", "async-stream", @@ -16721,7 +16723,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "futures", @@ -16739,7 +16741,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.638.4" +version = "1.642.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16748,7 +16750,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "lazy_static", @@ -16760,7 +16762,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "serde_json", @@ -16772,7 +16774,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "gosyn", @@ -16784,7 +16786,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "lazy_static", @@ -16796,7 +16798,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "serde_json", @@ -16808,7 +16810,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "nu-parser", @@ -16819,7 +16821,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16830,7 +16832,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16843,7 +16845,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-recursion", @@ -16867,7 +16869,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "lazy_static", @@ -16881,7 +16883,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16891,14 +16893,14 @@ dependencies = [ "quote", "regex", "serde_json", - "syn 2.0.116", + "syn 2.0.117", "toml", "windmill-parser", ] [[package]] name = "windmill-parser-sql" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "lazy_static", @@ -16913,7 +16915,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "lazy_static", @@ -16932,7 +16934,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "serde", @@ -16943,7 +16945,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-recursion", @@ -16980,7 +16982,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "const_format", @@ -17018,7 +17020,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.638.4" +version = "1.642.0" dependencies = [ "wasm-bindgen", "wasm-bindgen-test", @@ -17028,7 +17030,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-recursion", @@ -17057,7 +17059,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17080,7 +17082,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -17113,7 +17115,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -17133,7 +17135,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -17167,7 +17169,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -17202,7 +17204,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -17225,7 +17227,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -17249,7 +17251,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-nats", @@ -17273,7 +17275,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -17308,7 +17310,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -17336,7 +17338,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-trait", @@ -17359,7 +17361,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17377,7 +17379,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.638.4" +version = "1.642.0" dependencies = [ "anyhow", "async-once-cell", @@ -17591,7 +17593,7 @@ checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -17602,7 +17604,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -17613,7 +17615,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -17624,7 +17626,7 @@ checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -17635,7 +17637,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -17646,7 +17648,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -18117,7 +18119,7 @@ dependencies = [ "heck 0.5.0", "indexmap 2.11.1", "prettyplease", - "syn 2.0.116", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -18133,7 +18135,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -18329,7 +18331,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure 0.13.2", ] @@ -18341,7 +18343,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure 0.13.2", ] @@ -18362,7 +18364,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -18382,7 +18384,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", "synstructure 0.13.2", ] @@ -18403,7 +18405,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -18436,7 +18438,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.116", + "syn 2.0.117", ] [[package]] @@ -18453,9 +18455,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a33bbf307b25a1774cee0687694ec72fa7814b3ab5c1c12a9d2fc6a36fc439c" +checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8" [[package]] name = "zstd" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 90abdd5a6b..29cc8aa28c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.638.4" +version = "1.642.0" authors.workspace = true edition.workspace = true @@ -76,7 +76,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.638.4" +version = "1.642.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -103,7 +103,7 @@ enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmi local_reports = ["windmill-common/local_reports"] enterprise_saml = ["windmill-api/enterprise_saml", "oauth2"] stripe = ["windmill-api/stripe"] -benchmark = ["windmill-api/benchmark", "windmill-worker/benchmark", "windmill-queue/benchmark", "windmill-common/benchmark"] +benchmark = ["windmill-api/benchmark", "windmill-worker/benchmark", "windmill-queue/benchmark", "windmill-common/benchmark", "windmill-api-agent-workers?/benchmark"] embedding = ["windmill-api/embedding"] parquet = ["windmill-api/parquet", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker/parquet"] prometheus = ["windmill-common/prometheus", "windmill-api/prometheus", "windmill-worker/prometheus", "windmill-queue/prometheus", "dep:prometheus"] @@ -254,6 +254,7 @@ axum.workspace = true serde.workspace = true windmill-api-client.workspace = true tempfile.workspace = true +windmill-parser-ts.workspace = true rumqttc.workspace = true rdkafka.workspace = true async-nats.workspace = true @@ -479,7 +480,7 @@ bit-vec = "=0.6.3" mappable-rc = "^0" mysql_async = { version = "*", default-features = false, features = ["minimal", "default", "native-tls-tls", "rust_decimal"]} postgres-native-tls = "^0" -native-tls = "^0" +native-tls = ">=0.2, <0.2.17" # samael will break compilation on MacOS. Use this fork instead to make it work # samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] } libxml = { version = "=0.3.3" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 79d825bcf3..fbf2a4f9a4 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -0eccae6a9a9ecde09816cd4d88ca9ab305659e4c \ No newline at end of file +0fede4b1086bc1456be9cc55b203228c979c5c5e diff --git a/backend/parsers/windmill-parser-sql/src/asset_parser.rs b/backend/parsers/windmill-parser-sql/src/asset_parser.rs index 9db4e8a0d0..78246389e2 100644 --- a/backend/parsers/windmill-parser-sql/src/asset_parser.rs +++ b/backend/parsers/windmill-parser-sql/src/asset_parser.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; use sqlparser::{ ast::{ - CopyTarget, Expr, ObjectName, ObjectNamePart, SelectItem, TableFactor, TableObject, Value, - ValueWithSpan, Visit, Visitor, + CopyTarget, Expr, FunctionArg, FunctionArgExpr, ObjectName, ObjectNamePart, SelectItem, + TableFactor, TableObject, Value, ValueWithSpan, Visit, Visitor, }, dialect::DuckDbDialect, parser::Parser, @@ -125,6 +125,72 @@ impl AssetCollector { Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None }) } + /// If `table_factor` is a string literal used directly as a table name (e.g. FROM 's3:///file.parquet'), + /// return a `ParseAssetsResult` for it. + fn get_s3_asset_from_str_literal_table( + &self, + table_factor: &TableFactor, + ) -> Option { + let name = match table_factor { + TableFactor::Table { name, args: None, .. } => name, + _ => return None, + }; + + let s3_str = get_str_lit_from_obj_name(name)?; + let (kind, path) = parse_asset_syntax(s3_str, false)?; + if kind != AssetKind::S3Object { + return None; + } + + Some(ParseAssetsResult { + kind, + path: path.to_string(), + access_type: Some(R), + columns: None, + }) + } + + /// If `table_factor` is a read function (read_parquet/read_csv/read_json) whose first + /// positional argument is an S3 string literal, return a `ParseAssetsResult` for it. + fn get_s3_asset_from_table_function( + &self, + table_factor: &TableFactor, + ) -> Option { + let (name, args) = match table_factor { + TableFactor::Table { name, args: Some(args), .. } => (name, args), + _ => return None, + }; + + let fname = get_trivial_obj_name(name)?; + if !is_read_fn(fname) { + return None; + } + + let s3_str = args.args.first().and_then(|arg| match arg { + FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(ValueWithSpan { + value: Value::SingleQuotedString(s), + .. + }))) + | FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(ValueWithSpan { + value: Value::DoubleQuotedString(s), + .. + }))) => Some(s.as_str()), + _ => None, + })?; + + let (kind, path) = parse_asset_syntax(s3_str, false)?; + if kind != AssetKind::S3Object { + return None; + } + + Some(ParseAssetsResult { + kind, + path: path.to_string(), + access_type: Some(R), + columns: None, + }) + } + fn handle_string_literal(&mut self, s: &str) { // Check if the string matches our asset syntax patterns if let Some((kind, path)) = parse_asset_syntax(s, false) { @@ -190,13 +256,19 @@ impl AssetCollector { projection: &[SelectItem], from_tables: &[sqlparser::ast::TableWithJoins], ) { - // Check if this is a single-table SELECT (to avoid ambiguity) + // Check if this is a single-table SELECT (to avoid ambiguity). + // For S3 table functions (read_parquet/read_csv/read_json), detect the asset even + // though args are present, since we know the file path from the string literal arg. let single_table = if from_tables.len() == 1 { - if let TableFactor::Table { name, args, .. } = &from_tables[0].relation { - if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 { - return; // Skip table functions + let relation = &from_tables[0].relation; + if let TableFactor::Table { name, args, .. } = relation { + let has_args = args.as_ref().map_or(false, |a| !a.args.is_empty()); + if has_args { + self.get_s3_asset_from_table_function(relation) + } else { + self.get_associated_asset_from_obj_name(name, Some(R)) + .or_else(|| self.get_s3_asset_from_str_literal_table(relation)) } - self.get_associated_asset_from_obj_name(name, Some(R)) } else { None } @@ -204,26 +276,48 @@ impl AssetCollector { None }; - // Build a map of table aliases/names to assets for multi-table queries + // Build a map of table aliases/names to assets for multi-table queries. + // For S3 table functions, only aliased references are unambiguous + // (e.g. SELECT t.col1 FROM read_parquet('s3://...') AS t). let mut table_to_asset: BTreeMap = BTreeMap::new(); for table_with_joins in from_tables { if let TableFactor::Table { name, alias, args, .. } = &table_with_joins.relation { - if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 { - continue; // Skip table functions - } - if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(R)) { - // Use alias if present, otherwise use the table name - let table_key = if let Some(alias) = alias { - alias.name.value.clone() + let has_args = args.as_ref().map_or(false, |a| !a.args.is_empty()); + if has_args { + // For table functions, only add to the alias map when an alias is present + if let Some(alias) = alias { + if let Some(asset) = + self.get_s3_asset_from_table_function(&table_with_joins.relation) + { + table_to_asset.insert(alias.name.value.clone(), asset); + } + } + } else if let Some(asset) = self + .get_associated_asset_from_obj_name(name, Some(R)) + .or_else(|| { + self.get_s3_asset_from_str_literal_table(&table_with_joins.relation) + }) + { + // For string literal S3 tables (e.g. FROM 's3:///file.parquet'), only add to + // the alias map when an alias is present (to avoid false positives). + // For regular named tables, use alias or table name as key. + let is_str_literal = get_str_lit_from_obj_name(name).is_some(); + if is_str_literal { + if let Some(alias) = alias { + table_to_asset.insert(alias.name.value.clone(), asset); + } } else { - // For qualified names like "dl.table1", use just the last part - name.0 - .last() - .and_then(|id| id.as_ident()) - .map(|id| id.value.clone()) - .unwrap_or_default() - }; - table_to_asset.insert(table_key, asset); + let table_key = if let Some(alias) = alias { + alias.name.value.clone() + } else { + name.0 + .last() + .and_then(|id| id.as_ident()) + .map(|id| id.value.clone()) + .unwrap_or_default() + }; + table_to_asset.insert(table_key, asset); + } } } } @@ -1271,4 +1365,163 @@ mod tests { assert_eq!(columns.get("age"), Some(&W)); // Only written assert_eq!(columns.get("id"), Some(&R)); // Only read } + + #[test] + fn test_sql_asset_parser_s3_single_table_column_detection() { + let input = r#" + SELECT col1, col2 FROM read_parquet('s3:///example_file.parquet'); + "#; + let result = parse_assets(input).unwrap().assets; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].kind, AssetKind::S3Object); + assert_eq!(result[0].path, "/example_file.parquet"); + assert_eq!(result[0].access_type, Some(R)); + + let columns = result[0].columns.as_ref().expect("Should have columns"); + assert_eq!(columns.len(), 2); + assert_eq!(columns.get("col1"), Some(&R)); + assert_eq!(columns.get("col2"), Some(&R)); + } + + #[test] + fn test_sql_asset_parser_s3_single_table_column_with_alias() { + let input = r#" + SELECT col1 AS c1, col2 AS c2 FROM read_parquet('s3:///example_file.parquet'); + "#; + let result = parse_assets(input).unwrap().assets; + + assert_eq!(result.len(), 1); + let columns = result[0].columns.as_ref().expect("Should have columns"); + assert_eq!(columns.get("col1"), Some(&R)); + assert_eq!(columns.get("col2"), Some(&R)); + } + + #[test] + fn test_sql_asset_parser_s3_wildcard_no_columns() { + let input = r#" + SELECT * FROM read_parquet('s3:///example_file.parquet'); + "#; + let result = parse_assets(input).unwrap().assets; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].kind, AssetKind::S3Object); + assert_eq!(result[0].path, "/example_file.parquet"); + assert!(result[0].columns.is_none()); + } + + #[test] + fn test_sql_asset_parser_s3_table_alias_qualified_columns() { + let input = r#" + SELECT t.col1, t.col2 FROM read_parquet('s3:///example_file.parquet') AS t; + "#; + let result = parse_assets(input).unwrap().assets; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].kind, AssetKind::S3Object); + assert_eq!(result[0].path, "/example_file.parquet"); + + let columns = result[0].columns.as_ref().expect("Should have columns"); + assert_eq!(columns.get("col1"), Some(&R)); + assert_eq!(columns.get("col2"), Some(&R)); + } + + #[test] + fn test_sql_asset_parser_s3_multi_table_aliased_columns() { + let input = r#" + SELECT t1.col1, t2.col2 + FROM read_parquet('s3:///file1.parquet') AS t1, + read_csv('s3://bucket/file2.csv') AS t2; + "#; + let result = parse_assets(input).unwrap().assets; + + assert_eq!(result.len(), 2); + + assert!(result.iter().any(|a| { + a.path == "/file1.parquet" + && a.columns.as_ref().map_or(false, |c| c.contains_key("col1")) + })); + assert!(result.iter().any(|a| { + a.path == "bucket/file2.csv" + && a.columns.as_ref().map_or(false, |c| c.contains_key("col2")) + })); + } + + #[test] + fn test_sql_asset_parser_s3_multi_table_no_alias_no_columns() { + // Without aliases, unqualified columns in a multi-table query are ambiguous + let input = r#" + SELECT col1, col2 + FROM read_parquet('s3:///file1.parquet'), + read_parquet('s3:///file2.parquet'); + "#; + let result = parse_assets(input).unwrap().assets; + + // Table-level assets should still be detected, but no columns + assert_eq!(result.iter().filter(|a| a.columns.is_some()).count(), 0); + } + + #[test] + fn test_sql_asset_parser_s3_str_literal_table_column_detection() { + // FROM 's3:///file.parquet' (string literal as table, no read_parquet wrapper) + let input = r#" + SELECT a,b,c FROM 's3:///test.parquet'; + "#; + let result = parse_assets(input).unwrap().assets; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].kind, AssetKind::S3Object); + assert_eq!(result[0].path, "/test.parquet"); + assert_eq!(result[0].access_type, Some(R)); + + let columns = result[0].columns.as_ref().expect("Should have columns"); + assert_eq!(columns.len(), 3); + assert_eq!(columns.get("a"), Some(&R)); + assert_eq!(columns.get("b"), Some(&R)); + assert_eq!(columns.get("c"), Some(&R)); + } + + #[test] + fn test_sql_asset_parser_s3_str_literal_table_with_alias_columns() { + let input = r#" + SELECT t.col1, t.col2 FROM 's3://bucket/file.parquet' AS t; + "#; + let result = parse_assets(input).unwrap().assets; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].kind, AssetKind::S3Object); + assert_eq!(result[0].path, "bucket/file.parquet"); + + let columns = result[0].columns.as_ref().expect("Should have columns"); + assert_eq!(columns.get("col1"), Some(&R)); + assert_eq!(columns.get("col2"), Some(&R)); + } + + #[test] + fn test_sql_asset_parser_s3_str_literal_wildcard_no_columns() { + let input = r#" + SELECT * FROM 's3:///test.parquet'; + "#; + let result = parse_assets(input).unwrap().assets; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].kind, AssetKind::S3Object); + assert!(result[0].columns.is_none()); + } + + #[test] + fn test_sql_asset_parser_s3_read_csv_columns() { + let input = r#" + SELECT name, age FROM read_csv('s3://my-bucket/data.csv'); + "#; + let result = parse_assets(input).unwrap().assets; + + assert_eq!(result.len(), 1); + assert_eq!(result[0].kind, AssetKind::S3Object); + assert_eq!(result[0].path, "my-bucket/data.csv"); + + let columns = result[0].columns.as_ref().expect("Should have columns"); + assert_eq!(columns.get("name"), Some(&R)); + assert_eq!(columns.get("age"), Some(&R)); + } } diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 40f53ecb48..1a0d425704 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -59,3 +59,6 @@ wasm-bindgen.workspace = true serde_json.workspace = true getrandom = { workspace = true, features = ["js"] } +# getrandom 0.3 is pulled in transitively by rand 0.9 (via windmill-types). +# It requires the "wasm_js" feature to work on wasm32-unknown-unknown. +getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] } diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index f3849a46b1..17e79c3393 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -39,8 +39,6 @@ use windmill_common::ee_oss::{jobs_waiting_alerts, worker_groups_alerts}; #[cfg(feature = "oauth2")] use windmill_common::global_settings::OAUTH_SETTING; -#[cfg(feature = "parquet")] -use windmill_object_store::reload_object_store_setting; use windmill_common::{ agent_workers::DECODED_AGENT_TOKEN, apps::APP_WORKSPACED_ROUTE, @@ -56,7 +54,7 @@ use windmill_common::{ HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, - NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, + NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, @@ -84,14 +82,16 @@ use windmill_common::{ OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS, }; use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING}; +#[cfg(feature = "parquet")] +use windmill_object_store::reload_object_store_setting; use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload}; use windmill_worker::{ result_processor::handle_job_error, JobCompletedSender, JobIsolationLevel, OtelTracingProxySettings, SameWorkerSender, BUNFIG_INSTALL_SCOPES, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR, JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, - MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, - NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, - POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UV_INDEX_STRATEGY, + MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, + NSJAIL_AVAILABLE, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, + PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UV_INDEX_STRATEGY, }; #[cfg(feature = "parquet")] @@ -330,6 +330,7 @@ pub async fn initial_load( reload_uv_index_strategy_setting(&conn).await; reload_npm_config_registry_setting(&conn).await; reload_bunfig_install_scopes_setting(&conn).await; + reload_npmrc_setting(&conn).await; reload_instance_python_version_setting(&conn).await; reload_nuget_config_setting(&conn).await; reload_powershell_repo_url_setting(&conn).await; @@ -1204,7 +1205,10 @@ async fn delete_log_files_from_disk_and_store( #[cfg(feature = "parquet")] if _should_del_from_store { if let Some(os) = _os2 { - let p = windmill_object_store::object_store_reexports::Path::from(format!("{}{}", _s3_prefix, path)); + let p = windmill_object_store::object_store_reexports::Path::from(format!( + "{}{}", + _s3_prefix, path + )); if let Err(e) = os.delete(&p).await { tracing::error!("Failed to delete from object store {}: {e}", p.to_string()) } else { @@ -1303,6 +1307,10 @@ pub async fn reload_bunfig_install_scopes_setting(conn: &Connection) { .await; } +pub async fn reload_npmrc_setting(conn: &Connection) { + reload_option_setting_with_tracing(conn, NPMRC_SETTING, "NPMRC", NPMRC.clone()).await; +} + pub async fn reload_nuget_config_setting(conn: &Connection) { reload_option_setting_with_tracing( conn, @@ -2341,7 +2349,7 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> { async fn stale_job_cancellation(db: &Pool) { if let Some(threshold) = *STALE_JOB_THRESHOLD_MINUTES { let stale_jobs = sqlx::query!( - "SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval", + "SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind", threshold.to_string() ) .fetch_all(db) diff --git a/backend/substitute_ee_code.sh b/backend/substitute_ee_code.sh index e98f44529e..63d39e3854 100755 --- a/backend/substitute_ee_code.sh +++ b/backend/substitute_ee_code.sh @@ -7,6 +7,7 @@ REVERT="NO" COPY="NO" MOVE_NEW_FILES="NO" EE_CODE_DIR="../windmill-ee-private/" +DIR_EXPLICIT="NO" while [[ $# -gt 0 ]]; do case $1 in @@ -34,6 +35,7 @@ while [[ $# -gt 0 ]]; do # Path to the local directory of the windmill-ee-private repository. By defaults, it # assumes it is cloned next to the Windmill OSS repo. EE_CODE_DIR="$2" + DIR_EXPLICIT="YES" shift # past argument shift # past value ;; @@ -55,10 +57,22 @@ else fi # Fallback to ~/windmill-ee-private if the default location doesn't exist -if [ ! -d "${EE_CODE_DIR}" ] && [ "${EE_CODE_DIR}" == "${root_dirpath}/../windmill-ee-private/" ]; then +if [ ! -d "${EE_CODE_DIR}" ]; then EE_CODE_DIR="${HOME}/windmill-ee-private" fi +# Unless --dir was explicitly set, try to find an EE worktree on the same branch +if [ "$DIR_EXPLICIT" == "NO" ] && [ -d "${HOME}/windmill-ee-private" ]; then + current_branch=$(git -C "${root_dirpath}" branch --show-current 2>/dev/null || true) + if [ -n "$current_branch" ]; then + ee_worktree=$(git -C "${HOME}/windmill-ee-private" worktree list 2>/dev/null \ + | awk -v branch="[${current_branch}]" '$NF == branch {print $1; exit}') + if [ -n "$ee_worktree" ] && [ -d "$ee_worktree" ]; then + EE_CODE_DIR="$ee_worktree" + fi + fi +fi + echo "EE code directory = ${EE_CODE_DIR} | Revert = ${REVERT}" if [ ! -d "${EE_CODE_DIR}" ]; then diff --git a/backend/test_debounce_e2e.sh b/backend/test_debounce_e2e.sh new file mode 100755 index 0000000000..d7088000dd --- /dev/null +++ b/backend/test_debounce_e2e.sh @@ -0,0 +1,474 @@ +#!/usr/bin/env bash +# End-to-end debounce tests against the running backend API +# Usage: BACKEND_PORT=8030 ./test_debounce_e2e.sh +set -uo pipefail + +BASE="http://localhost:${BACKEND_PORT:-8030}/api" +W="admins" +EMAIL="admin@windmill.dev" +PASSWORD="changeme" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +pass=0 +fail=0 + +log_pass() { echo -e "${GREEN}PASS${NC}: $1"; ((pass++)) || true; } +log_fail() { echo -e "${RED}FAIL${NC}: $1 — $2"; ((fail++)) || true; } +log_info() { echo -e "${YELLOW}INFO${NC}: $1"; } + +# Unique suffix for idempotent re-runs +TS=$(date +%s) + +# --- Auth --- +log_info "Logging in..." +TOKEN=$(curl -s "$BASE/auth/login" \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}") + +if [ -z "$TOKEN" ]; then + echo "Failed to login"; exit 1 +fi + +AUTH="Authorization: Bearer $TOKEN" +log_info "Logged in" + +# --- Helpers --- +api() { + # Usage: api METHOD path [data] + local method="$1" path="$2" data="${3:-}" + if [ -n "$data" ]; then + curl -s "$BASE/w/$W/$path" -X "$method" -H "$AUTH" -H 'Content-Type: application/json' -d "$data" + else + curl -s "$BASE/w/$W/$path" -X "$method" -H "$AUTH" + fi +} + +wait_job() { + local job_id="$1" max_wait="${2:-30}" + for _ in $(seq 1 "$max_wait"); do + local r + r=$(api GET "jobs/completed/get_result_maybe/$job_id") + if echo "$r" | jq -e '.completed == true' > /dev/null 2>&1; then + echo "$r"; return 0 + fi + sleep 1 + done + echo '{"completed":false,"error":"timeout"}'; return 1 +} + +BUN_EMPTY_LOCK=$'{"dependencies": {}}\n//bun.lock\n' + +create_script() { + # Usage: create_script path language content [extra_json_fields] + # Note: lock must be non-empty; empty string ("") is treated as None by the backend + # (scripts.rs:798-800), which triggers dependency resolution instead of direct deployment. + # For bun scripts, the lock must contain "//bun.lock" as a split pattern. + local path="$1" lang="$2" content="$3" extra="${4:-}" + local json + json=$(jq -n \ + --arg path "$path" \ + --arg lang "$lang" \ + --arg content "$content" \ + --arg summary "test" \ + --arg desc "test" \ + --arg lock "$BUN_EMPTY_LOCK" \ + '{path: $path, language: $lang, content: $content, summary: $summary, description: $desc, lock: $lock}') + if [ -n "$extra" ]; then + json=$(echo "$json" | jq ". + $extra") + fi + local hash + hash=$(api POST "scripts/create" "$json") + # Small delay for DB visibility after tx commit + sleep 0.2 + echo "$hash" +} + +run_script() { + # Usage: run_script path args_json + api POST "jobs/run/p/$1" "$2" +} + +############################################################################### +# TEST 1: Deploy a script and run it 5 times in close succession +############################################################################### +echo "" +log_info "=== TEST 1: Deploy & run script 5 times rapidly ===" + +P1="u/admin/e2e_simple_$TS" +H1=$(create_script "$P1" "bun" 'export function main(x: number = 0) { return { result: x * 2 }; }') + +if echo "$H1" | grep -qE '^[0-9a-f]{16}$'; then + log_pass "Script created: $H1" +else + log_fail "Script creation" "$H1" +fi + +log_info "Running 5 times rapidly..." +JOB_IDS=() +for i in $(seq 1 5); do + JID=$(run_script "$P1" "{\"x\": $i}") + JOB_IDS+=("$JID") +done +log_info "Jobs: ${JOB_IDS[*]}" + +log_info "Waiting for completion..." +all_ok=true +for i in "${!JOB_IDS[@]}"; do + JID="${JOB_IDS[$i]}" + R=$(wait_job "$JID" 30) + success=$(echo "$R" | jq -r '.success // false') + value=$(echo "$R" | jq -r '.result.result // "null"') + expected=$(( (i + 1) * 2 )) + if [ "$success" = "true" ] && [ "$value" = "$expected" ]; then + log_pass "Job $((i+1)): x=$((i+1)) → $value (correct)" + else + log_fail "Job $((i+1))" "success=$success value=$value expected=$expected" + all_ok=false + fi +done + +if [ "$all_ok" = "true" ]; then + log_pass "All 5 runs completed correctly (no debounce — different args)" +fi + +############################################################################### +# TEST 2: Redeploy script WITHOUT lock in close succession +############################################################################### +echo "" +log_info "=== TEST 2: Redeploy without lock in rapid succession ===" + +P2="u/admin/e2e_nolock_$TS" + +# Deploy 5 versions of the same script without lock → triggers dependency jobs +DEPLOY_HASHES=() +for i in $(seq 1 5); do + content="export function main(x: number = 0) { return { result: x * $i, version: $i }; }" + parent_extra="" + if [ "${#DEPLOY_HASHES[@]}" -gt 0 ]; then + last_hash="${DEPLOY_HASHES[-1]}" + parent_extra="{\"parent_hash\": \"$last_hash\"}" + fi + + # Deploy without lock (omit lock field entirely) + json=$(jq -n \ + --arg path "$P2" \ + --arg content "$content" \ + --arg summary "v$i" \ + --arg desc "test" \ + '{path: $path, language: "bun", content: $content, summary: $summary, description: $desc}') + if [ -n "$parent_extra" ]; then + json=$(echo "$json" | jq ". + $parent_extra") + fi + + hash=$(api POST "scripts/create" "$json") + if echo "$hash" | grep -qE '^[0-9a-f]{16}$'; then + DEPLOY_HASHES+=("$hash") + log_info "Deploy $i: $hash" + else + log_fail "Deploy $i" "$hash" + # If path conflict, the script already exists from a previous version + break + fi + sleep 0.1 +done + +# Wait for dependency resolution +log_info "Waiting 15s for dependency jobs..." +sleep 15 + +# Check the latest script — should have lock resolved +SCRIPT_INFO=$(api GET "scripts/get/p/$P2") +LOCK=$(echo "$SCRIPT_INFO" | jq -r '.lock // "null"') +if [ "$LOCK" != "null" ] && [ -n "$LOCK" ]; then + log_pass "Latest version has lock resolved" +else + log_info "Lock not yet resolved: $LOCK" +fi + +# Run the latest version to verify it works +sleep 0.5 +JID2=$(run_script "$P2" '{"x": 10}') +if echo "$JID2" | grep -qE '^[0-9a-f-]{36}$'; then + R2=$(wait_job "$JID2" 30) + success=$(echo "$R2" | jq -r '.success // false') + if [ "$success" = "true" ]; then + version=$(echo "$R2" | jq -r '.result.version // "?"') + log_pass "Latest version runs: version=$version" + else + err=$(echo "$R2" | jq -r '.result.error.message // "unknown"' 2>/dev/null) + log_fail "Run latest version" "success=false err=$err" + fi +else + log_fail "Run latest version" "bad job id: $JID2" +fi + +############################################################################### +# TEST 3: Script with debounce_delay_s — rapid runs with SAME args +############################################################################### +echo "" +log_info "=== TEST 3: Debounce with same args (should debounce) ===" + +P3="u/admin/e2e_debounce_$TS" +H3=$(create_script "$P3" "bun" \ + 'export function main(x: number = 0) { return { result: x }; }' \ + '{"debounce_delay_s": 3}') + +if echo "$H3" | grep -qE '^[0-9a-f]{16}$'; then + log_pass "Debounce script created: $H3" +else + log_fail "Debounce script creation" "$H3" +fi + +log_info "Running 5 times with same args {x: 42}..." +DEB_IDS=() +for i in $(seq 1 5); do + JID=$(run_script "$P3" '{"x": 42}') + DEB_IDS+=("$JID") + log_info " Run $i: $JID" +done + +log_info "Waiting 10s for debounce delay (3s) + execution..." +sleep 10 + +executed=0 +skipped=0 +for JID in "${DEB_IDS[@]}"; do + if ! echo "$JID" | grep -qE '^[0-9a-f-]{36}$'; then + log_info " Invalid job id: $JID" + continue + fi + R=$(wait_job "$JID" 5 2>/dev/null || echo '{"completed":false}') + completed=$(echo "$R" | jq -r '.completed // false') + success=$(echo "$R" | jq -r '.success // false') + if [ "$completed" = "true" ] && [ "$success" = "true" ]; then + ((executed++)) || true + elif [ "$completed" = "true" ]; then + ((skipped++)) || true + fi +done + +log_info "Results: $executed executed, $skipped skipped out of ${#DEB_IDS[@]}" +if [ "$executed" -eq 1 ] && [ "$skipped" -ge 3 ]; then + log_pass "Debouncing perfect: 1 executed, $skipped skipped" +elif [ "$executed" -le 2 ] && [ "$skipped" -ge 2 ]; then + log_pass "Debouncing working: $executed executed, $skipped skipped" +else + log_fail "Debounce same args" "executed=$executed skipped=$skipped (want ~1 exec, ~4 skip)" +fi + +############################################################################### +# TEST 3b: Different args should NOT debounce against each other +############################################################################### +echo "" +log_info "=== TEST 3b: Debounce with different args (should NOT debounce) ===" + +DIFF_IDS=() +for i in $(seq 1 3); do + JID=$(run_script "$P3" "{\"x\": $((i * 100))}") + DIFF_IDS+=("$JID") +done + +log_info "Waiting 8s..." +sleep 8 + +diff_exec=0 +for JID in "${DIFF_IDS[@]}"; do + if ! echo "$JID" | grep -qE '^[0-9a-f-]{36}$'; then continue; fi + R=$(wait_job "$JID" 5 2>/dev/null || echo '{"completed":false}') + success=$(echo "$R" | jq -r '.success // false') + if [ "$success" = "true" ]; then ((diff_exec++)) || true; fi +done + +if [ "$diff_exec" -eq 3 ]; then + log_pass "Different args: all 3 executed independently" +else + log_fail "Different args" "only $diff_exec/3 executed" +fi + +############################################################################### +# TEST 4: Custom debounce_key with $args interpolation +############################################################################### +echo "" +log_info "=== TEST 4: Custom debounce key ===" + +P4="u/admin/e2e_custom_key_$TS" +H4=$(create_script "$P4" "bun" \ + 'export function main(event_id: string = "", data: string = "") { return { event_id, data }; }' \ + '{"debounce_delay_s": 3, "debounce_key": "event#$args.event_id"}') + +if echo "$H4" | grep -qE '^[0-9a-f]{16}$'; then + log_pass "Custom key script created: $H4" +else + log_fail "Custom key script creation" "$H4" +fi + +# Same event_id → should debounce +log_info "3 runs with same event_id..." +SAME_IDS=() +for i in $(seq 1 3); do + JID=$(run_script "$P4" "{\"event_id\": \"evt_001\", \"data\": \"payload_$i\"}") + SAME_IDS+=("$JID") +done + +# Different event_id → should NOT debounce +JID_DIFF=$(run_script "$P4" '{"event_id": "evt_002", "data": "different"}') + +log_info "Waiting 8s..." +sleep 8 + +same_exec=0 +same_skip=0 +for JID in "${SAME_IDS[@]}"; do + if ! echo "$JID" | grep -qE '^[0-9a-f-]{36}$'; then continue; fi + R=$(wait_job "$JID" 5 2>/dev/null || echo '{"completed":false}') + completed=$(echo "$R" | jq -r '.completed // false') + success=$(echo "$R" | jq -r '.success // false') + if [ "$completed" = "true" ] && [ "$success" = "true" ]; then + data=$(echo "$R" | jq -r '.result.data // "?"') + ((same_exec++)) || true + log_info " Executed: data=$data" + elif [ "$completed" = "true" ]; then + ((same_skip++)) || true + fi +done + +log_info "Same event_id: $same_exec executed, $same_skip skipped" +if [ "$same_exec" -eq 1 ] && [ "$same_skip" -ge 1 ]; then + log_pass "Custom key debounce: same event_id debounced correctly" +elif [ "$same_exec" -le 2 ]; then + log_pass "Custom key debounce working: $same_exec executed, $same_skip skipped" +else + log_fail "Custom key debounce" "exec=$same_exec skip=$same_skip" +fi + +# Check different event_id ran independently +if echo "$JID_DIFF" | grep -qE '^[0-9a-f-]{36}$'; then + R_DIFF=$(wait_job "$JID_DIFF" 10 2>/dev/null || echo '{"completed":false}') + diff_success=$(echo "$R_DIFF" | jq -r '.success // false') + if [ "$diff_success" = "true" ]; then + log_pass "Different event_id: executed independently" + else + log_info "Different event_id: success=$diff_success" + fi +fi + +############################################################################### +# TEST 5: Git sync with bad target — debounced deployment callbacks +############################################################################### +echo "" +log_info "=== TEST 5: Git sync debounce + aggregation ===" + +# Create git repo resource +api POST "resources/create?update_if_exists=true" '{ + "path": "u/admin/e2e_bad_git_repo", + "description": "Bad git repo for testing", + "resource_type": "git_repository", + "value": {"url": "https://github.com/nonexistent/nope.git", "branch": "main", "token": "bad"} +}' > /dev/null 2>&1 +log_info "Created git repo resource" + +# Create a sync script at a folder path where the 2nd segment is a number >= 28103. +# is_script_meets_min_version parses split("/").skip(1).next() as the version number. +# This enables debounce_delay_s=5 and debounce_args_to_accumulate=["items"]. +api POST "folders/create" '{"name": "28103"}' > /dev/null 2>&1 +P5="f/28103/e2e_sync_$TS" +H5=$(create_script "$P5" "bun" \ + 'export function main(repo_url_resource_path: string = "", workspace_id: string = "", items: any[] = [], use_individual_branch: boolean = false, group_by_folder: boolean = false, parent_workspace_id: string = "") { return { synced: items.length, items }; }') + +if echo "$H5" | grep -qE '^[0-9a-f]{16}$'; then + log_pass "Sync script created: $H5" +else + log_fail "Sync script creation" "$H5" +fi + +# Configure git sync with include_path to match deployed scripts. +# Without include_path, path_matches_filters returns false and no DeploymentCallback is created. +api POST "workspaces/edit_git_sync_config" "{ + \"git_sync_settings\": { + \"include_type\": [\"script\"], + \"include_path\": [\"**\"], + \"repositories\": [{ + \"script_path\": \"$P5\", + \"git_repo_resource_path\": \"\$res:u/admin/e2e_bad_git_repo\", + \"use_individual_branch\": false, + \"group_by_folder\": false + }] + } +}" > /dev/null 2>&1 +log_pass "Git sync configured with include_path and versioned folder script path" + +# Deploy 5 scripts rapidly to trigger git sync. +# Scripts are created with lock="" (via create_script), so handle_deployment_metadata +# fires immediately after tx commit (not after dependency resolution). +log_info "Deploying 5 scripts to trigger git sync..." +for i in $(seq 1 5); do + dp="u/admin/e2e_gitsync_${TS}_$i" + create_script "$dp" "bun" "export function main() { return { v: $i }; }" > /dev/null + log_info " Deployed $dp" +done + +# Wait for debounce delay (5s) + execution +log_info "Waiting 15s for debounce (5s) + execution..." +sleep 15 + +# Check deployment callback jobs for our sync script. +# Debounced jobs have is_skipped=true (but success=true), so we use is_skipped to distinguish. +SYNC_JOBS=$(api GET "jobs/completed/list?script_path_exact=$P5&job_kinds=deploymentcallback") +SYNC_TOTAL=$(echo "$SYNC_JOBS" | jq 'length') +SYNC_EXECUTED=$(echo "$SYNC_JOBS" | jq '[.[] | select(.is_skipped != true)] | length') +SYNC_SKIPPED=$(echo "$SYNC_JOBS" | jq '[.[] | select(.is_skipped == true)] | length') + +log_info "Sync jobs: total=$SYNC_TOTAL executed=$SYNC_EXECUTED skipped=$SYNC_SKIPPED" + +if [ "$SYNC_TOTAL" -gt 0 ]; then + # With debouncing (5s delay), rapid deploys should be consolidated. + # All 5 jobs are created but most should be skipped (debounced). + if [ "$SYNC_SKIPPED" -gt 0 ]; then + log_pass "Git sync debouncing: $SYNC_EXECUTED executed, $SYNC_SKIPPED debounced out of $SYNC_TOTAL" + else + log_fail "Git sync debouncing" "No jobs were debounced ($SYNC_TOTAL all executed independently)" + fi + + # Check if items were aggregated in the executed (non-skipped) job(s) + for idx in $(seq 0 $((SYNC_TOTAL - 1))); do + is_skipped=$(echo "$SYNC_JOBS" | jq -r ".[$idx].is_skipped") + [ "$is_skipped" = "true" ] && continue + jid=$(echo "$SYNC_JOBS" | jq -r ".[$idx].id") + r=$(api GET "jobs/completed/get_result/$jid") + items_count=$(echo "$r" | jq '.items | length // 0') + log_info " Executed sync job $jid: items=$items_count" + if [ "$items_count" -gt 1 ]; then + log_pass "Items aggregated: $items_count items in single sync job" + fi + done +else + # Check queued — jobs may still be pending debounce delay + Q=$(api GET "jobs/queue/list?script_path_exact=$P5&job_kinds=deploymentcallback") + QC=$(echo "$Q" | jq 'length') + log_info "No completed sync jobs. $QC queued." + if [ "$QC" -gt 0 ] && [ "$QC" -lt 5 ]; then + log_pass "Git sync debouncing (queued): $QC jobs for 5 deploys" + elif [ "$QC" -eq 0 ]; then + log_fail "Git sync" "No deployment callback jobs found (completed or queued)" + fi +fi + +# Cleanup git sync +api POST "workspaces/edit_git_sync_config" '{"git_sync_settings": null}' > /dev/null 2>&1 +log_info "Git sync config cleared" + +############################################################################### +# Summary +############################################################################### +echo "" +echo "=========================================" +echo -e "Results: ${GREEN}$pass passed${NC}, ${RED}$fail failed${NC}" +echo "=========================================" + +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 534d530095..15a2b3c27c 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -1,8 +1,8 @@ -use windmill_test_utils::*; use sqlx::postgres::Postgres; use sqlx::Pool; use windmill_common::jobs::{JobPayload, RawCode}; use windmill_common::scripts::ScriptLang; +use windmill_test_utils::*; // ============================================================================ // Basic Execution Tests @@ -27,8 +27,8 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -63,8 +63,8 @@ export function main(name: string, count: number) { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -104,8 +104,9 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -135,8 +136,9 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -167,8 +169,9 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -207,8 +210,8 @@ export async function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -245,8 +248,9 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -276,8 +280,9 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -318,8 +323,8 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -358,8 +363,8 @@ export function notMain() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -398,8 +403,8 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -437,8 +442,8 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -474,8 +479,8 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -516,8 +521,8 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -613,8 +618,9 @@ export function main() { path: Some("f/nested/test_deep".to_string()), language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -647,8 +653,9 @@ export function main() { path: Some("f/nested/test_deep_relative".to_string()), language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -693,8 +700,8 @@ export function main() { path: Some("f/circular/test_both".to_string()), language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -741,8 +748,8 @@ export function main(x: number) { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -791,8 +798,8 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -836,8 +843,8 @@ export function main() { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -859,11 +866,11 @@ export function main() { // ============================================================================ mod dedicated_worker_protocol { - use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; + use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult}; use windmill_worker::{ - build_loader, generate_dedicated_worker_wrapper, BUN_DEDICATED_WORKER_ARGS, LoaderMode, + build_loader, generate_dedicated_worker_wrapper, LoaderMode, BUN_DEDICATED_WORKER_ARGS, BUN_PATH, NODE_BIN_PATH, }; @@ -934,12 +941,8 @@ mod dedicated_worker_protocol { let temp_dir = tempfile::tempdir().unwrap(); // Create files and get the wrapper path (bundled for node, raw for bun) - let wrapper_path = create_test_worker_files( - temp_dir.path(), - script, - arg_names, - runtime == "node", - ); + let wrapper_path = + create_test_worker_files(temp_dir.path(), script, arg_names, runtime == "node"); let wrapper_str = wrapper_path.to_str().unwrap(); // Build args matching production behavior @@ -992,7 +995,10 @@ mod dedicated_worker_protocol { match parse_dedicated_worker_line(response.trim()) { DedicatedWorkerResult::Success(value) => results.push(Ok(value)), DedicatedWorkerResult::Error(err) => { - let msg = err["message"].as_str().unwrap_or("Unknown error").to_string(); + let msg = err["message"] + .as_str() + .unwrap_or("Unknown error") + .to_string(); results.push(Err(msg)); } other => panic!("Unexpected response: {:?}", other), @@ -1162,8 +1168,8 @@ export function main(name: string) { path: None, language: ScriptLang::Bun, lock: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, cache_ignore_s3_path: None, @@ -1190,6 +1196,68 @@ export function main(name: string) { Ok(()) } +/// Test that full .npmrc content works for bun jobs with private registries. +/// Requires: +/// - `TEST_NPMRC` environment variable set to the full .npmrc content +#[cfg(feature = "private_registry_test")] +#[sqlx::test(fixtures("base"))] +async fn test_bun_job_private_npmrc(db: Pool) -> anyhow::Result<()> { + use windmill_worker::NPMRC; + + let npmrc_content = std::env::var("TEST_NPMRC") + .expect("TEST_NPMRC must be set when running private_registry_test"); + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + { + let mut npmrc = NPMRC.write().await; + *npmrc = Some(npmrc_content.clone()); + } + + let content = r#" +import { greet } from "@windmill-test/private-pkg"; + +export function main(name: string) { + return greet(name); +} +"# + .to_owned(); + + let job = JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Bun, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + }); + + let result = RunJob::from(job) + .arg("name", serde_json::json!("World")) + .run_until_complete(&db, false, port) + .await + .json_result() + .unwrap(); + + { + let mut npmrc = NPMRC.write().await; + *npmrc = None; + } + + assert_eq!( + result, + serde_json::json!("Hello from private package, World!") + ); + Ok(()) +} + /// Tests for RELATIVE_BUN_BUILDER (loader_builder.bun.js) /// These tests verify Bun's behavior for import scanning and package.json generation. /// Purpose: Catch regressions when upgrading Bun versions. @@ -1241,8 +1309,8 @@ mod bun_builder_tests { } // Read generated package.json - let package_json = std::fs::read_to_string(dir.join("package.json")) - .expect("package.json not generated"); + let package_json = + std::fs::read_to_string(dir.join("package.json")).expect("package.json not generated"); serde_json::from_str(&package_json).expect("Invalid JSON in package.json") } @@ -1257,7 +1325,10 @@ export function main() { return lodash; } let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); - assert!(deps.contains_key("lodash"), "lodash should be in dependencies"); + assert!( + deps.contains_key("lodash"), + "lodash should be in dependencies" + ); assert_eq!(deps["lodash"], "latest"); } @@ -1271,7 +1342,10 @@ export function main() { return _; } let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); - assert!(deps.contains_key("lodash"), "lodash should be in dependencies"); + assert!( + deps.contains_key("lodash"), + "lodash should be in dependencies" + ); assert_eq!(deps["lodash"], "4.17.21"); } @@ -1304,9 +1378,18 @@ export function main() { return { lodash, axios, dayjs }; } let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); - assert!(deps.contains_key("lodash"), "lodash should be in dependencies"); - assert!(deps.contains_key("axios"), "axios should be in dependencies"); - assert!(deps.contains_key("dayjs"), "dayjs should be in dependencies"); + assert!( + deps.contains_key("lodash"), + "lodash should be in dependencies" + ); + assert!( + deps.contains_key("axios"), + "axios should be in dependencies" + ); + assert!( + deps.contains_key("dayjs"), + "dayjs should be in dependencies" + ); assert_eq!(deps.len(), 3, "Should have exactly 3 dependencies"); } @@ -1330,8 +1413,15 @@ export function main() { return { fs, path, lodash }; } !deps.contains_key("path"), "path (builtin) should NOT be in dependencies" ); - assert!(deps.contains_key("lodash"), "lodash should be in dependencies"); - assert_eq!(deps.len(), 1, "Should have exactly 1 dependency (lodash only)"); + assert!( + deps.contains_key("lodash"), + "lodash should be in dependencies" + ); + assert_eq!( + deps.len(), + 1, + "Should have exactly 1 dependency (lodash only)" + ); } /// Test: semver.order() resolves version conflicts (picks lowest version) @@ -1347,7 +1437,10 @@ export function main() { return { a, b }; } let pkg = run_builder(main_ts); let deps = pkg["dependencies"].as_object().unwrap(); - assert!(deps.contains_key("lodash"), "lodash should be in dependencies"); + assert!( + deps.contains_key("lodash"), + "lodash should be in dependencies" + ); // The builder sorts by semver and picks the first (lowest) version assert_eq!( deps["lodash"], "4.17.10", diff --git a/backend/tests/fixtures/wmill_cli_test.sql b/backend/tests/fixtures/wmill_cli_test.sql new file mode 100644 index 0000000000..e4ac24734b --- /dev/null +++ b/backend/tests/fixtures/wmill_cli_test.sql @@ -0,0 +1,10 @@ +-- Fixture for testing wmill CLI variable/resource get from bash scripts + +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'u/test-user/test_var', 'hello from variable', false, 'A test variable', '{"u/test-user": true}'); + +INSERT INTO resource_type (workspace_id, name, schema, description, created_by) +VALUES ('test-workspace', 'test_object', '{}', 'Test object type', 'test-user'); + +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ('test-workspace', 'u/test-user/test_res', '{"host": "localhost", "port": 5432}', 'A test resource', 'test_object', '{"u/test-user": true}', 'test-user'); diff --git a/backend/tests/nativets_dedicated.rs b/backend/tests/nativets_dedicated.rs new file mode 100644 index 0000000000..4b730b32c7 --- /dev/null +++ b/backend/tests/nativets_dedicated.rs @@ -0,0 +1,275 @@ +/* + * Tests for the PrewarmedIsolate used by nativets dedicated workers. + * + * Run with: + * cargo test -p windmill --features "deno_core" --test nativets_dedicated -- --nocapture + */ + +#[cfg(feature = "deno_core")] +mod prewarmed_isolate_tests { + use std::process::Command; + use windmill_runtime_nativets::{NativeAnnotation, PrewarmedIsolate}; + use windmill_worker::{build_loader, LoaderMode, BUN_PATH}; + + fn default_annotation() -> NativeAnnotation { + NativeAnnotation { useragent: None, proxy: None } + } + + /// Bundle a TypeScript script into JS suitable for `PrewarmedIsolate`. + /// + /// Returns `(ts_source, js_bundle, arg_names)`. + async fn bundle_script(script: &str) -> (String, String, Vec) { + let temp_dir = tempfile::tempdir().unwrap(); + let dir = temp_dir.path(); + let dir_str = dir.to_str().unwrap(); + + std::fs::write(dir.join("main.ts"), script).unwrap(); + + build_loader( + dir_str, + "http://localhost:8000", + "test_token", + "test-workspace", + "f/test/script", + LoaderMode::BrowserBundle, + ) + .await + .expect("build_loader failed"); + + let output = Command::new(BUN_PATH.as_str()) + .args(["run", dir.join("node_builder.ts").to_str().unwrap()]) + .current_dir(dir) + .output() + .expect("Failed to run bun build"); + + if !output.status.success() { + panic!( + "Bun build failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let ts = std::fs::read_to_string(dir.join("main.ts")).unwrap(); + let js = std::fs::read_to_string(dir.join("main.js")).unwrap(); + let parsed = windmill_parser_ts::parse_deno_signature(&ts, true, false, None) + .expect("failed to parse signature"); + let arg_names: Vec = parsed.args.into_iter().map(|a| a.name).collect(); + (ts, js, arg_names) + } + + const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + + async fn run_prewarmed_test( + script: &str, + jobs: Vec, + ) -> Vec> { + tokio::time::timeout(TEST_TIMEOUT, run_prewarmed_test_inner(script, jobs)) + .await + .expect("test timed out after 30s") + } + + async fn run_prewarmed_test_inner( + script: &str, + jobs: Vec, + ) -> Vec> { + windmill_runtime_nativets::setup_deno_runtime().expect("V8 init failed"); + + let (_ts, js, arg_names) = bundle_script(script).await; + let ann = default_annotation(); + + let mut results = Vec::new(); + + for job_args in &jobs { + let mut isolate = + PrewarmedIsolate::spawn("".to_string(), js.clone(), ann.clone(), arg_names.clone()); + isolate.wait_ready().await.expect("isolate failed to warm"); + + let args = serde_json::to_string(job_args).unwrap(); + let executing = isolate.start_execution(args); + let prewarmed_result = executing.wait().await.expect("isolate execution failed"); + + match prewarmed_result.result { + Ok(raw) => { + let value: serde_json::Value = + serde_json::from_str(raw.get()).unwrap_or(serde_json::Value::Null); + results.push(Ok(value)); + } + Err(e) => { + results.push(Err(e)); + } + } + } + + results + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_prewarmed_simple() { + let script = r#" +export function main(x: number, y: number): number { + return x + y; +} +"#; + let results = run_prewarmed_test(script, vec![serde_json::json!({"x": 2, "y": 3})]).await; + + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(5))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_prewarmed_isolation() { + let script = r#" +let counter = 0; +export function main(): number { + counter++; + return counter; +} +"#; + let results = + run_prewarmed_test(script, vec![serde_json::json!({}), serde_json::json!({})]).await; + + assert_eq!(results.len(), 2); + // Each job gets a fresh isolate, so counter should be 1 both times + assert_eq!(results[0], Ok(serde_json::json!(1))); + assert_eq!(results[1], Ok(serde_json::json!(1))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_prewarmed_error() { + let script = r#" +export function main(msg: string): never { + throw new Error(msg); +} +"#; + let results = + run_prewarmed_test(script, vec![serde_json::json!({"msg": "test error"})]).await; + + assert_eq!(results.len(), 1); + assert!(results[0].is_err()); + assert!( + results[0].as_ref().unwrap_err().contains("test error"), + "Error should contain 'test error', got: {}", + results[0].as_ref().unwrap_err() + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_prewarmed_async() { + let script = r#" +export async function main(x: number): Promise { + const val = await Promise.resolve(x * 10); + return val + 1; +} +"#; + let results = run_prewarmed_test(script, vec![serde_json::json!({"x": 7})]).await; + + assert_eq!(results.len(), 1); + assert_eq!(results[0], Ok(serde_json::json!(71))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_prewarmed_pipeline() { + windmill_runtime_nativets::setup_deno_runtime().expect("V8 init failed"); + + let script = r#" +export function main(n: number): number { + return n * 2; +} +"#; + let (_ts, js, arg_names) = bundle_script(script).await; + let ann = default_annotation(); + + // Pre-warm first isolate + let mut warm = + PrewarmedIsolate::spawn("".to_string(), js.clone(), ann.clone(), arg_names.clone()); + warm.wait_ready() + .await + .expect("first isolate failed to warm"); + + let mut results = Vec::new(); + + for i in 1..=3 { + let args = serde_json::to_string(&serde_json::json!({"n": i})).unwrap(); + let executing = warm.start_execution(args); + + // Pipeline: start pre-warming next isolate while current one runs + warm = + PrewarmedIsolate::spawn("".to_string(), js.clone(), ann.clone(), arg_names.clone()); + + let prewarmed_result = executing.wait().await.expect("isolate execution failed"); + match prewarmed_result.result { + Ok(raw) => { + let value: serde_json::Value = + serde_json::from_str(raw.get()).unwrap_or(serde_json::Value::Null); + results.push(value); + } + Err(e) => panic!("unexpected error: {e}"), + } + + warm.wait_ready() + .await + .expect("next isolate failed to warm"); + } + + assert_eq!( + results, + vec![ + serde_json::json!(2), + serde_json::json!(4), + serde_json::json!(6), + ] + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_prewarmed_complex_return() { + let script = r#" +export function main(name: string, items: number[]): any { + return { + greeting: `hello ${name}`, + sum: items.reduce((a, b) => a + b, 0), + items: items.map(x => x * 2), + }; +} +"#; + let results = run_prewarmed_test( + script, + vec![serde_json::json!({"name": "world", "items": [1, 2, 3]})], + ) + .await; + + assert_eq!(results.len(), 1); + assert_eq!( + results[0], + Ok(serde_json::json!({ + "greeting": "hello world", + "sum": 6, + "items": [2, 4, 6], + })) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_prewarmed_null_undefined() { + let script = r#" +export function main(returnNull: boolean): any { + if (returnNull) { + return null; + } + return undefined; +} +"#; + let results = run_prewarmed_test( + script, + vec![ + serde_json::json!({"returnNull": true}), + serde_json::json!({"returnNull": false}), + ], + ) + .await; + + assert_eq!(results.len(), 2); + assert_eq!(results[0], Ok(serde_json::Value::Null)); + assert_eq!(results[1], Ok(serde_json::Value::Null)); + } +} diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index d084a0177c..9548986a5a 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -993,6 +993,80 @@ echo "hello $msg" Ok(()) } +#[sqlx::test(fixtures("base", "wmill_cli_test"))] +async fn test_bash_wmill_variable_get(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The bash script uses wmill CLI to get the variable value. + // The worker sets WM_TOKEN, WM_WORKSPACE, and BASE_INTERNAL_URL as env vars, + // and the CLI auto-configures from them when no workspace is explicitly set. + // We point WMILL_CONFIG_DIR to a clean temp dir so no local active workspace interferes. + let content = r#" +export WMILL_CONFIG_DIR=$(mktemp -d) +result=$(wmill variable get "u/test-user/test_var" --json | jq -r .value) +echo "$result" +"# + .to_owned(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content, + path: None, + lock: None, + language: ScriptLang::Bash, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + })) + .run_until_complete(&db, false, port) + .await; + assert_eq!(job.json_result(), Some(json!("hello from variable"))); + Ok(()) +} + +#[sqlx::test(fixtures("base", "wmill_cli_test"))] +async fn test_bash_wmill_resource_get(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The bash script uses wmill CLI to get the resource value. + // We point WMILL_CONFIG_DIR to a clean temp dir so no local active workspace interferes. + let content = r#" +export WMILL_CONFIG_DIR=$(mktemp -d) +result=$(wmill resource get "u/test-user/test_res" --json | jq -c .value) +echo "$result" +"# + .to_owned(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content, + path: None, + lock: None, + language: ScriptLang::Bash, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + })) + .run_until_complete(&db, false, port) + .await; + // Bash echo outputs are returned as strings, so the JSON is a string value + assert_eq!( + job.json_result(), + Some(json!("{\"host\":\"localhost\",\"port\":5432}")) + ); + Ok(()) +} + #[cfg(feature = "nu")] #[sqlx::test(fixtures("base"))] async fn test_nu_job(db: Pool) -> anyhow::Result<()> { @@ -1592,6 +1666,66 @@ export async function main(a: Date) { Ok(()) } +/// Test that full .npmrc content works for deno jobs with private registries. +/// Requires: +/// - `TEST_NPMRC` environment variable set to the full .npmrc content +#[cfg(feature = "private_registry_test")] +#[sqlx::test(fixtures("base"))] +async fn test_deno_job_private_npmrc(db: Pool) -> anyhow::Result<()> { + use windmill_worker::NPMRC; + + let npmrc_content = std::env::var("TEST_NPMRC") + .expect("TEST_NPMRC must be set when running private_registry_test"); + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + { + let mut npmrc = NPMRC.write().await; + *npmrc = Some(npmrc_content.clone()); + } + + let content = r#" +import { greet } from "npm:@windmill-test/private-pkg"; + +export function main(name: string) { + return greet(name); +} +"# + .to_owned(); + + let result = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Deno, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + })) + .arg("name", json!("World")) + .run_until_complete(&db, false, port) + .await + .json_result() + .unwrap(); + + { + let mut npmrc = NPMRC.write().await; + *npmrc = None; + } + + assert_eq!( + result, + serde_json::json!("Hello from private package, World!") + ); + Ok(()) +} + #[cfg(feature = "python")] #[sqlx::test(fixtures("base"))] async fn test_python_job_datetime_and_bytes(db: Pool) -> anyhow::Result<()> { diff --git a/backend/windmill-api-agent-workers/Cargo.toml b/backend/windmill-api-agent-workers/Cargo.toml index 667c592e47..feabde8b12 100644 --- a/backend/windmill-api-agent-workers/Cargo.toml +++ b/backend/windmill-api-agent-workers/Cargo.toml @@ -13,7 +13,7 @@ default = [] enterprise = ["windmill-common/enterprise", "windmill-queue/enterprise"] private = ["windmill-common/private", "windmill-queue/private"] python = ["dep:windmill-parser-py-imports"] -benchmark = [] +benchmark = ["windmill-queue/benchmark"] [dependencies] windmill-api-auth.workspace = true diff --git a/backend/windmill-api-inputs/src/lib.rs b/backend/windmill-api-inputs/src/lib.rs index 501d28d1db..2973085fe1 100644 --- a/backend/windmill-api-inputs/src/lib.rs +++ b/backend/windmill-api-inputs/src/lib.rs @@ -6,7 +6,6 @@ * LICENSE-AGPL for a copy of the license. */ -use windmill_api_auth::ApiAuthed; use axum::{ extract::{Path, Query}, routing::{get, post}, @@ -20,6 +19,7 @@ use std::{ fmt::{Display, Formatter}, vec, }; +use windmill_api_auth::ApiAuthed; use windmill_common::{ db::UserDB, error::JsonResult, @@ -109,7 +109,7 @@ pub struct Input { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct CompletedJobMini { id: Uuid, - created_at: chrono::DateTime, + completed_at: chrono::DateTime, args: Option>>, created_by: String, success: bool, @@ -147,9 +147,9 @@ async fn get_input_history( }; let sql = &format!( - "select id, v2_job.created_at, created_by, 'null'::jsonb as args, status = 'success' as success from v2_job JOIN v2_job_completed USING (id) \ + "select id, v2_job_completed.completed_at, created_by, 'null'::jsonb as args, status = 'success' as success from v2_job JOIN v2_job_completed USING (id) \ where v2_job.workspace_id = $3 and {} = $1 and kind = any($2) {args_query} AND v2_job_completed.status != 'skipped' {include_non_root} \ - order by v2_job.created_at desc limit $4 offset $5", + order by v2_job_completed.completed_at desc limit $4 offset $5", r.runnable_type.column_name(), ); @@ -189,10 +189,10 @@ async fn get_input_history( id: row.id, name: format!( "{} {}", - row.created_at.format("%H:%M %-d/%-m"), + row.completed_at.format("%H:%M %-d/%-m"), row.created_by ), - created_at: row.created_at, + created_at: row.completed_at, args: sqlx::types::Json( serde_json::value::RawValue::from_string("null".to_string()).unwrap(), ), @@ -352,11 +352,12 @@ async fn update_input( ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; - sqlx::query("UPDATE input SET name = $1, is_public = $2 WHERE id = $3 and workspace_id = $4") + sqlx::query("UPDATE input SET name = $1, is_public = $2 WHERE id = $3 and workspace_id = $4 AND created_by = $5") .bind(&input.name) .bind(&input.is_public) .bind(&input.id) .bind(&w_id) + .bind(&authed.username) .execute(&mut *tx) .await?; @@ -372,9 +373,10 @@ async fn delete_input( ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; - sqlx::query("DELETE FROM input WHERE id = $1 and workspace_id = $2") + sqlx::query("DELETE FROM input WHERE id = $1 and workspace_id = $2 AND created_by = $3") .bind(&i_id) .bind(&w_id) + .bind(&authed.username) .execute(&mut *tx) .await?; diff --git a/backend/windmill-api-npm-proxy/Cargo.toml b/backend/windmill-api-npm-proxy/Cargo.toml index c5852090ce..4cdae773fe 100644 --- a/backend/windmill-api-npm-proxy/Cargo.toml +++ b/backend/windmill-api-npm-proxy/Cargo.toml @@ -13,6 +13,7 @@ windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } axum.workspace = true flate2.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true sqlx.workspace = true diff --git a/backend/windmill-api-npm-proxy/src/lib.rs b/backend/windmill-api-npm-proxy/src/lib.rs index 6bcffe0d0a..310142598f 100644 --- a/backend/windmill-api-npm-proxy/src/lib.rs +++ b/backend/windmill-api-npm-proxy/src/lib.rs @@ -14,8 +14,10 @@ use std::collections::HashMap; use tower_http::cors::{Any, CorsLayer}; use windmill_common::{ error::{Error, JsonResult, Result}, - global_settings::{load_value_from_global_settings, NPM_CONFIG_REGISTRY_SETTING}, - utils::StripPath, + global_settings::{ + load_value_from_global_settings, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, + }, + utils::{parse_npmrc_registry, StripPath}, }; use windmill_api_auth::ApiAuthed; @@ -129,6 +131,14 @@ pub fn workspaced_service() -> Router { ) } +fn build_registry_request(url: &str, auth_token: &Option) -> reqwest::RequestBuilder { + let mut req = HTTP_CLIENT.get(url); + if let Some(token) = auth_token { + req = req.bearer_auth(token); + } + req +} + /// Get package metadata (versions and tags) from the private registry async fn get_package_metadata( _authed: ApiAuthed, @@ -136,21 +146,14 @@ async fn get_package_metadata( Extension(db): Extension>, ) -> JsonResult { let package = parse_package_name(package_path.to_path()); - let npm_registry = get_npm_registry(&db).await?; - - if npm_registry.is_none() { - return Err(Error::BadRequest( - "No private npm registry configured".to_string(), - )); - } - - let registry_url = npm_registry.unwrap(); + let (registry_url, auth_token) = get_npm_registry(&db) + .await? + .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; let package_url = format_registry_url(®istry_url, &package, None, None); tracing::info!("Fetching package metadata from: {}", package_url); - let response = HTTP_CLIENT - .get(&package_url) + let response = build_registry_request(&package_url, &auth_token) .send() .await .map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?; @@ -167,7 +170,6 @@ async fn get_package_metadata( .await .map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?; - // Extract versions and dist-tags from the package metadata let mut versions = Vec::new(); let mut tags = HashMap::new(); @@ -194,22 +196,15 @@ async fn resolve_package_version( Extension(db): Extension>, ) -> JsonResult { let package = parse_package_name(package_path.to_path()); - let npm_registry = get_npm_registry(&db).await?; - - if npm_registry.is_none() { - return Err(Error::BadRequest( - "No private npm registry configured".to_string(), - )); - } - - let registry_url = npm_registry.unwrap(); + let (registry_url, auth_token) = get_npm_registry(&db) + .await? + .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; let reference = query.tag.unwrap_or_else(|| "latest".to_string()); let package_url = format_registry_url(®istry_url, &package, None, None); tracing::info!("Resolving package version from: {}", package_url); - let response = HTTP_CLIENT - .get(&package_url) + let response = build_registry_request(&package_url, &auth_token) .send() .await .map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?; @@ -256,21 +251,14 @@ async fn get_package_filetree( Extension(db): Extension>, ) -> JsonResult { let (package, version) = parse_package_and_version(package_version_path.to_path())?; - let npm_registry = get_npm_registry(&db).await?; - - if npm_registry.is_none() { - return Err(Error::BadRequest( - "No private npm registry configured".to_string(), - )); - } - - let registry_url = npm_registry.unwrap(); + let (registry_url, auth_token) = get_npm_registry(&db) + .await? + .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; let package_url = format_registry_url(®istry_url, &package, None, None); tracing::info!("Fetching package filetree from: {}", package_url); - let response = HTTP_CLIENT - .get(&package_url) + let response = build_registry_request(&package_url, &auth_token) .send() .await .map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?; @@ -287,7 +275,6 @@ async fn get_package_filetree( .await .map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?; - // Get the tarball URL for this version let tarball_url = package_json .get("versions") .and_then(|v| v.get(&version)) @@ -296,9 +283,7 @@ async fn get_package_filetree( .and_then(|t| t.as_str()) .ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?; - // Download and extract tarball to get file list - let tarball_response = HTTP_CLIENT - .get(tarball_url) + let tarball_response = build_registry_request(tarball_url, &auth_token) .send() .await .map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?; @@ -337,21 +322,14 @@ async fn get_package_file( Extension(db): Extension>, ) -> Result { let (package, version, filepath) = parse_package_version_and_file(full_path.to_path())?; - let npm_registry = get_npm_registry(&db).await?; - - if npm_registry.is_none() { - return Err(Error::BadRequest( - "No private npm registry configured".to_string(), - )); - } - - let registry_url = npm_registry.unwrap(); + let (registry_url, auth_token) = get_npm_registry(&db) + .await? + .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; let package_url = format_registry_url(®istry_url, &package, None, None); tracing::info!("Fetching package file from: {}", package_url); - let response = HTTP_CLIENT - .get(&package_url) + let response = build_registry_request(&package_url, &auth_token) .send() .await .map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?; @@ -368,7 +346,6 @@ async fn get_package_file( .await .map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?; - // Get the tarball URL for this version let tarball_url = package_json .get("versions") .and_then(|v| v.get(&version)) @@ -377,9 +354,7 @@ async fn get_package_file( .and_then(|t| t.as_str()) .ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?; - // Download tarball - let tarball_response = HTTP_CLIENT - .get(tarball_url) + let tarball_response = build_registry_request(tarball_url, &auth_token) .send() .await .map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?; @@ -402,13 +377,38 @@ async fn get_package_file( Ok(file_content) } -/// Get the npm registry URL from global settings -async fn get_npm_registry(db: &sqlx::Pool) -> Result> { +/// Get the npm registry URL and optional auth token from global settings. +/// Checks the `npmrc` setting first, then falls back to `npm_config_registry`. +async fn get_npm_registry( + db: &sqlx::Pool, +) -> Result)>> { + let npmrc = load_value_from_global_settings(db, NPMRC_SETTING) + .await? + .and_then(|v| v.as_str().map(|s| s.to_string())); + + if let Some(ref npmrc_content) = npmrc { + if let Some(parsed) = parse_npmrc_registry(npmrc_content) { + return Ok(Some(parsed)); + } + } + let registry = load_value_from_global_settings(db, NPM_CONFIG_REGISTRY_SETTING) .await? .and_then(|v| v.as_str().map(|s| s.to_string())); - Ok(registry) + if let Some(ref s) = registry { + let (url, token) = if s.contains(":_authToken=") { + let parts: Vec<&str> = s.split(":_authToken=").collect(); + let url = parts[0].to_string(); + let token = parts.get(1).map(|t| t.to_string()); + (url, token) + } else { + (s.clone(), None) + }; + return Ok(Some((url, token))); + } + + Ok(None) } /// Format a registry URL for a package diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 5478231102..a58a760372 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -43,8 +43,9 @@ use windmill_common::{ get_database_url, global_settings::{ APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, - CRITICAL_ALERT_MUTE_UI_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, - HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, + CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, + EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, + HUB_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, @@ -512,6 +513,7 @@ pub async fn get_global_setting( && !key.starts_with("default_recovery_handler_") && !key.starts_with("default_success_handler_") && key != AUTOMATE_USERNAME_CREATION_SETTING + && key != DEFAULT_TAGS_WORKSPACES_SETTING && key != HUB_BASE_URL_SETTING && key != HUB_ACCESSIBLE_URL_SETTING && key != EMAIL_DOMAIN_SETTING diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 93f44f51ce..27e535f8bf 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.638.4 + version: 1.642.0 title: Windmill API contact: @@ -19671,6 +19671,8 @@ components: type: boolean lock: type: string + flow_path: + type: string required: - args diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 960ad29d71..4fc388ce62 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -42,8 +42,6 @@ use windmill_common::runnable_settings::{ }; #[cfg(feature = "inline_preview")] use windmill_common::runtime_assets::{register_runtime_asset, InsertRuntimeAssetParams}; -use windmill_types::s3::BundleFormat; -use windmill_object_store::upload_artifact_to_store; use windmill_common::scripts::ScriptRunnableSettingsInline; use windmill_common::triggers::TriggerMetadata; use windmill_common::utils::{RunnableKind, WarnAfterExt}; @@ -54,8 +52,10 @@ use windmill_common::workspace_dependencies::{ use windmill_common::DYNAMIC_INPUT_CACHE; #[cfg(all(feature = "enterprise", feature = "smtp"))] use windmill_common::{email_oss::send_email_html, server::load_smtp_config}; +use windmill_object_store::upload_artifact_to_store; #[cfg(feature = "inline_preview")] use windmill_parser::asset_parser::AssetKind; +use windmill_types::s3::BundleFormat; #[cfg(feature = "inline_preview")] use windmill_worker::get_worker_internal_server_inline_utils; @@ -1386,7 +1386,8 @@ async fn get_logs_from_store( log_file_index: &Option>, ) -> Option> { use futures::StreamExt; - let stream = windmill_object_store::get_logs_from_store(log_offset, logs, log_file_index).await?; + let stream = + windmill_object_store::get_logs_from_store(log_offset, logs, log_file_index).await?; let header = bytes::Bytes::from( r#"to remove ansi colors, use: | sed 's/\x1B\[[0-9;]\{1,\}[A-Za-z]//g' "# @@ -2849,6 +2850,7 @@ struct Preview { dedicated_worker: Option, lock: Option, format: Option, + flow_path: Option, } #[cfg(feature = "inline_preview")] @@ -4509,6 +4511,14 @@ async fn run_preview_script( check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let preview_args = preview.args.unwrap_or_default(); + let flow_path_extra = preview.flow_path.map(|fp| { + let mut extra = HashMap::new(); + extra.insert("_FLOW_PATH".to_string(), to_raw_value(&fp)); + extra + }); + let push_args = PushArgs { extra: flow_path_extra, args: &preview_args }; + let (uuid, tx) = push( &db, tx, @@ -4532,7 +4542,7 @@ async fn run_preview_script( dedicated_worker: preview.dedicated_worker, }), }, - PushArgs::from(&preview.args.unwrap_or_default()), + push_args, authed.display_username(), &authed.email, username_to_permissioned_as(&authed.username), @@ -5772,7 +5782,9 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R #[cfg(all(feature = "enterprise", feature = "parquet"))] if let Some(os) = windmill_object_store::get_object_store().await { let file = os - .get(&windmill_object_store::object_store_reexports::Path::from(format!("logs/{file_p}"))) + .get(&windmill_object_store::object_store_reexports::Path::from( + format!("logs/{file_p}"), + )) .await; if let Ok(file) = file { if let Ok(bytes) = file.bytes().await { diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 712ae50098..3347127303 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -10,6 +10,7 @@ pub const REQUEST_SIZE_LIMIT_SETTING: &str = "request_size_limit_mb"; pub const LICENSE_KEY_SETTING: &str = "license_key"; pub const NPM_CONFIG_REGISTRY_SETTING: &str = "npm_config_registry"; pub const BUNFIG_INSTALL_SCOPES_SETTING: &str = "bunfig_install_scopes"; +pub const NPMRC_SETTING: &str = "npmrc"; pub const NUGET_CONFIG_SETTING: &str = "nuget_config"; pub const POWERSHELL_REPO_URL_SETTING: &str = "powershell_repo_url"; pub const POWERSHELL_REPO_PAT_SETTING: &str = "powershell_repo_pat"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 7270577e88..89df71824f 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -261,6 +261,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub bunfig_install_scopes: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub npmrc: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub nuget_config: Option, #[serde(skip_serializing_if = "Option::is_none")] pub maven_repos: Option, @@ -774,7 +776,11 @@ pub const PROTECTED_SETTINGS: &[&str] = &[ /// Note: jwt_secret is intentionally NOT hidden — it is included in YAML exports so that /// operators can set it via ConfigMap. It is protected from deletion (PROTECTED_SETTINGS) /// and from being set to empty/null, and its value is partially redacted in log output. -pub const HIDDEN_SETTINGS: &[&str] = &["uid", "min_keep_alive_version", "automate_username_creation"]; +pub const HIDDEN_SETTINGS: &[&str] = &[ + "uid", + "min_keep_alive_version", + "automate_username_creation", +]; /// Top-level settings whose entire value is sensitive and must be fully redacted in logs. const SENSITIVE_SETTINGS: &[&str] = &[ @@ -788,6 +794,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[ "pip_extra_index_url", "npm_config_registry", "bunfig_install_scopes", + "npmrc", "maven_repos", "ruby_repos", "powershell_repo_pat", @@ -798,7 +805,10 @@ const SENSITIVE_SETTINGS: &[&str] = &[ const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[ ("smtp_settings", &["smtp_password"]), ("secret_backend", &["token"]), - ("object_store_cache_config", &["secret_key", "serviceAccountKey"]), + ( + "object_store_cache_config", + &["secret_key", "serviceAccountKey"], + ), ]; fn redact_json_value(value: &serde_json::Value) -> serde_json::Value { @@ -2353,7 +2363,11 @@ mod tests { ); let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge); - assert_eq!(diff.upserts.len(), 1, "Same client with newer expiry should update even with different signature"); + assert_eq!( + diff.upserts.len(), + 1, + "Same client with newer expiry should update even with different signature" + ); } #[test] diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index ee9c25a3e5..db8b5916cf 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -404,30 +404,6 @@ impl PgDatabase { ) } - pub fn to_conn_str(&self) -> String { - format!( - "dbname={dbname} {user} host={host} {password} {port} {sslmode}", - dbname = self.dbname, - user = self - .user - .as_ref() - .map(|u| format!("user={}", urlencoding::encode(u))) - .unwrap_or_default(), - host = self.host, - password = self - .password - .as_ref() - .map(|p| format!("password={}", urlencoding::encode(p))) - .unwrap_or_default(), - port = self.port.map(|p| format!("port={}", p)).unwrap_or_default(), - sslmode = self - .sslmode - .as_ref() - .map(|s| format!("sslmode={}", s.clone())) - .unwrap_or_default(), - ) - } - pub async fn connect( &self, ) -> Result<(tokio_postgres::Client, TokioPgConnection), error::Error> { diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 0c5f4b4a23..2fec2f9e94 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -1281,3 +1281,96 @@ mod tests { assert_eq!(parsed, serde_json::json!([[1], [2], [3], [4], [5]])); } } + +/// Parse .npmrc content to extract the default registry URL and its auth token. +/// Returns `Some((registry_url, Option))` if a default registry is found. +pub fn parse_npmrc_registry(npmrc_content: &str) -> Option<(String, Option)> { + let mut registry_url: Option = None; + let mut auth_tokens: Vec<(String, String)> = Vec::new(); + + for line in npmrc_content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + + if let Some(url) = line.strip_prefix("registry=") { + registry_url = Some(url.trim().to_string()); + } + + if line.starts_with("//") { + if let Some((prefix, token)) = line.split_once(":_authToken=") { + auth_tokens.push((prefix.to_string(), token.to_string())); + } + } + } + + let url = registry_url?; + let url_without_protocol = url.trim_start_matches("https:").trim_start_matches("http:"); + let url_prefix = url_without_protocol.trim_end_matches('/'); + + let token = auth_tokens + .iter() + .find(|(prefix, _)| { + let p = prefix.trim_end_matches('/'); + p == url_prefix + }) + .map(|(_, token)| token.clone()); + + Some((url, token)) +} + +#[cfg(test)] +mod npmrc_tests { + use super::parse_npmrc_registry; + + #[test] + fn test_parse_simple_registry() { + let npmrc = "registry=https://registry.mycompany.com/\n//registry.mycompany.com/:_authToken=secret123\n"; + let result = parse_npmrc_registry(npmrc); + assert_eq!( + result, + Some(( + "https://registry.mycompany.com/".to_string(), + Some("secret123".to_string()) + )) + ); + } + + #[test] + fn test_parse_registry_without_auth() { + let npmrc = "registry=https://registry.npmjs.org/\n"; + let result = parse_npmrc_registry(npmrc); + assert_eq!( + result, + Some(("https://registry.npmjs.org/".to_string(), None)) + ); + } + + #[test] + fn test_parse_scoped_only_no_default() { + let npmrc = + "@myorg:registry=https://registry.myorg.com/\n//registry.myorg.com/:_authToken=tok\n"; + let result = parse_npmrc_registry(npmrc); + assert_eq!(result, None); + } + + #[test] + fn test_parse_with_comments() { + let npmrc = "# My registry\nregistry=https://r.example.com/\n; auth\n//r.example.com/:_authToken=tok\n"; + let result = parse_npmrc_registry(npmrc); + assert_eq!( + result, + Some(( + "https://r.example.com/".to_string(), + Some("tok".to_string()) + )) + ); + } + + #[test] + fn test_parse_empty_npmrc() { + assert_eq!(parse_npmrc_registry(""), None); + assert_eq!(parse_npmrc_registry("# just a comment"), None); + } +} diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index f80c78f244..1a2d55b896 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -427,7 +427,7 @@ fn format_pull_query(peek: String) -> String { j.same_worker, j.pre_run_error, j.visible_to_owner, j.tag, j.concurrent_limit, j.concurrency_time_window_s, j.flow_innermost_root_job, j.root_job, j.timeout, j.flow_step_id, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, j.priority, j.raw_code, j.raw_lock, j.raw_flow, - j.script_entrypoint_override, j.preprocessed, pj.runnable_path as parent_runnable_path, + j.script_entrypoint_override, j.preprocessed, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path, COALESCE(p.email, j.permissioned_as_email) as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin, p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email FROM q, j diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index aa9524fe6c..e0c499c355 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -576,7 +576,18 @@ pub async fn exchange_token( Ok(token) } -/// Refresh an OAuth token and update the database +/// Pre-fetched account fields needed for token refresh. +pub struct OAuthAccountInfo { + pub client: String, + pub refresh_token: String, + pub grant_type: String, + pub cc_client_id: Option, + pub cc_client_secret: Option, + pub cc_token_url: Option, +} + +/// Refresh an OAuth token and update the database. +/// Fetches the account from DB, then delegates to `refresh_token_for_account`. pub async fn refresh_token<'c>( mut tx: Transaction<'c, Postgres>, path: &str, @@ -587,7 +598,8 @@ pub async fn refresh_token<'c>( http_client: &reqwest::Client, connect_configs_json: &str, ) -> error::Result { - let account = sqlx::query!( + let account = sqlx::query_as!( + OAuthAccountInfo, "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url FROM account WHERE workspace_id = $1 AND id = $2", w_id, id, @@ -595,6 +607,22 @@ pub async fn refresh_token<'c>( .fetch_optional(&mut *tx) .await?; let account = windmill_common::utils::not_found_if_none(account, "Account", &id.to_string())?; + + refresh_token_for_account(tx, path, w_id, id, db, account, oauth_clients, http_client, connect_configs_json).await +} + +/// Refresh an OAuth token given pre-fetched account info (no additional SELECT). +pub async fn refresh_token_for_account<'c>( + mut tx: Transaction<'c, Postgres>, + path: &str, + w_id: &str, + id: i32, + db: &DB, + account: OAuthAccountInfo, + oauth_clients: &AllClients, + http_client: &reqwest::Client, + connect_configs_json: &str, +) -> error::Result { let oauth_client_info = oauth_clients .connects .get(&account.client) diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index eaa75f04c1..007b398c79 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -5321,7 +5321,11 @@ async fn push_inner<'c, 'd>( .as_ref() .map(|x| { let tag_lang = if x == &ScriptLang::Bunnative { - ScriptLang::Nativets.as_str() + if job_kind == JobKind::Dependencies { + ScriptLang::Bun.as_str() + } else { + ScriptLang::Nativets.as_str() + } } else { x.as_str() }; @@ -5364,7 +5368,6 @@ async fn push_inner<'c, 'd>( job_id, &args, &mut tx, - _db, ) .await? } @@ -6204,7 +6207,7 @@ pub async fn get_same_worker_job( v2_job.raw_code, v2_job.raw_lock, v2_job.raw_flow, - pj.runnable_path as parent_runnable_path, + COALESCE(pj.runnable_path, v2_job.args->>'_FLOW_PATH') as parent_runnable_path, p.email as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin, p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email FROM v2_job_queue diff --git a/backend/windmill-queue/tests/debounce_test.rs b/backend/windmill-queue/tests/debounce_test.rs new file mode 100644 index 0000000000..9dec28a7ee --- /dev/null +++ b/backend/windmill-queue/tests/debounce_test.rs @@ -0,0 +1,3079 @@ +//! Tests for debouncing logic: both normal (push-time) and post-preprocessing debouncing. +//! +//! Run with: +//! cargo test -p windmill-queue --test debounce_test --features private,enterprise -- --nocapture +//! +//! Requires a live database (migrations are applied automatically by sqlx::test). + +#[cfg(feature = "private")] +mod debounce { + use chrono::Utc; + use serde_json::value::RawValue; + use sqlx::{Pool, Postgres}; + use std::collections::HashMap; + use uuid::Uuid; + use windmill_common::jobs::JobKind; + use windmill_common::runnable_settings::DebouncingSettings; + use windmill_queue::PushArgs; + + /// Helper: insert a minimal job into v2_job + v2_job_queue + v2_job_runtime so debounce can find it. + async fn insert_noop_job(db: &Pool, job_id: Uuid, workspace_id: &str) { + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id) + VALUES ($1, 'noop', 'deno', 'test-user', 'u/test-user', 'test@windmill.dev', $2)", + job_id, + workspace_id, + ) + .execute(db) + .await + .expect("insert v2_job"); + + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + VALUES ($1, $2, now(), 'deno')", + job_id, + workspace_id, + ) + .execute(db) + .await + .expect("insert v2_job_queue"); + + sqlx::query!("INSERT INTO v2_job_runtime (id) VALUES ($1)", job_id,) + .execute(db) + .await + .expect("insert v2_job_runtime"); + } + + /// Helper: insert a flow job into v2_job + v2_job_queue + v2_job_runtime. + async fn insert_flow_job( + db: &Pool, + job_id: Uuid, + workspace_id: &str, + runnable_path: &str, + ) { + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path) + VALUES ($1, 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', $2, $3)", + job_id, + workspace_id, + runnable_path, + ) + .execute(db) + .await + .expect("insert v2_job"); + + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + VALUES ($1, $2, now(), 'flow')", + job_id, + workspace_id, + ) + .execute(db) + .await + .expect("insert v2_job_queue"); + + sqlx::query!("INSERT INTO v2_job_runtime (id) VALUES ($1)", job_id,) + .execute(db) + .await + .expect("insert v2_job_runtime"); + } + + /// Helper: check if a job is completed (exists in v2_job_completed). + async fn is_completed(db: &Pool, job_id: &Uuid) -> bool { + sqlx::query_scalar!("SELECT 1 as x FROM v2_job_completed WHERE id = $1", job_id,) + .fetch_optional(db) + .await + .expect("check completed") + .is_some() + } + + /// Helper: check if a job is still in the queue. + async fn is_queued(db: &Pool, job_id: &Uuid) -> bool { + sqlx::query_scalar!("SELECT 1 as x FROM v2_job_queue WHERE id = $1", job_id,) + .fetch_optional(db) + .await + .expect("check queued") + .is_some() + } + + /// Helper: get the debounce_key entry for a given key. + async fn get_debounce_key(db: &Pool, key: &str) -> Option<(Uuid, Option, i32)> { + sqlx::query!( + "SELECT job_id, previous_job_id, debounced_times FROM debounce_key WHERE key = $1", + key, + ) + .fetch_optional(db) + .await + .expect("get debounce_key") + .map(|r| (r.job_id, r.previous_job_id, r.debounced_times)) + } + + fn empty_args() -> HashMap> { + HashMap::new() + } + + // ========================================================================= + // Tests for maybe_debounce (push-time debouncing) + // ========================================================================= + + /// Test: First job in a debounce batch should set scheduled_for and create debounce_key entry. + /// No previous job should be debounced. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_debounce_first_job(db: Pool) -> anyhow::Result<()> { + let job_id = Uuid::new_v4(); + insert_noop_job(&db, job_id, "test-workspace").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("test_first_job_key".to_string()), + ..Default::default() + }; + + let mut scheduled_for = None; + let args_hm = empty_args(); + let args = PushArgs::from(&args_hm); + let mut tx = db.begin().await?; + + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + job_id, + &args, + &mut tx, + ) + .await?; + + tx.commit().await?; + + // scheduled_for should be set to now + 5 seconds + assert!(scheduled_for.is_some(), "scheduled_for should be set"); + let sf = scheduled_for.unwrap(); + let diff = (sf - Utc::now()).num_seconds(); + assert!( + diff >= 3 && diff <= 6, + "scheduled_for should be ~5s in the future, got {diff}s" + ); + + // debounce_key entry should exist with this job + let dk = get_debounce_key(&db, "test_first_job_key").await; + assert!(dk.is_some(), "debounce_key entry should exist"); + let (dk_job_id, dk_prev, dk_times) = dk.unwrap(); + assert_eq!(dk_job_id, job_id); + assert!(dk_prev.is_none(), "no previous job for first in batch"); + assert_eq!(dk_times, 0, "debounced_times should be 0 for first job"); + + // Job should still be in queue (not debounced) + assert!( + is_queued(&db, &job_id).await, + "first job should still be queued" + ); + assert!( + !is_completed(&db, &job_id).await, + "first job should not be completed" + ); + + Ok(()) + } + + /// Test: Second job with the same debounce key should debounce (complete) the first job. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_debounce_second_job_cancels_first(db: Pool) -> anyhow::Result<()> { + let job1 = Uuid::new_v4(); + let job2 = Uuid::new_v4(); + insert_noop_job(&db, job1, "test-workspace").await; + insert_noop_job(&db, job2, "test-workspace").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("test_cancel_key".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + + // Push job 1 + { + let args = PushArgs::from(&args_hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + job1, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + // Push job 2 with same key - should debounce job 1 + { + let args = PushArgs::from(&args_hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + job2, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + // job1 should be completed (debounced) + assert!( + is_completed(&db, &job1).await, + "job1 should be completed (debounced)" + ); + + // job2 should still be in queue + assert!(is_queued(&db, &job2).await, "job2 should still be in queue"); + + // debounce_key should point to job2 + let dk = get_debounce_key(&db, "test_cancel_key").await.unwrap(); + assert_eq!(dk.0, job2, "debounce_key should point to job2"); + assert_eq!(dk.2, 1, "debounced_times should be 1"); + + Ok(()) + } + + /// Test: 1000 jobs in sequence with the same debounce key — only the last should remain queued. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_debounce_chain_of_1000(db: Pool) -> anyhow::Result<()> { + let n: usize = 1000; + + // Batch-insert all jobs for speed + let jobs: Vec = (0..n).map(|_| Uuid::new_v4()).collect(); + for chunk in jobs.chunks(500) { + let chunk_vec: Vec = chunk.to_vec(); + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id) + SELECT unnest($1::uuid[]), 'noop', 'deno', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + SELECT unnest($1::uuid[]), 'test-workspace', now(), 'deno'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &chunk_vec, + ) + .execute(&db) + .await?; + } + + let settings = DebouncingSettings { + debounce_delay_s: Some(10), + debounce_key: Some("test_chain_1000_key".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + + for &j in &jobs { + let args = PushArgs::from(&args_hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + j, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + // Only the last job should remain in queue + let queued_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_queue WHERE id = ANY($1)", + &jobs, + ) + .fetch_one(&db) + .await?; + assert_eq!( + queued_count, 1, + "exactly 1 job should remain in queue, got {queued_count}" + ); + + // N-1 jobs should be completed (debounced) + let completed_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_completed WHERE id = ANY($1)", + &jobs, + ) + .fetch_one(&db) + .await?; + assert_eq!( + completed_count, + (n - 1) as i64, + "{} jobs should be completed (debounced), got {completed_count}", + n - 1 + ); + + // Last job should be the survivor + assert!( + is_queued(&db, &jobs[n - 1]).await, + "last job should still be queued" + ); + + let dk = get_debounce_key(&db, "test_chain_1000_key").await.unwrap(); + assert_eq!(dk.0, jobs[n - 1], "debounce_key should point to last job"); + assert_eq!(dk.2, (n - 1) as i32); + + Ok(()) + } + + /// Test: Different debounce keys should not interfere with each other. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_debounce_different_keys_independent(db: Pool) -> anyhow::Result<()> { + let job_a = Uuid::new_v4(); + let job_b = Uuid::new_v4(); + insert_noop_job(&db, job_a, "test-workspace").await; + insert_noop_job(&db, job_b, "test-workspace").await; + + let args_hm = empty_args(); + + // Push job_a with key "alpha" + { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("alpha".to_string()), + ..Default::default() + }; + let args = PushArgs::from(&args_hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script_a".to_string()), + "test-workspace", + JobKind::Noop, + job_a, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + // Push job_b with key "beta" + { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("beta".to_string()), + ..Default::default() + }; + let args = PushArgs::from(&args_hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script_b".to_string()), + "test-workspace", + JobKind::Noop, + job_b, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + // Both should still be queued since they have different keys + assert!(is_queued(&db, &job_a).await, "job_a should still be queued"); + assert!(is_queued(&db, &job_b).await, "job_b should still be queued"); + + Ok(()) + } + + /// Test: Debounce key with $args interpolation uses the args to build a unique key. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_debounce_key_with_args_interpolation(db: Pool) -> anyhow::Result<()> { + let job1 = Uuid::new_v4(); + let job2 = Uuid::new_v4(); + let job3 = Uuid::new_v4(); + insert_noop_job(&db, job1, "test-workspace").await; + insert_noop_job(&db, job2, "test-workspace").await; + insert_noop_job(&db, job3, "test-workspace").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("debounce_$args[tenant_id]".to_string()), + ..Default::default() + }; + + // job1: tenant_id = "A" + { + let mut hm = HashMap::new(); + hm.insert( + "tenant_id".to_string(), + RawValue::from_string("\"A\"".to_string()).unwrap(), + ); + let args = PushArgs::from(&hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + job1, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + // job2: tenant_id = "B" (different key) + { + let mut hm = HashMap::new(); + hm.insert( + "tenant_id".to_string(), + RawValue::from_string("\"B\"".to_string()).unwrap(), + ); + let args = PushArgs::from(&hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + job2, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + // job3: tenant_id = "A" (same key as job1, should debounce job1) + { + let mut hm = HashMap::new(); + hm.insert( + "tenant_id".to_string(), + RawValue::from_string("\"A\"".to_string()).unwrap(), + ); + let args = PushArgs::from(&hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + job3, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + // job1 should be debounced (same key as job3) + assert!( + is_completed(&db, &job1).await, + "job1 should be debounced by job3" + ); + // job2 should still be queued (different key) + assert!( + is_queued(&db, &job2).await, + "job2 should still be queued (different tenant)" + ); + // job3 should still be queued + assert!(is_queued(&db, &job3).await, "job3 should still be queued"); + + Ok(()) + } + + /// Test: When debounce_delay_s is 0 or None, no debouncing should occur. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_no_debounce_when_delay_zero(db: Pool) -> anyhow::Result<()> { + let job1 = Uuid::new_v4(); + let job2 = Uuid::new_v4(); + insert_noop_job(&db, job1, "test-workspace").await; + insert_noop_job(&db, job2, "test-workspace").await; + + let args_hm = empty_args(); + + // delay = 0 + { + let settings = DebouncingSettings { + debounce_delay_s: Some(0), + debounce_key: Some("no_debounce_zero".to_string()), + ..Default::default() + }; + let args = PushArgs::from(&args_hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + job1, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + assert!( + scheduled_for.is_none(), + "scheduled_for should not be set with delay=0" + ); + } + + // delay = None + { + let settings = DebouncingSettings { + debounce_delay_s: None, + debounce_key: Some("no_debounce_none".to_string()), + ..Default::default() + }; + let args = PushArgs::from(&args_hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + job2, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + assert!( + scheduled_for.is_none(), + "scheduled_for should not be set with delay=None" + ); + } + + // Both should still be queued + assert!(is_queued(&db, &job1).await); + assert!(is_queued(&db, &job2).await); + + Ok(()) + } + + /// Test: max_total_debounces_amount limit - debounce batch resets when exceeded. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_debounce_max_count_limit(db: Pool) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("count_limit_key".to_string()), + max_total_debounces_amount: Some(2), + ..Default::default() + }; + let args_hm = empty_args(); + + // Push 4 jobs: after the 3rd debounce (exceeding limit of 2), batch should reset + let mut jobs = Vec::new(); + for _ in 0..4 { + let job_id = Uuid::new_v4(); + insert_noop_job(&db, job_id, "test-workspace").await; + jobs.push(job_id); + } + + for &j in &jobs { + let args = PushArgs::from(&args_hm); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &Some("f/test/script".to_string()), + "test-workspace", + JobKind::Noop, + j, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + } + + // The debounce_key entry should still exist + let dk = get_debounce_key(&db, "count_limit_key").await; + assert!(dk.is_some(), "debounce_key entry should exist"); + + Ok(()) + } + + // ========================================================================= + // Tests for maybe_debounce_post_preprocessing + // ========================================================================= + + /// Test: Post-preprocessing debounce with first job returns scheduled_for. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_debounce_first_job(db: Pool) -> anyhow::Result<()> { + let flow_id = Uuid::new_v4(); + insert_flow_job(&db, flow_id, "test-workspace", "f/test/flow").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_first_key".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + let args = PushArgs::from(&args_hm); + + let result = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_id, + &args, + &db, + ) + .await?; + + // Should return a scheduled_for value + assert!( + result.is_some(), + "should return scheduled_for for first job" + ); + let sf = result.unwrap(); + let diff = (sf - Utc::now()).num_seconds(); + assert!( + diff >= 3 && diff <= 6, + "scheduled_for should be ~5s in future, got {diff}s" + ); + + // debounce_key should be created + let dk = get_debounce_key(&db, "pp_first_key").await; + assert!(dk.is_some(), "debounce_key entry should exist"); + let (dk_job_id, _, _) = dk.unwrap(); + assert_eq!(dk_job_id, flow_id); + + Ok(()) + } + + /// Test: Post-preprocessing debounce with second job debounces the first. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_debounce_second_cancels_first( + db: Pool, + ) -> anyhow::Result<()> { + let flow1 = Uuid::new_v4(); + let flow2 = Uuid::new_v4(); + insert_flow_job(&db, flow1, "test-workspace", "f/test/flow").await; + insert_flow_job(&db, flow2, "test-workspace", "f/test/flow").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_cancel_key".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + + // First flow + { + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow1, + &args, + &db, + ) + .await?; + } + + // Second flow - should debounce the first + { + let args = PushArgs::from(&args_hm); + let result = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow2, + &args, + &db, + ) + .await?; + assert!(result.is_some(), "should return scheduled_for"); + } + + // flow1 should be completed (debounced) + assert!( + is_completed(&db, &flow1).await, + "flow1 should be completed (debounced by flow2)" + ); + + // flow2 should still be in queue + assert!(is_queued(&db, &flow2).await, "flow2 should still be queued"); + + // debounce_key should point to flow2 + let dk = get_debounce_key(&db, "pp_cancel_key").await.unwrap(); + assert_eq!(dk.0, flow2, "debounce_key should point to flow2"); + assert_eq!(dk.2, 1, "debounced_times should be 1"); + + Ok(()) + } + + /// Test: Post-preprocessing debounce with args-based key differentiates by preprocessed args. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_debounce_args_differentiation( + db: Pool, + ) -> anyhow::Result<()> { + let flow_a = Uuid::new_v4(); + let flow_b = Uuid::new_v4(); + let flow_a2 = Uuid::new_v4(); + insert_flow_job(&db, flow_a, "test-workspace", "f/test/flow").await; + insert_flow_job(&db, flow_b, "test-workspace", "f/test/flow").await; + insert_flow_job(&db, flow_a2, "test-workspace", "f/test/flow").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_$args[region]".to_string()), + ..Default::default() + }; + + // flow_a: region = "us" + { + let mut hm = HashMap::new(); + hm.insert( + "region".to_string(), + RawValue::from_string("\"us\"".to_string()).unwrap(), + ); + let args = PushArgs::from(&hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_a, + &args, + &db, + ) + .await?; + } + + // flow_b: region = "eu" (different key, no debounce) + { + let mut hm = HashMap::new(); + hm.insert( + "region".to_string(), + RawValue::from_string("\"eu\"".to_string()).unwrap(), + ); + let args = PushArgs::from(&hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_b, + &args, + &db, + ) + .await?; + } + + // flow_a2: region = "us" (same key as flow_a, should debounce flow_a) + { + let mut hm = HashMap::new(); + hm.insert( + "region".to_string(), + RawValue::from_string("\"us\"".to_string()).unwrap(), + ); + let args = PushArgs::from(&hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_a2, + &args, + &db, + ) + .await?; + } + + // flow_a should be debounced (same region as flow_a2) + assert!( + is_completed(&db, &flow_a).await, + "flow_a should be debounced by flow_a2" + ); + // flow_b should be queued (different region) + assert!( + is_queued(&db, &flow_b).await, + "flow_b should still be queued" + ); + // flow_a2 should be queued + assert!( + is_queued(&db, &flow_a2).await, + "flow_a2 should still be queued" + ); + + Ok(()) + } + + /// Test: Post-preprocessing debounce returns None when delay is zero. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_no_debounce_zero_delay( + db: Pool, + ) -> anyhow::Result<()> { + let flow_id = Uuid::new_v4(); + insert_flow_job(&db, flow_id, "test-workspace", "f/test/flow").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(0), + debounce_key: Some("pp_zero_delay".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + let args = PushArgs::from(&args_hm); + + let result = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_id, + &args, + &db, + ) + .await?; + + assert!(result.is_none(), "should return None when delay is 0"); + Ok(()) + } + + /// Test: Post-preprocessing debounce returns None when delay is None. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_no_debounce_no_delay( + db: Pool, + ) -> anyhow::Result<()> { + let flow_id = Uuid::new_v4(); + insert_flow_job(&db, flow_id, "test-workspace", "f/test/flow").await; + + let settings = DebouncingSettings::default(); + let args_hm = empty_args(); + let args = PushArgs::from(&args_hm); + + let result = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_id, + &args, + &db, + ) + .await?; + + assert!(result.is_none(), "should return None with default settings"); + Ok(()) + } + + /// Test: Post-preprocessing debounce chain of 1000 jobs — only the last should remain queued. + /// This verifies debouncing works correctly at scale with sequential debounce operations. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_debounce_chain_1000(db: Pool) -> anyhow::Result<()> { + let n: usize = 1000; + + // Batch-insert all jobs using raw SQL for speed + let uuids: Vec = (0..n).map(|_| Uuid::new_v4()).collect(); + for chunk in uuids.chunks(500) { + let chunk_vec: Vec = chunk.to_vec(); + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path) + SELECT unnest($1::uuid[]), 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace', 'f/test/flow'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + SELECT unnest($1::uuid[]), 'test-workspace', now(), 'flow'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &chunk_vec, + ) + .execute(&db) + .await?; + } + + let settings = DebouncingSettings { + debounce_delay_s: Some(10), + debounce_key: Some("pp_chain_1000_key".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + + for &j in &uuids { + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + j, + &args, + &db, + ) + .await?; + } + + // Only the last job should remain in queue + let queued_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_queue WHERE id = ANY($1)", + &uuids, + ) + .fetch_one(&db) + .await?; + assert_eq!( + queued_count, 1, + "exactly 1 job should remain in queue, got {queued_count}" + ); + + // N-1 jobs should be completed (debounced) + let completed_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_completed WHERE id = ANY($1)", + &uuids, + ) + .fetch_one(&db) + .await?; + assert_eq!( + completed_count, + (n - 1) as i64, + "n-1 jobs should be completed (debounced), got {completed_count}" + ); + + let dk = get_debounce_key(&db, "pp_chain_1000_key").await.unwrap(); + assert_eq!(dk.0, uuids[n - 1], "debounce_key should point to last job"); + assert_eq!(dk.2, (n - 1) as i32); + + Ok(()) + } + + /// Test: Post-preprocessing debounce with max count limit resets the batch. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_debounce_max_count_resets( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_max_count_key".to_string()), + max_total_debounces_amount: Some(2), + ..Default::default() + }; + let args_hm = empty_args(); + + // Push 4 jobs. After 3rd debounce (exceeding limit of 2), batch should reset. + let mut jobs = Vec::new(); + let mut results = Vec::new(); + for _ in 0..4 { + let id = Uuid::new_v4(); + insert_flow_job(&db, id, "test-workspace", "f/test/flow").await; + jobs.push(id); + } + + for &j in &jobs { + let args = PushArgs::from(&args_hm); + let result = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + j, + &args, + &db, + ) + .await?; + results.push(result); + } + + // First job always gets scheduled_for + assert!(results[0].is_some(), "first job should get scheduled_for"); + + // Jobs 2 and 3 should also get scheduled_for (debouncing within limit) + assert!(results[1].is_some(), "second job should get scheduled_for"); + assert!(results[2].is_some(), "third job should get scheduled_for"); + + // Job 4 (the one that exceeds the limit): when limit is exceeded, + // the batch resets and the job executes immediately (no scheduled_for delay) + // The exact behavior depends on whether the limit check happens before or after the + // new job is counted. Let's just verify the debounce_key is reset. + let dk = get_debounce_key(&db, "pp_max_count_key").await.unwrap(); + // debounced_times should have been reset at some point + assert!(dk.0 == jobs[3], "debounce_key should point to last job"); + + Ok(()) + } + + /// Test: 1000 concurrent debounce operations with different keys — no contention or deadlocks. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_concurrent_different_keys_1000( + db: Pool, + ) -> anyhow::Result<()> { + let n: usize = 1000; + + // Batch-insert all flow jobs upfront + let flow_ids: Vec = (0..n).map(|_| Uuid::new_v4()).collect(); + for chunk in flow_ids.chunks(500) { + let chunk_vec: Vec = chunk.to_vec(); + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path) + SELECT unnest($1::uuid[]), 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace', 'f/test/flow'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + SELECT unnest($1::uuid[]), 'test-workspace', now(), 'flow'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &chunk_vec, + ) + .execute(&db) + .await?; + } + + // Fire all debounce calls concurrently, each with a unique key + let mut handles = Vec::new(); + for (i, &flow_id) in flow_ids.iter().enumerate() { + let db = db.clone(); + let handle = tokio::spawn(async move { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some(format!("concurrent_key_{i}")), + ..Default::default() + }; + let args_hm: HashMap> = HashMap::new(); + let args = PushArgs::from(&args_hm); + + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_id, + &args, + &db, + ) + .await + }); + handles.push(handle); + } + + let mut error_count = 0; + for handle in handles { + match handle.await? { + Ok(result) => { + assert!(result.is_some(), "should return scheduled_for"); + } + Err(e) => { + eprintln!("Concurrent debounce error: {e:#}"); + error_count += 1; + } + } + } + assert_eq!(error_count, 0, "no errors expected, got {error_count}"); + + // All jobs should still be in queue (each has a unique key, no debouncing between them) + let queued_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_queue WHERE id = ANY($1)", + &flow_ids, + ) + .fetch_one(&db) + .await?; + assert_eq!( + queued_count, n as i64, + "all {n} jobs should remain in queue, got {queued_count}" + ); + + Ok(()) + } + + /// Test: 1000 concurrent debounce operations with the SAME key — verifies no deadlocks + /// and exactly 1 job survives in the queue. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_concurrent_same_key_1000( + db: Pool, + ) -> anyhow::Result<()> { + let n: usize = 1000; + + // Batch-insert all flow jobs upfront + let flow_ids: Vec = (0..n).map(|_| Uuid::new_v4()).collect(); + for chunk in flow_ids.chunks(500) { + let chunk_vec: Vec = chunk.to_vec(); + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path) + SELECT unnest($1::uuid[]), 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace', 'f/test/flow'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + SELECT unnest($1::uuid[]), 'test-workspace', now(), 'flow'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &chunk_vec, + ) + .execute(&db) + .await?; + } + + // Fire all debounce calls concurrently, all sharing the same key + let mut handles = Vec::new(); + for &flow_id in &flow_ids { + let db = db.clone(); + let handle = tokio::spawn(async move { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("shared_concurrent_key_1000".to_string()), + ..Default::default() + }; + let args_hm: HashMap> = HashMap::new(); + let args = PushArgs::from(&args_hm); + + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_id, + &args, + &db, + ) + .await + }); + handles.push(handle); + } + + let mut success_count = 0; + let mut error_count = 0; + for handle in handles { + match handle.await? { + Ok(_) => success_count += 1, + Err(e) => { + eprintln!("Concurrent debounce error: {e:#}"); + error_count += 1; + } + } + } + + assert_eq!(error_count, 0, "no errors expected, got {error_count}"); + assert_eq!(success_count, n, "all {n} debounce calls should succeed"); + + // Only 1 job should remain in queue, rest should be debounced + let queued_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_queue WHERE id = ANY($1)", + &flow_ids, + ) + .fetch_one(&db) + .await?; + assert_eq!( + queued_count, 1, + "exactly 1 job should remain in queue, got {queued_count}" + ); + + let completed_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_completed WHERE id = ANY($1)", + &flow_ids, + ) + .fetch_one(&db) + .await?; + assert_eq!( + completed_count, + (n - 1) as i64, + "{} jobs should be completed (debounced), got {completed_count}", + n - 1 + ); + + Ok(()) + } + + // ========================================================================= + // Edge case tests: timing, limits, batch behavior, scheduled_for + // ========================================================================= + + /// Test: scheduled_for is set to approximately now + delay_seconds. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_push_scheduled_for_value(db: Pool) -> anyhow::Result<()> { + let job_id = Uuid::new_v4(); + insert_noop_job(&db, job_id, "test-workspace").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(30), + debounce_key: Some("scheduled_for_test".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + let args = PushArgs::from(&args_hm); + + let before = Utc::now(); + let mut scheduled_for = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &None, + "test-workspace", + JobKind::Noop, + job_id, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + let after = Utc::now(); + + let sf = scheduled_for.expect("scheduled_for should be set"); + let expected_min = before + chrono::Duration::seconds(30); + let expected_max = after + chrono::Duration::seconds(30); + assert!( + sf >= expected_min && sf <= expected_max, + "scheduled_for ({sf}) should be between {expected_min} and {expected_max}" + ); + + Ok(()) + } + + /// Test: post-preprocessing scheduled_for is set to approximately now + delay_seconds. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_scheduled_for_value(db: Pool) -> anyhow::Result<()> { + let flow_id = Uuid::new_v4(); + insert_flow_job(&db, flow_id, "test-workspace", "f/test/flow").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(45), + debounce_key: Some("pp_scheduled_for_test".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + let args = PushArgs::from(&args_hm); + + let before = Utc::now(); + let result = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_id, + &args, + &db, + ) + .await?; + let after = Utc::now(); + + let sf = result.expect("should return scheduled_for"); + let expected_min = before + chrono::Duration::seconds(45); + let expected_max = after + chrono::Duration::seconds(45); + assert!( + sf >= expected_min && sf <= expected_max, + "scheduled_for ({sf}) should be between {expected_min} and {expected_max}" + ); + + Ok(()) + } + + /// Test: push-time does NOT set scheduled_for if one is already provided (uses .or()). + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_push_preserves_existing_scheduled_for(db: Pool) -> anyhow::Result<()> { + let job_id = Uuid::new_v4(); + insert_noop_job(&db, job_id, "test-workspace").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(30), + debounce_key: Some("preserve_sf_test".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + let args = PushArgs::from(&args_hm); + + let preset = Utc::now() + chrono::Duration::seconds(999); + let mut scheduled_for = Some(preset); + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &None, + "test-workspace", + JobKind::Noop, + job_id, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + + assert_eq!( + scheduled_for, + Some(preset), + "existing scheduled_for should be preserved" + ); + + Ok(()) + } + + /// Test: max_total_debouncing_time causes batch reset when exceeded. + /// Uses direct DB manipulation to set first_started_at in the past. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_max_time_exceeded(db: Pool) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_time_limit_key".to_string()), + max_total_debouncing_time: Some(10), // 10 seconds max + ..Default::default() + }; + let args_hm = empty_args(); + + // Job 1: first in batch + let job1 = Uuid::new_v4(); + insert_flow_job(&db, job1, "test-workspace", "f/test/flow").await; + let args = PushArgs::from(&args_hm); + let r1 = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job1, + &args, + &db, + ) + .await?; + assert!(r1.is_some(), "first job should get scheduled_for"); + + // Force first_started_at to 20 seconds ago to simulate time exceeding the limit + sqlx::query!( + "UPDATE debounce_key SET first_started_at = now() - interval '20 seconds' WHERE key = $1", + "pp_time_limit_key" + ) + .execute(&db) + .await?; + + // Job 2: should trigger time limit exceeded → batch reset, no debouncing + let job2 = Uuid::new_v4(); + insert_flow_job(&db, job2, "test-workspace", "f/test/flow").await; + let args = PushArgs::from(&args_hm); + let r2 = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job2, + &args, + &db, + ) + .await?; + // When limit is exceeded, the function resets and returns None (execute immediately) + assert!( + r2.is_none(), + "should return None when time limit is exceeded" + ); + + // Verify the batch was reset: debounced_times should be 0 + let dk = get_debounce_key(&db, "pp_time_limit_key").await.unwrap(); + assert_eq!(dk.2, 0, "debounced_times should be reset to 0"); + + // Job 1 should NOT be completed (time limit reset skips debouncing the previous job) + assert!( + is_queued(&db, &job1).await, + "job1 should still be queued (time limit reset doesn't debounce)" + ); + + Ok(()) + } + + /// Test: push-time max_total_debouncing_time causes batch reset when exceeded. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_push_max_time_exceeded(db: Pool) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("push_time_limit_key".to_string()), + max_total_debouncing_time: Some(10), + ..Default::default() + }; + let args_hm = empty_args(); + + // Job 1: first in batch + let job1 = Uuid::new_v4(); + insert_noop_job(&db, job1, "test-workspace").await; + let args = PushArgs::from(&args_hm); + let mut sf = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut sf, + &None, + "test-workspace", + JobKind::Noop, + job1, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + assert!(sf.is_some(), "first job should get scheduled_for"); + + // Force first_started_at to 20 seconds ago + sqlx::query!( + "UPDATE debounce_key SET first_started_at = now() - interval '20 seconds' WHERE key = $1", + "push_time_limit_key" + ) + .execute(&db) + .await?; + + // Job 2: should trigger time limit exceeded + let job2 = Uuid::new_v4(); + insert_noop_job(&db, job2, "test-workspace").await; + let args = PushArgs::from(&args_hm); + let mut sf2 = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut sf2, + &None, + "test-workspace", + JobKind::Noop, + job2, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + // scheduled_for is still set (push-time doesn't clear it on limit exceed) + // but the batch should be reset + let dk = get_debounce_key(&db, "push_time_limit_key").await.unwrap(); + assert_eq!(dk.2, 0, "debounced_times should be reset to 0"); + + Ok(()) + } + + /// Test: max_count boundary — at exactly the limit, debouncing still works. + /// One over the limit triggers reset. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_max_count_exact_boundary( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_count_boundary_key".to_string()), + max_total_debounces_amount: Some(3), + ..Default::default() + }; + let args_hm = empty_args(); + + let mut jobs = Vec::new(); + let mut results = Vec::new(); + // Push 5 jobs: job 1 (no debounce), jobs 2-4 (debounce, count 1-3), job 5 (count 4 > limit 3 → reset) + for _ in 0..5 { + let id = Uuid::new_v4(); + insert_flow_job(&db, id, "test-workspace", "f/test/flow").await; + jobs.push(id); + } + + for &j in &jobs { + let args = PushArgs::from(&args_hm); + let result = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + j, + &args, + &db, + ) + .await?; + results.push(result); + } + + // Jobs 1-4 should return Some (scheduled_for) — debouncing within limit + for (i, r) in results.iter().enumerate().take(4) { + assert!( + r.is_some(), + "job {} should get scheduled_for (within limit)", + i + 1 + ); + } + + // Job 5 (debounced_times=4, exceeds limit=3) should return None (batch reset) + assert!( + results[4].is_none(), + "job 5 should return None (limit exceeded, batch reset)" + ); + + // After reset, debounced_times should be 0 + let dk = get_debounce_key(&db, "pp_count_boundary_key") + .await + .unwrap(); + assert_eq!(dk.2, 0, "debounced_times should be reset to 0 after limit"); + + Ok(()) + } + + /// Test: after a max_count reset, a new batch starts fresh and debouncing works again. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_max_count_reset_new_batch( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_reset_cycle_key".to_string()), + max_total_debounces_amount: Some(2), + ..Default::default() + }; + let args_hm = empty_args(); + + // Limit check is `debounced_times > max`, so with max=2 we need 4 jobs + // to trigger reset (debounced_times=3 on the 4th job, 3>2=true). + // Cycle 1: jobs 1-4 (job 4 exceeds limit → reset) + let mut cycle1 = Vec::new(); + for _ in 0..4 { + let id = Uuid::new_v4(); + insert_flow_job(&db, id, "test-workspace", "f/test/flow").await; + cycle1.push(id); + } + let mut cycle1_results = Vec::new(); + for &j in &cycle1 { + let args = PushArgs::from(&args_hm); + let r = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + j, + &args, + &db, + ) + .await?; + cycle1_results.push(r); + } + assert!(cycle1_results[0].is_some(), "cycle1 job1 scheduled"); + assert!(cycle1_results[1].is_some(), "cycle1 job2 scheduled"); + assert!( + cycle1_results[2].is_some(), + "cycle1 job3 scheduled (at limit)" + ); + assert!( + cycle1_results[3].is_none(), + "cycle1 job4 should reset (over limit)" + ); + + // Verify debounced_times is reset to 0 + let dk = get_debounce_key(&db, "pp_reset_cycle_key").await.unwrap(); + assert_eq!(dk.2, 0, "debounced_times should be 0 after reset"); + + // Cycle 2: jobs 5-8 (new batch, should debounce independently) + let mut cycle2 = Vec::new(); + for _ in 0..4 { + let id = Uuid::new_v4(); + insert_flow_job(&db, id, "test-workspace", "f/test/flow").await; + cycle2.push(id); + } + let mut cycle2_results = Vec::new(); + for &j in &cycle2 { + let args = PushArgs::from(&args_hm); + let r = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + j, + &args, + &db, + ) + .await?; + cycle2_results.push(r); + } + // After cycle 1 reset, debounced_times=0. Cycle 2's first job hits ON CONFLICT + // and increments to 1 (unlike cycle 1's first job which was a fresh insert at 0). + // So cycle 2 reaches the limit one job sooner: + // job5: dt=1, job6: dt=2, job7: dt=3 (>2 → reset), job8: dt=1 + assert!(cycle2_results[0].is_some(), "cycle2 job1 scheduled (dt=1)"); + assert!(cycle2_results[1].is_some(), "cycle2 job2 scheduled (dt=2)"); + assert!( + cycle2_results[2].is_none(), + "cycle2 job3 should reset (dt=3 > 2)" + ); + assert!( + cycle2_results[3].is_some(), + "cycle2 job4 scheduled (fresh after reset, dt=1)" + ); + + Ok(()) + } + + /// Test: combined max_count AND max_time — whichever triggers first resets the batch. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_combined_count_and_time_limits( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_combined_limits_key".to_string()), + max_total_debounces_amount: Some(100), // high count limit + max_total_debouncing_time: Some(10), // low time limit + ..Default::default() + }; + let args_hm = empty_args(); + + // Job 1: start batch + let job1 = Uuid::new_v4(); + insert_flow_job(&db, job1, "test-workspace", "f/test/flow").await; + let args = PushArgs::from(&args_hm); + let r1 = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job1, + &args, + &db, + ) + .await?; + assert!(r1.is_some(), "first job should get scheduled_for"); + + // Force time to exceed limit (count is still 1, well under 100) + sqlx::query!( + "UPDATE debounce_key SET first_started_at = now() - interval '20 seconds' WHERE key = $1", + "pp_combined_limits_key" + ) + .execute(&db) + .await?; + + // Job 2: time limit should trigger even though count is low + let job2 = Uuid::new_v4(); + insert_flow_job(&db, job2, "test-workspace", "f/test/flow").await; + let args = PushArgs::from(&args_hm); + let r2 = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job2, + &args, + &db, + ) + .await?; + assert!( + r2.is_none(), + "time limit should trigger reset even with low count" + ); + + Ok(()) + } + + /// Test: debounce batch IDs are consistent within a batch. + /// All jobs in the same debounce batch should share the same batch number. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_batch_id_consistency( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_batch_id_test".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + + let mut jobs = Vec::new(); + for _ in 0..5 { + let id = Uuid::new_v4(); + insert_flow_job(&db, id, "test-workspace", "f/test/flow").await; + jobs.push(id); + } + + for &j in &jobs { + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + j, + &args, + &db, + ) + .await?; + } + + // All jobs should have the same debounce_batch + let batches: Vec = sqlx::query_scalar!( + "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = ANY($1) ORDER BY debounce_batch", + &jobs, + ) + .fetch_all(&db) + .await?; + + assert_eq!(batches.len(), 5, "all 5 jobs should have batch entries"); + let first = batches[0]; + assert!( + batches.iter().all(|b| *b == first), + "all jobs in same debounce batch should have the same batch ID, got {:?}", + batches + ); + + Ok(()) + } + + /// Test: after a max_count reset, the new batch gets a different batch ID. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_batch_id_changes_on_reset( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_batch_reset_id_test".to_string()), + max_total_debounces_amount: Some(2), + ..Default::default() + }; + let args_hm = empty_args(); + + // Batch 1: jobs 1-4 (job 4 triggers reset at debounced_times=3 > 2) + let mut batch1_jobs = Vec::new(); + for _ in 0..4 { + let id = Uuid::new_v4(); + insert_flow_job(&db, id, "test-workspace", "f/test/flow").await; + batch1_jobs.push(id); + } + for &j in &batch1_jobs { + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + j, + &args, + &db, + ) + .await?; + } + + // Batch 2: jobs 5-6 (new batch after reset) + let mut batch2_jobs = Vec::new(); + for _ in 0..2 { + let id = Uuid::new_v4(); + insert_flow_job(&db, id, "test-workspace", "f/test/flow").await; + batch2_jobs.push(id); + } + for &j in &batch2_jobs { + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + j, + &args, + &db, + ) + .await?; + } + + let batch1_id: i64 = sqlx::query_scalar!( + "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1", + batch1_jobs[0], + ) + .fetch_one(&db) + .await?; + + // Job 4 (the one that triggered reset) should have a different batch from jobs 1-3 + let reset_batch: i64 = sqlx::query_scalar!( + "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1", + batch1_jobs[3], + ) + .fetch_one(&db) + .await?; + + assert_ne!( + batch1_id, reset_batch, + "reset job should have a different batch ID" + ); + + // Batch 2 jobs should share the same batch but different from batch 1 + let batch2_id: i64 = sqlx::query_scalar!( + "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1", + batch2_jobs[0], + ) + .fetch_one(&db) + .await?; + + assert_ne!( + batch1_id, batch2_id, + "batch 2 should have a different batch ID from batch 1" + ); + + Ok(()) + } + + /// Test: different workspaces with the same debounce_key template produce different + /// resolved keys and do not interfere with each other. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_workspace_isolation(db: Pool) -> anyhow::Result<()> { + // Create a second workspace with required related rows + sqlx::query!( + "INSERT INTO workspace (id, name, owner) VALUES ('ws2', 'Workspace 2', 'test-user')" + ) + .execute(&db) + .await?; + sqlx::query!("INSERT INTO workspace_settings (workspace_id) VALUES ('ws2')") + .execute(&db) + .await?; + + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: None, // default key includes workspace_id + ..Default::default() + }; + let args_hm = empty_args(); + + // Job in workspace 1 + let job_ws1_a = Uuid::new_v4(); + let job_ws1_b = Uuid::new_v4(); + insert_flow_job(&db, job_ws1_a, "test-workspace", "f/test/flow").await; + insert_flow_job(&db, job_ws1_b, "test-workspace", "f/test/flow").await; + + // Job in workspace 2 + let job_ws2 = Uuid::new_v4(); + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path) + VALUES ($1, 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', 'ws2', 'f/test/flow')", + job_ws2, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) VALUES ($1, 'ws2', now(), 'flow')", + job_ws2, + ) + .execute(&db) + .await?; + sqlx::query!("INSERT INTO v2_job_runtime (id) VALUES ($1)", job_ws2) + .execute(&db) + .await?; + + // Debounce ws1 job A + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job_ws1_a, + &args, + &db, + ) + .await?; + + // Debounce ws2 job — should NOT debounce ws1 job A + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "ws2", + job_ws2, + &args, + &db, + ) + .await?; + + // ws1 job A should still be queued (not debounced by ws2) + assert!( + is_queued(&db, &job_ws1_a).await, + "ws1 job A should still be queued" + ); + + // Now debounce ws1 job B — should debounce ws1 job A + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job_ws1_b, + &args, + &db, + ) + .await?; + + // ws1 job A should now be completed (debounced by ws1 job B) + assert!( + is_completed(&db, &job_ws1_a).await, + "ws1 job A should be debounced by ws1 job B" + ); + // ws2 job should still be queued + assert!( + is_queued(&db, &job_ws2).await, + "ws2 job should still be queued" + ); + // ws1 job B should still be queued + assert!( + is_queued(&db, &job_ws1_b).await, + "ws1 job B should still be queued" + ); + + Ok(()) + } + + /// Test: debounced job's completed result contains the expected "Debounced by" message. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_completed_result_format( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_result_format_key".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + + let job1 = Uuid::new_v4(); + let job2 = Uuid::new_v4(); + insert_flow_job(&db, job1, "test-workspace", "f/test/flow").await; + insert_flow_job(&db, job2, "test-workspace", "f/test/flow").await; + + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job1, + &args, + &db, + ) + .await?; + + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job2, + &args, + &db, + ) + .await?; + + // Job 1 should be completed with "Debounced by {job2}" + assert!(is_completed(&db, &job1).await, "job1 should be completed"); + let result: Option = sqlx::query_scalar!( + "SELECT result::text FROM v2_job_completed WHERE id = $1", + job1, + ) + .fetch_one(&db) + .await?; + let result_str = result.expect("result should not be null"); + assert!( + result_str.contains(&format!("Debounced by {job2}")), + "result should contain 'Debounced by {job2}', got: {result_str}" + ); + + Ok(()) + } + + /// Test: debounce logs are appended to both the debounced job and the new job. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_logs_appended(db: Pool) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("pp_logs_test_key".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + + let job1 = Uuid::new_v4(); + let job2 = Uuid::new_v4(); + insert_flow_job(&db, job1, "test-workspace", "f/test/flow").await; + insert_flow_job(&db, job2, "test-workspace", "f/test/flow").await; + + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job1, + &args, + &db, + ) + .await?; + + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job2, + &args, + &db, + ) + .await?; + + // Job 1 (debounced) should have "Debounced by job {job2}" in its logs + let logs1: Option = sqlx::query_scalar!( + r#"SELECT logs as "logs!" FROM job_logs WHERE job_id = $1"#, + job1, + ) + .fetch_optional(&db) + .await?; + let logs1 = logs1.expect("debounced job should have logs"); + assert!( + logs1.contains(&format!("Debounced by job {job2}")), + "debounced job logs should contain 'Debounced by job {job2}', got: {logs1}" + ); + + // Job 2 (new) should have "debounce key" in its logs + let logs2: Option = sqlx::query_scalar!( + r#"SELECT logs as "logs!" FROM job_logs WHERE job_id = $1"#, + job2, + ) + .fetch_optional(&db) + .await?; + let logs2 = logs2.expect("new job should have logs"); + assert!( + logs2.contains("pp_logs_test_key"), + "new job logs should contain the debounce key, got: {logs2}" + ); + + Ok(()) + } + + /// Test: debounce with negative delay behaves like no debounce. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_negative_delay(db: Pool) -> anyhow::Result<()> { + let flow_id = Uuid::new_v4(); + insert_flow_job(&db, flow_id, "test-workspace", "f/test/flow").await; + + let settings = DebouncingSettings { + debounce_delay_s: Some(-5), + debounce_key: Some("pp_negative_delay".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + let args = PushArgs::from(&args_hm); + + let result = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_id, + &args, + &db, + ) + .await?; + + assert!( + result.is_none(), + "negative delay should be treated as no debounce" + ); + + Ok(()) + } + + /// Test: different runnable_paths with no custom debounce_key produce different resolved keys. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_different_paths_independent( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: None, // default key includes runnable_path + ..Default::default() + }; + let args_hm = empty_args(); + + // Two jobs on different paths + let job_a = Uuid::new_v4(); + let job_b = Uuid::new_v4(); + let job_a2 = Uuid::new_v4(); + insert_flow_job(&db, job_a, "test-workspace", "f/test/flow_a").await; + insert_flow_job(&db, job_b, "test-workspace", "f/test/flow_b").await; + insert_flow_job(&db, job_a2, "test-workspace", "f/test/flow_a").await; + + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow_a".to_string()), + "test-workspace", + job_a, + &args, + &db, + ) + .await?; + + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow_b".to_string()), + "test-workspace", + job_b, + &args, + &db, + ) + .await?; + + // job_a should still be queued (flow_b shouldn't debounce it) + assert!(is_queued(&db, &job_a).await, "job_a should still be queued"); + + // Now push job_a2 on the same path as job_a — should debounce job_a + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow_a".to_string()), + "test-workspace", + job_a2, + &args, + &db, + ) + .await?; + + assert!( + is_completed(&db, &job_a).await, + "job_a should be debounced by job_a2" + ); + assert!(is_queued(&db, &job_b).await, "job_b should be unaffected"); + assert!(is_queued(&db, &job_a2).await, "job_a2 should be queued"); + + Ok(()) + } + + /// Test: push-time debounce with custom key containing $args interpolation + /// differentiates on arg values. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_push_args_interpolation_differentiates(db: Pool) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("user:$args[user_id]".to_string()), + ..Default::default() + }; + + // Job with user_id = "alice" + let job_alice1 = Uuid::new_v4(); + insert_noop_job(&db, job_alice1, "test-workspace").await; + let mut hm = HashMap::new(); + hm.insert( + "user_id".to_string(), + RawValue::from_string("\"alice\"".to_string()).unwrap(), + ); + let args = PushArgs::from(&hm); + let mut sf = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut sf, + &None, + "test-workspace", + JobKind::Noop, + job_alice1, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + + // Job with user_id = "bob" + let job_bob = Uuid::new_v4(); + insert_noop_job(&db, job_bob, "test-workspace").await; + let mut hm = HashMap::new(); + hm.insert( + "user_id".to_string(), + RawValue::from_string("\"bob\"".to_string()).unwrap(), + ); + let args = PushArgs::from(&hm); + let mut sf = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut sf, + &None, + "test-workspace", + JobKind::Noop, + job_bob, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + + // Both should still be queued (different user_id → different keys) + assert!( + is_queued(&db, &job_alice1).await, + "alice job should still be queued" + ); + assert!( + is_queued(&db, &job_bob).await, + "bob job should still be queued" + ); + + // Another alice job should debounce the first + let job_alice2 = Uuid::new_v4(); + insert_noop_job(&db, job_alice2, "test-workspace").await; + let mut hm = HashMap::new(); + hm.insert( + "user_id".to_string(), + RawValue::from_string("\"alice\"".to_string()).unwrap(), + ); + let args = PushArgs::from(&hm); + let mut sf = None; + let mut tx = db.begin().await?; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut sf, + &None, + "test-workspace", + JobKind::Noop, + job_alice2, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + + assert!( + is_completed(&db, &job_alice1).await, + "alice job 1 should be debounced by alice job 2" + ); + assert!( + is_queued(&db, &job_bob).await, + "bob job should be unaffected" + ); + + Ok(()) + } + + /// Test: debounce_key entry points to the latest job after a chain, and + /// previous_job_id tracks the one that was just debounced. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_debounce_key_tracking_chain(db: Pool) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: Some("tracking_chain_key".to_string()), + ..Default::default() + }; + let args_hm = empty_args(); + + let job1 = Uuid::new_v4(); + let job2 = Uuid::new_v4(); + let job3 = Uuid::new_v4(); + insert_flow_job(&db, job1, "test-workspace", "f/test/flow").await; + insert_flow_job(&db, job2, "test-workspace", "f/test/flow").await; + insert_flow_job(&db, job3, "test-workspace", "f/test/flow").await; + + // After job 1 + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job1, + &args, + &db, + ) + .await?; + let dk = get_debounce_key(&db, "tracking_chain_key").await.unwrap(); + assert_eq!(dk.0, job1, "should point to job1"); + assert_eq!(dk.1, None, "no previous job for first entry"); + assert_eq!(dk.2, 0, "debounced_times should be 0"); + + // After job 2 + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job2, + &args, + &db, + ) + .await?; + let dk = get_debounce_key(&db, "tracking_chain_key").await.unwrap(); + assert_eq!(dk.0, job2, "should point to job2"); + assert_eq!(dk.1, Some(job1), "previous should be job1"); + assert_eq!(dk.2, 1, "debounced_times should be 1"); + + // After job 3 + let args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job3, + &args, + &db, + ) + .await?; + let dk = get_debounce_key(&db, "tracking_chain_key").await.unwrap(); + assert_eq!(dk.0, job3, "should point to job3"); + assert_eq!(dk.1, Some(job2), "previous should be job2"); + assert_eq!(dk.2, 2, "debounced_times should be 2"); + + Ok(()) + } + + // ========================================================================= + // Stress test for DB contention (run manually with --ignored) + // ========================================================================= + + /// Stress test: 20,000 debounce operations across 100 keys (200 jobs per key), + /// with bounded concurrency (64 in-flight at a time, matching a large worker fleet). + /// Measures wall-clock time, per-operation latency percentiles, and throughput. + /// + /// Run with: + /// cargo test -p windmill-queue --test debounce_test --features private,enterprise \ + /// -- --ignored test_debounce_contention_stress --nocapture + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + #[ignore] + async fn test_debounce_contention_stress(db: Pool) -> anyhow::Result<()> { + use std::sync::Arc; + use tokio::sync::Semaphore; + + let num_keys: usize = 100; + let jobs_per_key: usize = 200; + let total = num_keys * jobs_per_key; + let max_concurrent: usize = 64; + + // Batch-insert all flow jobs upfront + let all_ids: Vec = (0..total).map(|_| Uuid::new_v4()).collect(); + for chunk in all_ids.chunks(500) { + let chunk_vec: Vec = chunk.to_vec(); + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path) + SELECT unnest($1::uuid[]), 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace', 'f/test/flow'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + SELECT unnest($1::uuid[]), 'test-workspace', now(), 'flow'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &chunk_vec, + ) + .execute(&db) + .await?; + } + + eprintln!("=== DEBOUNCE CONTENTION STRESS TEST ==="); + eprintln!(" keys: {num_keys}"); + eprintln!(" jobs per key: {jobs_per_key}"); + eprintln!(" total jobs: {total}"); + eprintln!(" max concurrent: {max_concurrent}"); + + let semaphore = Arc::new(Semaphore::new(max_concurrent)); + let start = std::time::Instant::now(); + + let mut handles = Vec::with_capacity(total); + for (i, &flow_id) in all_ids.iter().enumerate() { + let db = db.clone(); + let sem = semaphore.clone(); + let key_index = i % num_keys; + let handle = tokio::spawn(async move { + let _permit = sem.acquire().await.unwrap(); + let settings = DebouncingSettings { + debounce_delay_s: Some(60), + debounce_key: Some(format!("stress_key_{key_index}")), + ..Default::default() + }; + let args_hm: HashMap> = HashMap::new(); + let args = PushArgs::from(&args_hm); + + let op_start = std::time::Instant::now(); + let result = windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + flow_id, + &args, + &db, + ) + .await; + let op_duration = op_start.elapsed(); + + (result, op_duration) + }); + handles.push(handle); + } + + let mut error_count = 0; + let mut op_durations = Vec::with_capacity(total); + for handle in handles { + let (result, duration) = handle.await?; + op_durations.push(duration); + if let Err(e) = result { + eprintln!(" error: {e:#}"); + error_count += 1; + } + } + + let wall_time = start.elapsed(); + + // Compute stats + op_durations.sort(); + let p50 = op_durations[total / 2]; + let p95 = op_durations[total * 95 / 100]; + let p99 = op_durations[total * 99 / 100]; + let max = op_durations[total - 1]; + let ops_per_sec = total as f64 / wall_time.as_secs_f64(); + + // Each key group should have exactly 1 survivor in queue + let queued_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_queue WHERE id = ANY($1)", + &all_ids, + ) + .fetch_one(&db) + .await?; + + let completed_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_completed WHERE id = ANY($1)", + &all_ids, + ) + .fetch_one(&db) + .await?; + + eprintln!(" wall time: {wall_time:?}"); + eprintln!(" ops/sec: {ops_per_sec:.0}"); + eprintln!(" p50 latency: {p50:?}"); + eprintln!(" p95 latency: {p95:?}"); + eprintln!(" p99 latency: {p99:?}"); + eprintln!(" max latency: {max:?}"); + eprintln!(" errors: {error_count}"); + eprintln!(" queued: {queued_count} (expected {num_keys})"); + eprintln!( + " completed: {completed_count} (expected {})", + total - num_keys + ); + eprintln!("======================================="); + + assert_eq!(error_count, 0, "no errors expected, got {error_count}"); + assert_eq!( + queued_count, num_keys as i64, + "expected {num_keys} survivors (1 per key), got {queued_count}" + ); + assert_eq!( + completed_count, + (total - num_keys) as i64, + "expected {} debounced, got {completed_count}", + total - num_keys + ); + + Ok(()) + } + + /// Stress test for push-time maybe_debounce: concurrent operations across multiple keys, + /// each holding a caller transaction open (simulating push_inner) while debouncing. + /// + /// Note: push-time debounce holds a caller tx AND `add_completed_job` needs its own + /// pool connection, so each concurrent push needs 2 pool connections. The sqlx::test + /// pool defaults to ~10 connections, so max_concurrent must be <= pool_size/2. + /// In production, pool_size ~50 allows ~25 concurrent pushes per server. + /// + /// Run with: + /// cargo test -p windmill-queue --test debounce_test --features private,enterprise \ + /// -- --ignored test_push_debounce_contention_stress --nocapture + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + #[ignore] + async fn test_push_debounce_contention_stress(db: Pool) -> anyhow::Result<()> { + use std::sync::Arc; + use tokio::sync::Semaphore; + + let num_keys: usize = 10; + let jobs_per_key: usize = 100; + let total = num_keys * jobs_per_key; + // Each push holds 1 tx + add_completed_job needs 1 more = 2 connections. + // sqlx::test pool is ~10, so max_concurrent = 4 to stay safe. + let max_concurrent: usize = 4; + + // Batch-insert all jobs upfront + let all_ids: Vec = (0..total).map(|_| Uuid::new_v4()).collect(); + for chunk in all_ids.chunks(500) { + let chunk_vec: Vec = chunk.to_vec(); + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id) + SELECT unnest($1::uuid[]), 'noop', 'deno', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + SELECT unnest($1::uuid[]), 'test-workspace', now(), 'deno'", + &chunk_vec, + ) + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &chunk_vec, + ) + .execute(&db) + .await?; + } + + eprintln!("=== PUSH-TIME DEBOUNCE CONTENTION STRESS TEST ==="); + eprintln!(" keys: {num_keys}"); + eprintln!(" jobs per key: {jobs_per_key}"); + eprintln!(" total jobs: {total}"); + eprintln!(" max concurrent: {max_concurrent}"); + + let semaphore = Arc::new(Semaphore::new(max_concurrent)); + let start = std::time::Instant::now(); + + let mut handles = Vec::with_capacity(total); + for (i, &job_id) in all_ids.iter().enumerate() { + let db = db.clone(); + let sem = semaphore.clone(); + let key_index = i % num_keys; + let handle = tokio::spawn(async move { + let _permit = sem.acquire().await.unwrap(); + let settings = DebouncingSettings { + debounce_delay_s: Some(60), + debounce_key: Some(format!("push_stress_key_{key_index}")), + ..Default::default() + }; + let args_hm: HashMap> = HashMap::new(); + let args = PushArgs::from(&args_hm); + + let op_start = std::time::Instant::now(); + + // Simulate push_inner: open a caller tx, call maybe_debounce, + // then commit (mirroring the real push flow). + let mut tx = db.begin().await?; + let mut scheduled_for = None; + windmill_queue::jobs_ee::maybe_debounce( + &settings, + &mut scheduled_for, + &None, + "test-workspace", + JobKind::Script, + job_id, + &args, + &mut tx, + ) + .await?; + tx.commit().await?; + + let op_duration = op_start.elapsed(); + Ok::<_, windmill_common::error::Error>((scheduled_for, op_duration)) + }); + handles.push(handle); + } + + let mut error_count = 0; + let mut op_durations = Vec::with_capacity(total); + for handle in handles { + match handle.await? { + Ok((_scheduled_for, duration)) => { + op_durations.push(duration); + } + Err(e) => { + eprintln!(" error: {e:#}"); + error_count += 1; + op_durations.push(std::time::Duration::ZERO); + } + } + } + + let wall_time = start.elapsed(); + + // Compute stats + op_durations.sort(); + let p50 = op_durations[total / 2]; + let p95 = op_durations[total * 95 / 100]; + let p99 = op_durations[total * 99 / 100]; + let max = op_durations[total - 1]; + let ops_per_sec = total as f64 / wall_time.as_secs_f64(); + + let queued_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_queue WHERE id = ANY($1)", + &all_ids, + ) + .fetch_one(&db) + .await?; + + let completed_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"count!\" FROM v2_job_completed WHERE id = ANY($1)", + &all_ids, + ) + .fetch_one(&db) + .await?; + + eprintln!(" wall time: {wall_time:?}"); + eprintln!(" ops/sec: {ops_per_sec:.0}"); + eprintln!(" p50 latency: {p50:?}"); + eprintln!(" p95 latency: {p95:?}"); + eprintln!(" p99 latency: {p99:?}"); + eprintln!(" max latency: {max:?}"); + eprintln!(" errors: {error_count}"); + eprintln!(" queued: {queued_count} (expected {num_keys})"); + eprintln!( + " completed: {completed_count} (expected {})", + total - num_keys + ); + eprintln!("================================================="); + + assert_eq!(error_count, 0, "no errors expected, got {error_count}"); + assert_eq!( + queued_count, num_keys as i64, + "expected {num_keys} survivors (1 per key), got {queued_count}" + ); + assert_eq!( + completed_count, + (total - num_keys) as i64, + "expected {} debounced, got {completed_count}", + total - num_keys + ); + + Ok(()) + } + + /// Helper: insert a flow job with args into v2_job + v2_job_queue + v2_job_runtime. + async fn insert_flow_job_with_args( + db: &Pool, + job_id: Uuid, + workspace_id: &str, + runnable_path: &str, + args: &serde_json::Value, + ) { + sqlx::query!( + "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path, args) + VALUES ($1, 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', $2, $3, $4)", + job_id, + workspace_id, + runnable_path, + args, + ) + .execute(db) + .await + .expect("insert v2_job with args"); + + sqlx::query!( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) + VALUES ($1, $2, now(), 'flow')", + job_id, + workspace_id, + ) + .execute(db) + .await + .expect("insert v2_job_queue"); + + sqlx::query!("INSERT INTO v2_job_runtime (id) VALUES ($1)", job_id) + .execute(db) + .await + .expect("insert v2_job_runtime"); + } + + /// Test: debounce_args_to_accumulate excludes the named arg from the debounce key, + /// so jobs with different values for that arg still debounce each other. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_args_to_accumulate_same_key( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: None, // default key (includes args minus accumulated ones) + debounce_args_to_accumulate: Some(vec!["items".to_string()]), + ..Default::default() + }; + + // Job 1: items = ["a", "b"] + let job1 = Uuid::new_v4(); + let args1 = serde_json::json!({"items": ["a", "b"], "other": "same"}); + insert_flow_job_with_args(&db, job1, "test-workspace", "f/test/flow", &args1).await; + + // Job 2: items = ["c", "d"] (different items, same "other") + let job2 = Uuid::new_v4(); + let args2 = serde_json::json!({"items": ["c", "d"], "other": "same"}); + insert_flow_job_with_args(&db, job2, "test-workspace", "f/test/flow", &args2).await; + + let args_hm1: HashMap> = serde_json::from_value(args1).unwrap(); + let args = PushArgs::from(&args_hm1); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job1, + &args, + &db, + ) + .await?; + + let args_hm2: HashMap> = serde_json::from_value(args2).unwrap(); + let args = PushArgs::from(&args_hm2); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job2, + &args, + &db, + ) + .await?; + + // Job 1 should be debounced (completed) because "items" is excluded from key + assert!( + is_completed(&db, &job1).await, + "job1 should be debounced despite different 'items' values" + ); + assert!( + is_queued(&db, &job2).await, + "job2 should still be queued (survivor)" + ); + + Ok(()) + } + + /// Test: debounce_args_to_accumulate does NOT cause debouncing when non-accumulated + /// args differ — only the accumulated arg is excluded from the key. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_args_to_accumulate_different_non_accumulated( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: None, + debounce_args_to_accumulate: Some(vec!["items".to_string()]), + ..Default::default() + }; + + // Job 1: other = "foo" + let job1 = Uuid::new_v4(); + let args1 = serde_json::json!({"items": ["a"], "other": "foo"}); + insert_flow_job_with_args(&db, job1, "test-workspace", "f/test/flow", &args1).await; + + // Job 2: other = "bar" (different non-accumulated arg) + let job2 = Uuid::new_v4(); + let args2 = serde_json::json!({"items": ["b"], "other": "bar"}); + insert_flow_job_with_args(&db, job2, "test-workspace", "f/test/flow", &args2).await; + + let args_hm1: HashMap> = serde_json::from_value(args1).unwrap(); + let args = PushArgs::from(&args_hm1); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job1, + &args, + &db, + ) + .await?; + + let args_hm2: HashMap> = serde_json::from_value(args2).unwrap(); + let args = PushArgs::from(&args_hm2); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + job2, + &args, + &db, + ) + .await?; + + // Both should still be queued — different "other" arg means different keys + assert!( + is_queued(&db, &job1).await, + "job1 should still be queued (different key due to 'other' arg)" + ); + assert!( + is_queued(&db, &job2).await, + "job2 should still be queued (different key due to 'other' arg)" + ); + + Ok(()) + } + + /// Test: batch tracking correctly groups debounced jobs so that accumulated args + /// can be collected at execution time via v2_job_debounce_batch. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_post_preprocessing_args_to_accumulate_batch_collection( + db: Pool, + ) -> anyhow::Result<()> { + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: None, + debounce_args_to_accumulate: Some(vec!["items".to_string()]), + ..Default::default() + }; + + // Create 3 jobs with different "items" but same "other" + let jobs: Vec<(Uuid, serde_json::Value)> = vec![ + ( + Uuid::new_v4(), + serde_json::json!({"items": ["a", "b"], "other": "x"}), + ), + ( + Uuid::new_v4(), + serde_json::json!({"items": ["c"], "other": "x"}), + ), + ( + Uuid::new_v4(), + serde_json::json!({"items": ["d", "e", "f"], "other": "x"}), + ), + ]; + + for (id, args) in &jobs { + insert_flow_job_with_args(&db, *id, "test-workspace", "f/test/flow", args).await; + } + + for (id, args) in &jobs { + let args_hm: HashMap> = + serde_json::from_value(args.clone()).unwrap(); + let push_args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + *id, + &push_args, + &db, + ) + .await?; + } + + let survivor = jobs[2].0; // last job survives + assert!( + is_queued(&db, &survivor).await, + "last job should be the survivor" + ); + + // All 3 jobs should be in the same debounce batch + let batch_ids: Vec = sqlx::query_scalar!( + "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = ANY($1)", + &jobs.iter().map(|(id, _)| *id).collect::>(), + ) + .fetch_all(&db) + .await?; + + assert_eq!(batch_ids.len(), 3, "all 3 jobs should have batch entries"); + assert!( + batch_ids.iter().all(|b| *b == batch_ids[0]), + "all jobs should share the same batch ID" + ); + + // Simulate what maybe_apply_debouncing does: collect accumulated args from batch + let accumulated: Vec> = sqlx::query_scalar!( + "WITH ids AS ( + SELECT id as job_id FROM v2_job_debounce_batch WHERE debounce_batch = ( + SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1 + ) + ) SELECT args->>'items' FROM ids LEFT JOIN v2_job ON v2_job.id = ids.job_id", + survivor, + ) + .fetch_all(&db) + .await?; + + // Merge all items arrays (same logic as maybe_apply_debouncing) + let mut all_items: Vec = vec![]; + for s in accumulated.iter().flatten() { + let items: Vec = serde_json::from_str(s).unwrap(); + all_items.extend(items); + } + all_items.sort_by(|a, b| a.as_str().unwrap().cmp(b.as_str().unwrap())); + + assert_eq!( + all_items, + vec!["a", "b", "c", "d", "e", "f"], + "accumulated items should contain all items from all debounced jobs" + ); + + Ok(()) + } + + /// Test: maybe_apply_debouncing actually merges accumulated args into the surviving job's args. + /// This is an end-to-end test that sets up runnable_settings in the DB, constructs a + /// PulledJobResult, and verifies the accumulated arg is written into the job. + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn test_maybe_apply_debouncing_merges_accumulated_args( + db: Pool, + ) -> anyhow::Result<()> { + use windmill_common::runnable_settings::RunnableSettings; + use windmill_common::runnable_settings::{ + insert_rs, ConcurrencySettings, RunnableSettingsTrait, + }; + use windmill_queue::{MiniPulledJob, PulledJob, PulledJobResult}; + + let settings = DebouncingSettings { + debounce_delay_s: Some(5), + debounce_key: None, + debounce_args_to_accumulate: Some(vec!["items".to_string()]), + ..Default::default() + }; + + // Insert debouncing_settings and concurrency_settings into the DB + let debouncing_hash = settings.insert_cached(&db).await?; + let concurrency_hash = ConcurrencySettings::default().insert_cached(&db).await?; + + let rs = RunnableSettings { + debouncing_settings: debouncing_hash, + concurrency_settings: concurrency_hash, + }; + let rs_handle = insert_rs(rs, &db).await?; + + // Create 3 jobs with different "items" values + let jobs: Vec<(Uuid, serde_json::Value)> = vec![ + ( + Uuid::new_v4(), + serde_json::json!({"items": [1, 2], "other": "x"}), + ), + ( + Uuid::new_v4(), + serde_json::json!({"items": [3], "other": "x"}), + ), + ( + Uuid::new_v4(), + serde_json::json!({"items": [4, 5, 6], "other": "x"}), + ), + ]; + + for (id, args) in &jobs { + insert_flow_job_with_args(&db, *id, "test-workspace", "f/test/flow", args).await; + // Set runnable_settings_handle on the job + sqlx::query!( + "UPDATE v2_job_queue SET runnable_settings_handle = $1 WHERE id = $2", + rs_handle, + id, + ) + .execute(&db) + .await?; + } + + // Debounce all 3 jobs via post-preprocessing + for (id, args) in &jobs { + let args_hm: HashMap> = + serde_json::from_value(args.clone()).unwrap(); + let push_args = PushArgs::from(&args_hm); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &settings, + &Some("f/test/flow".to_string()), + "test-workspace", + *id, + &push_args, + &db, + ) + .await?; + } + + let survivor_id = jobs[2].0; + assert!( + is_queued(&db, &survivor_id).await, + "last job should survive" + ); + + // Build a PulledJobResult for the surviving job (mimicking what the worker does) + let survivor_args: HashMap> = + serde_json::from_value(jobs[2].1.clone()).unwrap(); + + let mini = MiniPulledJob { + workspace_id: "test-workspace".to_string(), + id: survivor_id, + args: Some(sqlx::types::Json(survivor_args)), + parent_job: None, + created_by: "test-user".to_string(), + scheduled_for: Utc::now(), + started_at: None, + runnable_path: Some("f/test/flow".to_string()), + kind: JobKind::Flow, + runnable_id: None, + canceled_reason: None, + canceled_by: None, + permissioned_as: "u/test-user".to_string(), + permissioned_as_email: "test@windmill.dev".to_string(), + flow_status: None, + tag: "flow".to_string(), + script_lang: None, + same_worker: false, + pre_run_error: None, + concurrent_limit: None, + concurrency_time_window_s: None, + flow_innermost_root_job: None, + root_job: None, + timeout: None, + flow_step_id: None, + cache_ttl: None, + cache_ignore_s3_path: None, + priority: None, + preprocessed: None, + script_entrypoint_override: None, + trigger: None, + trigger_kind: None, + visible_to_owner: false, + permissioned_as_end_user_email: None, + runnable_settings_handle: rs_handle, + }; + + let pulled = PulledJob { + job: mini, + raw_code: None, + raw_lock: None, + raw_flow: None, + parent_runnable_path: None, + permissioned_as_email: None, + permissioned_as_username: None, + permissioned_as_is_admin: None, + permissioned_as_is_operator: None, + permissioned_as_groups: None, + permissioned_as_folders: None, + }; + + let mut result = PulledJobResult { + job: Some(pulled), + suspended: false, + missing_concurrency_key: false, + error_while_preprocessing: None, + }; + + // Call the real maybe_apply_debouncing + result.maybe_apply_debouncing(&db).await?; + + // The job should still be present (not debounced itself) + assert!( + result.job.is_some(), + "survivor job should not be nulled out" + ); + + let job = result.job.unwrap(); + let args = job.job.args.expect("args should be present"); + let items_raw = args.get("items").expect("items arg should exist"); + let items: Vec = serde_json::from_str(items_raw.get())?; + + // Should have all 6 items accumulated from all 3 debounced jobs + let mut item_nums: Vec = items + .iter() + .map(|v| v.as_i64().expect("item should be a number")) + .collect(); + item_nums.sort(); + + assert_eq!( + item_nums, + vec![1, 2, 3, 4, 5, 6], + "accumulated items should contain all values from all debounced jobs" + ); + + // "other" arg should be unchanged + let other_raw = args.get("other").expect("other arg should exist"); + let other: String = serde_json::from_str(other_raw.get())?; + assert_eq!(other, "x", "non-accumulated arg should be unchanged"); + + Ok(()) + } +} diff --git a/backend/windmill-runtime-nativets/src/dedicated.rs b/backend/windmill-runtime-nativets/src/dedicated.rs new file mode 100644 index 0000000000..5533c36262 --- /dev/null +++ b/backend/windmill-runtime-nativets/src/dedicated.rs @@ -0,0 +1,155 @@ +use std::collections::HashMap; + +use serde_json::value::RawValue; + +use crate::{ + create_nativets_runtime, execute_main, load_client_module, load_user_module, CreatedRuntime, + ExecuteError, MainArgs, NativeAnnotation, +}; + +pub struct PrewarmedResult { + pub result: Result, String>, + pub logs: String, +} + +pub struct ExecutingIsolate { + result_rx: tokio::sync::oneshot::Receiver, + handle: tokio::task::JoinHandle>, +} + +impl ExecutingIsolate { + pub async fn wait(self) -> anyhow::Result { + let result = self + .result_rx + .await + .map_err(|_| anyhow::anyhow!("isolate result channel closed"))?; + self.handle + .await + .map_err(|e| anyhow::anyhow!("isolate thread panicked: {e}"))??; + Ok(result) + } +} + +pub struct PrewarmedIsolate { + args_tx: Option>, + result_rx: Option>, + ready_rx: Option>, + handle: Option>>, +} + +/// Parse a JSON args object and reorder into positional args matching `arg_names`. +fn args_to_positional(args_json: &str, arg_names: &[String]) -> Vec>> { + let map: HashMap> = serde_json::from_str(args_json).unwrap_or_default(); + arg_names + .iter() + .map(|name| map.get(name).cloned()) + .collect() +} + +impl PrewarmedIsolate { + /// Spawn a new isolate on a blocking thread. + /// + /// The isolate loads `env_code` + WINDMILL_CLIENT as `windmill.ts`, + /// then loads `js_code` as `eval.ts`, and waits for args to execute. + pub fn spawn( + env_code: String, + js_code: String, + ann: NativeAnnotation, + arg_names: Vec, + ) -> Self { + let (args_tx, args_rx) = tokio::sync::oneshot::channel::(); + let (result_tx, result_rx) = tokio::sync::oneshot::channel::(); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + + let handle = tokio::task::spawn_blocking(move || { + let CreatedRuntime { mut js_runtime, log_receiver, mut memory_limit_rx } = + create_nativets_runtime(ann, vec![])?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + runtime.block_on(async { + load_client_module(&mut js_runtime, &env_code).await?; + load_user_module(&mut js_runtime, format!("{env_code}\n{js_code}")).await?; + + let _ = ready_tx.send(()); + + let args_json = match args_rx.await { + Ok(a) => a, + Err(_) => return Ok(()), + }; + + let positional = args_to_positional(&args_json, &arg_names); + { + let op_state = js_runtime.op_state(); + let mut op_state = op_state.borrow_mut(); + op_state.put(MainArgs { args: positional }); + } + + let log_handle = tokio::spawn(async move { + let mut log_receiver = log_receiver; + let mut logs = String::new(); + while let Some(log) = log_receiver.recv().await { + logs.push_str(&log); + logs.push('\n'); + } + logs + }); + + let exec_result = tokio::select! { + r = execute_main(&mut js_runtime, None, false, None) => r, + _ = memory_limit_rx.recv() => { + Err(ExecuteError::Script("Memory limit reached, killing isolate".to_string())) + } + }; + + let result = match exec_result { + Ok(raw) => Ok(raw), + Err(ExecuteError::Script(msg)) => Err(msg), + Err(ExecuteError::Js { message, stack, .. }) => { + let msg = message.unwrap_or_default(); + let err = match stack { + Some(s) => format!("{msg}\n{s}"), + None => msg, + }; + Err(err) + } + }; + + drop(js_runtime); + let logs = log_handle.await.unwrap_or_default(); + let _ = result_tx.send(PrewarmedResult { result, logs }); + Ok(()) + }) + }); + + PrewarmedIsolate { + args_tx: Some(args_tx), + result_rx: Some(result_rx), + ready_rx: Some(ready_rx), + handle: Some(handle), + } + } + + /// Wait for the isolate to finish loading modules. + pub async fn wait_ready(&mut self) -> anyhow::Result<()> { + if let Some(rx) = self.ready_rx.take() { + rx.await + .map_err(|_| anyhow::anyhow!("isolate failed during pre-warm"))?; + } + Ok(()) + } + + /// Send args and start execution. Returns an `ExecutingIsolate` that + /// can be awaited independently, allowing the caller to pre-warm + /// the next isolate in parallel. + pub fn start_execution(mut self, args: String) -> ExecutingIsolate { + let args_tx = self.args_tx.take().expect("start_execution called twice"); + let _ = args_tx.send(args); + ExecutingIsolate { + result_rx: self.result_rx.take().expect("result_rx missing"), + handle: self.handle.take().expect("handle missing"), + } + } +} diff --git a/backend/windmill-runtime-nativets/src/lib.rs b/backend/windmill-runtime-nativets/src/lib.rs index 4f1f88daa8..a523106cbc 100644 --- a/backend/windmill-runtime-nativets/src/lib.rs +++ b/backend/windmill-runtime-nativets/src/lib.rs @@ -12,6 +12,9 @@ //! TypeScript scripts via the nativets runtime. By isolating this here, //! deno_core compilation no longer blocks windmill-worker or windmill-api. +mod dedicated; +pub use dedicated::{ExecutingIsolate, PrewarmedIsolate, PrewarmedResult}; + use std::{ borrow::Cow, cell::RefCell, @@ -112,14 +115,15 @@ impl NetPermissions for PermissionsContainer { // ── Types ──────────────────────────────────────────────────────────── -struct MainArgs { - args: Vec>>, +pub(crate) struct MainArgs { + pub(crate) args: Vec>>, } struct LogString { pub s: mpsc::UnboundedSender, } +#[derive(Clone)] pub struct NativeAnnotation { pub useragent: Option, pub proxy: Option<(String, Option<(String, String)>)>, @@ -145,7 +149,7 @@ impl Drop for IsolateDropGuard { static RUNTIME_SNAPSHOT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/FETCH_SNAPSHOT.bin")); -const WINDMILL_CLIENT: &str = include_str!("./windmill-client.js"); +pub(crate) const WINDMILL_CLIENT: &str = include_str!("./windmill-client.js"); const ERROR_DIR: &str = const_format::concatcp!(TMP_DIR, "/native_errors"); @@ -177,7 +181,10 @@ pub fn setup_deno_runtime() -> anyhow::Result<()> { .collect::>(); if !unrecognized_v8_flags.is_empty() { - init_err = Some(format!("Unrecognized V8 flags: {:?}", unrecognized_v8_flags)); + init_err = Some(format!( + "Unrecognized V8 flags: {:?}", + unrecognized_v8_flags + )); } // Use an unprotected platform that doesn't enforce thread-isolated allocations @@ -349,6 +356,140 @@ fn op_log(op_state: Rc>, #[string] log: &str) { } } +// ── Shared V8 runtime creation ─────────────────────────────────────── + +pub(crate) struct CreatedRuntime { + pub(crate) js_runtime: JsRuntime, + pub(crate) log_receiver: mpsc::UnboundedReceiver, + pub(crate) memory_limit_rx: mpsc::UnboundedReceiver<()>, +} + +/// Create a JsRuntime with the standard nativets extensions, heap limit +/// callback, and log channel. Must be called on a blocking thread (not +/// on the async tokio runtime) because V8 isolate creation is +/// synchronous and potentially heavy. +pub(crate) fn create_nativets_runtime( + ann: NativeAnnotation, + initial_args: Vec>>, +) -> anyhow::Result { + let ops = vec![op_get_static_args(), op_log()]; + let ext = Extension { name: "windmill", ops: ops.into(), ..Default::default() }; + + let fetch_options = deno_fetch::Options { + root_cert_store_provider: None, + user_agent: ann.useragent.unwrap_or_else(|| "windmill/beta".to_string()), + proxy: ann.proxy.map(|x| deno_tls::Proxy { + url: x.0, + basic_auth: x + .1 + .map(|(username, password)| deno_tls::BasicAuth { username, password }), + }), + ..Default::default() + }; + + let exts: Vec = vec![ + deno_telemetry::deno_telemetry::init_ops(), + deno_webidl::deno_webidl::init_ops(), + deno_url::deno_url::init_ops(), + deno_console::deno_console::init_ops(), + deno_web::deno_web::init_ops::(Arc::new(BlobStore::default()), None), + deno_fetch::deno_fetch::init_ops::(fetch_options), + deno_net::deno_net::init_ops::(None, None), + ext, + ]; + + let options = RuntimeOptions { + is_main: true, + extensions: exts, + create_params: Some( + deno_core::v8::CreateParams::default().heap_limits(0, 1024 * 1024 * 128), + ), + startup_snapshot: Some(RUNTIME_SNAPSHOT), + module_loader: Some(Rc::new(deno_core::FsModuleLoader)), + extension_transpiler: None, + ..Default::default() + }; + + let (memory_limit_tx, memory_limit_rx) = mpsc::unbounded_channel::<()>(); + + setup_deno_runtime().expect("V8 platform init failed"); + + let mut js_runtime = { + let _v8_lock = V8_ISOLATE_CREATE_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + JsRuntime::new(options) + }; + + js_runtime.add_near_heap_limit_callback(move |x, y| { + tracing::error!("heap limit reached: {x} {y}"); + if memory_limit_tx.send(()).is_err() { + tracing::warn!( + "memory limit notification channel closed - isolate may already be terminating" + ); + } + y * 2 + }); + + let (log_sender, log_receiver) = mpsc::unbounded_channel::(); + + { + let op_state = js_runtime.op_state(); + let mut op_state = op_state.borrow_mut(); + op_state.put(PermissionsContainer {}); + op_state.put(MainArgs { args: initial_args }); + op_state.put(LogString { s: log_sender }); + } + + Ok(CreatedRuntime { js_runtime, log_receiver, memory_limit_rx }) +} + +// ── Shared module-loading helpers ──────────────────────────────────── + +pub(crate) async fn load_client_module( + js_runtime: &mut JsRuntime, + env_code: &str, +) -> anyhow::Result<()> { + js_runtime + .load_side_es_module_from_code( + &deno_core::resolve_url("file:///windmill.ts") + .map_err(windmill_common::error::to_anyhow)?, + format!("{env_code}\n{WINDMILL_CLIENT}"), + ) + .await + .map_err(windmill_common::error::to_anyhow)?; + Ok(()) +} + +pub(crate) async fn load_user_module( + js_runtime: &mut JsRuntime, + source: String, +) -> anyhow::Result<()> { + use anyhow::Context; + js_runtime + .load_side_es_module_from_code( + &deno_core::resolve_url("file:///eval.ts") + .map_err(windmill_common::error::to_anyhow)?, + source, + ) + .await + .context("failed to load module")?; + Ok(()) +} + +/// Extract a string result from a resolved V8 global and convert to `Box`. +pub(crate) fn extract_global_string( + js_runtime: &mut JsRuntime, + global: v8::Global, +) -> Result, String> { + let scope = &mut js_runtime.handle_scope(); + let local = v8::Local::new(scope, global); + match serde_v8::from_v8::>(scope, local) { + Ok(s) => Ok(unsafe_raw(s.unwrap_or_else(|| "null".to_string()))), + Err(e) => Err(format!("failed to deserialize result: {e}")), + } +} + // ── eval_fetch_timeout ─────────────────────────────────────────────── /// Execute a NativeTS script using deno_core/V8. @@ -436,63 +577,9 @@ pub async fn eval_fetch_timeout( } let result_f = tokio::task::spawn_blocking(move || { - let ops = vec![op_get_static_args(), op_log()]; - let ext = Extension { name: "windmill", ops: ops.into(), ..Default::default() }; + let CreatedRuntime { mut js_runtime, mut log_receiver, mut memory_limit_rx } = + create_nativets_runtime(ann, spread)?; - let fetch_options = deno_fetch::Options { - root_cert_store_provider: None, - user_agent: ann.useragent.unwrap_or_else(|| "windmill/beta".to_string()), - proxy: ann.proxy.map(|x| deno_tls::Proxy { - url: x.0, - basic_auth: x - .1 - .map(|(username, password)| deno_tls::BasicAuth { username, password }), - }), - ..Default::default() - }; - - let exts: Vec = vec![ - deno_telemetry::deno_telemetry::init_ops(), - deno_webidl::deno_webidl::init_ops(), - deno_url::deno_url::init_ops(), - deno_console::deno_console::init_ops(), - deno_web::deno_web::init_ops::( - Arc::new(BlobStore::default()), - None, - ), - deno_fetch::deno_fetch::init_ops::(fetch_options), - deno_net::deno_net::init_ops::(None, None), - ext, - ]; - - let options = RuntimeOptions { - is_main: true, - extensions: exts, - create_params: Some( - deno_core::v8::CreateParams::default().heap_limits(0, 1024 * 1024 * 128), - ), - startup_snapshot: Some(RUNTIME_SNAPSHOT), - module_loader: Some(Rc::new(deno_core::FsModuleLoader)), - extension_transpiler: None, - ..Default::default() - }; - - let (memory_limit_tx, mut memory_limit_rx) = mpsc::unbounded_channel::<()>(); - - // Ensure V8 platform is initialized (idempotent, no-op if already done). - setup_deno_runtime().expect("V8 platform init failed"); - - // Serialize isolate creation as extra safety net against concurrent V8 - // isolate creation races. The main fix is the unprotected platform in - // setup_deno_runtime(), but this provides defense in depth. - let mut js_runtime = { - let _v8_lock = V8_ISOLATE_CREATE_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - JsRuntime::new(options) - }; - - // Bootstrap OpenTelemetry for fetch auto-instrumentation if OTEL was initialized. if otel_initialized { if let Err(e) = js_runtime.execute_script("", "globalThis.__bootstrapOtel()") @@ -501,24 +588,6 @@ pub async fn eval_fetch_timeout( } } - js_runtime.add_near_heap_limit_callback(move |x, y| { - tracing::error!("heap limit reached: {x} {y}"); - if memory_limit_tx.send(()).is_err() { - tracing::error!("failed to send memory limit reached notification - isolate may already be terminating"); - }; - y * 2 - }); - - let (log_sender, mut log_receiver) = mpsc::unbounded_channel::(); - - { - let op_state = js_runtime.op_state(); - let mut op_state = op_state.borrow_mut(); - op_state.put(PermissionsContainer {}); - op_state.put(MainArgs { args: spread }); - op_state.put(LogString { s: log_sender }); - } - *isolate_handle.lock().unwrap_or_else(|e| e.into_inner()) = Some(js_runtime.v8_isolate().thread_safe_handle()); @@ -601,42 +670,97 @@ async fn eval_fetch( script_entrypoint_override: Option, load_client: bool, job_id: &Uuid, - _otel_initialized: bool, + otel_initialized: bool, ) -> windmill_common::error::Result> { if load_client { if let Some(env_code) = env_code.as_ref() { - let _ = js_runtime - .load_side_es_module_from_code( - &deno_core::resolve_url("file:///windmill.ts") - .map_err(windmill_common::error::to_anyhow)?, - format!("{env_code}\n{}", WINDMILL_CLIENT.to_string()), - ) - .await - .map_err(windmill_common::error::to_anyhow)?; + load_client_module(js_runtime, env_code).await?; } } - use anyhow::Context; - use deno_core::error::CoreError; - use windmill_common::worker::to_raw_value; let source = format!("{}\n{expr}", env_code.unwrap_or_default()); - let _ = js_runtime - .load_side_es_module_from_code( - &deno_core::resolve_url("file:///eval.ts") - .map_err(windmill_common::error::to_anyhow)?, - source.to_string(), - ) - .await - .map_err(|e| { - write_error_expr(expr, &job_id); - e - }) - .context("failed to load module")?; + if let Err(e) = load_user_module(js_runtime, source.clone()).await { + write_error_expr(expr, job_id); + return Err(e.into()); + } - let main_override = script_entrypoint_override.unwrap_or("main".to_string()); + let result = execute_main( + js_runtime, + script_entrypoint_override.as_deref(), + otel_initialized, + Some(job_id), + ) + .await; + + match result { + Ok(raw) => Ok(raw), + Err(ExecuteError::Script(msg)) => { + write_error_expr(expr, job_id); + Err(Error::ExecutionErr(msg)) + } + Err(ExecuteError::Js { message, stack, name, source: eval_source }) => { + write_error_expr(expr, job_id); + use windmill_common::worker::to_raw_value; + let stack_head = eval_source.and_then(|(file, line_no)| { + if file == "file:///eval.ts" { + source + .lines() + .nth(line_no.saturating_sub(1)) + .map(|l| format!("{l}\n")) + } else { + None + } + }); + let stack_s = format!( + "{}{}", + stack_head.unwrap_or_default(), + stack.as_deref().unwrap_or_default() + ); + let stack = if stack_s.is_empty() { + None + } else { + Some(stack_s) + }; + Err(Error::ExecutionRawError(to_raw_value(&serde_json::json!({ + "message": message, + "stack": stack, + "name": name, + })))) + } + } +} + +// ── Shared execution engine ────────────────────────────────────────── + +pub(crate) enum ExecuteError { + /// Non-JS error (V8 internal, init failure, deserialization) + Script(String), + /// JS exception with structured error info + Js { + message: Option, + stack: Option, + name: Option, + /// (file_name, line_number) from the first stack frame, if in user code + source: Option<(String, usize)>, + }, +} + +/// Execute the `main` function from the already-loaded `eval.ts` module. +/// +/// Args must already be set in `MainArgs` in the runtime's OpState. +/// Modules (`windmill.ts` and `eval.ts`) must already be loaded. +pub(crate) async fn execute_main( + js_runtime: &mut JsRuntime, + entrypoint: Option<&str>, + _otel_initialized: bool, + _job_id: Option<&Uuid>, +) -> Result, ExecuteError> { + let main_fn = entrypoint.unwrap_or("main"); #[cfg(all(feature = "private", feature = "enterprise"))] let otel_context_inject = if _otel_initialized { - let trace_id = job_id.as_simple().to_string(); + let trace_id = _job_id + .map(|id| id.as_simple().to_string()) + .unwrap_or_default(); format!( r#"globalThis.__enterSpan?.({{ isRecording: () => true, @@ -687,7 +811,7 @@ function processStreamIterative(res) {{ {otel_context_inject} let args = Deno.core.ops.op_get_static_args().map(JSON.parse) -import("file:///eval.ts").then((module) => module.{main_override}(...args)) +import("file:///eval.ts").then((module) => module.{main_fn}(...args)) .then(res => {{ if (isAsyncIterable(res)) {{ return processStreamIterative(res) @@ -698,60 +822,25 @@ import("file:///eval.ts").then((module) => module.{main_override}(...args)) "# ), ) - .map_err(|e| { - write_error_expr(expr, &job_id); - e - }) - .context("native script initialization")?; + .map_err(|e| ExecuteError::Script(format!("native script initialization: {e}")))?; let fut = js_runtime.resolve(script); let global = js_runtime .with_event_loop_promise(fut, PollEventLoopOptions::default()) - .await - .map_err(|e| { - write_error_expr(expr, &job_id); - e - }); + .await; match global { Ok(global) => { - let scope = &mut js_runtime.handle_scope(); - let local = v8::Local::new(scope, global); - let r = serde_v8::from_v8::>(scope, local) - .map_err(windmill_common::error::to_anyhow)?; - Ok(unsafe_raw(r.unwrap_or_else(|| "null".to_string()))) + extract_global_string(js_runtime, global).map_err(|e| ExecuteError::Script(e)) } - Err(CoreError::Js(e)) => { - let stack_head = e.frames.first().and_then(|f| { - if f.file_name.as_ref().is_some_and(|x| x == "file:///eval.ts") { - Some(format!( - "{}\n", - source - .lines() - .nth((f.line_number.unwrap_or(1)) as usize - 1) - .unwrap_or("") - .to_string() - )) - } else { - None - } + Err(deno_core::error::CoreError::Js(e)) => { + let source = e.frames.first().and_then(|f| { + f.file_name + .as_ref() + .map(|name| (name.clone(), f.line_number.unwrap_or(1) as usize)) }); - let stack_s = format!( - "{}{}", - stack_head.unwrap_or("".to_string()), - e.stack.unwrap_or("".to_string()) - ); - let stack = if stack_s.is_empty() { - None - } else { - Some(stack_s) - }; - Err(Error::ExecutionRawError(to_raw_value(&serde_json::json!({ - "message": e.message, - "stack": stack, - "name": e.name, - })))) + Err(ExecuteError::Js { message: e.message, stack: e.stack, name: e.name, source }) } - Err(e) => Err(Error::ExecutionErr(e.print_with_cause())), + Err(e) => Err(ExecuteError::Script(e.print_with_cause())), } } diff --git a/backend/windmill-store/src/oauth_refresh_oss.rs b/backend/windmill-store/src/oauth_refresh_oss.rs index e2e1355cac..7402e66e49 100644 --- a/backend/windmill-store/src/oauth_refresh_oss.rs +++ b/backend/windmill-store/src/oauth_refresh_oss.rs @@ -8,8 +8,6 @@ #[cfg(feature = "private")] pub use crate::oauth_refresh_ee::_refresh_token; -#[cfg(feature = "private")] -pub use crate::oauth_refresh_ee::_refresh_workspace_integration_token; #[cfg(not(feature = "private"))] use sqlx::{Postgres, Transaction}; @@ -38,156 +36,3 @@ pub async fn _refresh_token<'c>( ) .await } - -#[cfg(not(feature = "private"))] -pub async fn _refresh_workspace_integration_token<'c>( - mut tx: Transaction<'c, Postgres>, - path: &str, - w_id: &str, - account_id: i32, - db: &DB, - client_name: &str, - refresh_token: &str, -) -> error::Result { - use windmill_common::global_settings::{ - get_instance_oauth_credentials, workspace_integration_auth_endpoint, - workspace_integration_oauth_key, workspace_integration_token_endpoint, - }; - use windmill_common::utils::now_from_db; - use windmill_common::variables::{build_crypt, encrypt}; - use windmill_oauth::{OClient, RefreshToken, Url, OAUTH_HTTP_CLIENT}; - - tracing::info!( - client = %client_name, - workspace_id = %w_id, - account_id = %account_id, - "Refreshing workspace integration OAuth token" - ); - - let oauth_data: serde_json::Value = sqlx::query_scalar( - "SELECT oauth_data FROM workspace_integrations \ - WHERE workspace_id = $1 AND service_name::text = $2", - ) - .bind(w_id) - .bind(client_name) - .fetch_optional(&mut *tx) - .await? - .ok_or_else(|| { - error::Error::NotFound(format!( - "Workspace integration for {} not found or not configured", - client_name - )) - })?; - - let is_instance_shared = oauth_data - .get("instance_shared") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let (client_id, client_secret, base_url); - if is_instance_shared { - let oauth_key = workspace_integration_oauth_key(client_name); - let (id, secret) = get_instance_oauth_credentials(db, oauth_key).await?; - client_id = id; - client_secret = secret; - base_url = String::new(); - } else { - client_id = oauth_data["client_id"] - .as_str() - .ok_or_else(|| { - error::Error::InternalErr("Missing client_id in workspace integration".into()) - })? - .to_string(); - client_secret = oauth_data["client_secret"] - .as_str() - .ok_or_else(|| { - error::Error::InternalErr( - "Missing client_secret in workspace integration".into(), - ) - })? - .to_string(); - base_url = oauth_data["base_url"].as_str().unwrap_or("").to_string(); - } - - let token_endpoint = workspace_integration_token_endpoint(client_name, &base_url); - let auth_endpoint = workspace_integration_auth_endpoint(client_name, &base_url); - - let auth_url = Url::parse(&auth_endpoint) - .map_err(|e| error::Error::InternalErr(format!("Invalid auth URL: {}", e)))?; - let token_url = Url::parse(&token_endpoint) - .map_err(|e| error::Error::InternalErr(format!("Invalid token URL: {}", e)))?; - - let mut client = OClient::new(client_id, auth_url, token_url); - client.set_client_secret(client_secret); - - let token = client - .exchange_refresh_token(&RefreshToken::from(refresh_token)) - .with_client(&*OAUTH_HTTP_CLIENT) - .execute::() - .await - .map_err(|e| { - error::Error::InternalErr(format!( - "Failed to refresh workspace integration token: {:?}", - e - )) - })?; - - #[derive(serde::Deserialize)] - struct WsTokenResponse { - access_token: String, - refresh_token: Option, - expires_in: Option, - } - - let token_result: WsTokenResponse = serde_json::from_value(token) - .map_err(|e| error::Error::InternalErr(format!("Failed to parse token response: {}", e)))?; - - let expires_at = now_from_db(&mut *tx).await? - + chrono::Duration::try_seconds( - token_result - .expires_in - .ok_or_else(|| { - error::Error::InternalErr("expires_in expected and not found".into()) - })? - .try_into() - .unwrap(), - ) - .unwrap_or_default(); - - sqlx::query( - "UPDATE account SET refresh_token = $1, expires_at = $2, refresh_error = NULL \ - WHERE workspace_id = $3 AND id = $4", - ) - .bind( - token_result - .refresh_token - .as_deref() - .unwrap_or(refresh_token), - ) - .bind(expires_at) - .bind(w_id) - .bind(account_id) - .execute(&mut *tx) - .await?; - tx.commit().await?; - - let token_str = &token_result.access_token; - let mc = build_crypt(db, w_id).await?; - let encrypted_token = encrypt(&mc, token_str); - - sqlx::query("UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3") - .bind(encrypted_token) - .bind(w_id) - .bind(path) - .execute(db) - .await?; - - tracing::info!( - client = %client_name, - workspace_id = %w_id, - account_id = %account_id, - "Workspace integration OAuth token refreshed successfully" - ); - - Ok(token_result.access_token) -} diff --git a/backend/windmill-types/Cargo.toml b/backend/windmill-types/Cargo.toml index 9d25773e99..9619a10ea7 100644 --- a/backend/windmill-types/Cargo.toml +++ b/backend/windmill-types/Cargo.toml @@ -13,7 +13,6 @@ serde.workspace = true serde_json.workspace = true chrono.workspace = true uuid.workspace = true -sqlx = { workspace = true, features = ["postgres"] } rand.workspace = true hex.workspace = true anyhow.workspace = true @@ -21,3 +20,6 @@ tracing.workspace = true itertools.workspace = true strum.workspace = true bitflags.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +sqlx = { workspace = true, features = ["postgres"] } diff --git a/backend/windmill-types/src/assets.rs b/backend/windmill-types/src/assets.rs index 5f75388410..cf86d83801 100644 --- a/backend/windmill-types/src/assets.rs +++ b/backend/windmill-types/src/assets.rs @@ -40,6 +40,7 @@ pub struct AssetWithAltAccessType { pub path: String, pub kind: AssetKind, pub access_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub alt_access_type: Option, /// Map of column name to access type for column-level access tracking #[serde(skip_serializing_if = "Option::is_none")] diff --git a/backend/windmill-types/src/lib.rs b/backend/windmill-types/src/lib.rs index 6e935c3ece..4d9b96c2af 100644 --- a/backend/windmill-types/src/lib.rs +++ b/backend/windmill-types/src/lib.rs @@ -1,13 +1,23 @@ -pub mod apps; -pub mod assets; -pub mod flow_status; -pub mod flows; -pub mod jobs; pub mod more_serde; -pub mod runnable_settings; pub mod s3; + +#[cfg(not(target_arch = "wasm32"))] +pub mod apps; +#[cfg(not(target_arch = "wasm32"))] +pub mod assets; +#[cfg(not(target_arch = "wasm32"))] +pub mod flow_status; +#[cfg(not(target_arch = "wasm32"))] +pub mod flows; +#[cfg(not(target_arch = "wasm32"))] +pub mod jobs; +#[cfg(not(target_arch = "wasm32"))] +pub mod runnable_settings; +#[cfg(not(target_arch = "wasm32"))] pub mod schedule; +#[cfg(not(target_arch = "wasm32"))] pub mod scripts; +#[cfg(not(target_arch = "wasm32"))] pub mod triggers; /// Duplicated from windmill-common::worker::to_raw_value. diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index b7361eb2f3..9c46db572d 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -22,8 +22,8 @@ use crate::{ handle_child::handle_child, is_sandboxing_enabled, read_ee_registry, BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_NO_CACHE, BUN_PATH, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH, - NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, TRACING_PROXY_CA_CERT_PATH, - TZ_ENV, + NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, + TRACING_PROXY_CA_CERT_PATH, TZ_ENV, }; use windmill_common::{ client::AuthedClient, @@ -47,9 +47,9 @@ use windmill_common::{ DB, }; +use crate::global_cache::{exists_in_cache, save_cache}; #[cfg(all(feature = "enterprise", feature = "parquet"))] use windmill_object_store::attempt_fetch_bytes; -use crate::global_cache::{exists_in_cache, save_cache}; use windmill_parser::Typ; @@ -299,6 +299,20 @@ async fn gen_bunfig( w_id: &str, db: Option<&Connection>, ) -> Result<()> { + let npmrc = if let Some(conn) = db { + read_ee_registry(NPMRC.read().await.clone(), "npmrc", job_id, w_id, conn).await + } else { + NPMRC.read().await.clone() + }; + + if let Some(ref npmrc_content) = npmrc { + if !npmrc_content.trim().is_empty() { + tracing::debug!("Writing .npmrc for bun from npmrc setting"); + write_file(job_dir, ".npmrc", npmrc_content)?; + return Ok(()); + } + } + let (registry, bunfig_install_scopes) = if let Some(conn) = db { ( read_ee_registry( @@ -402,39 +416,55 @@ pub async fn install_bun_lockfile( }; let has_file = if npm_mode { - let registry = if let Some(conn) = db { - read_ee_registry( - NPM_CONFIG_REGISTRY.read().await.clone(), - "npm registry", - job_id, - w_id, - conn, - ) - .await + let npmrc = if let Some(conn) = db { + read_ee_registry(NPMRC.read().await.clone(), "npmrc", job_id, w_id, conn).await } else { - NPM_CONFIG_REGISTRY.read().await.clone() + NPMRC.read().await.clone() }; - if let Some(registry) = registry { - let content = registry - .trim_start_matches("https:") - .trim_start_matches("http:"); - let mut splitted = registry.split(":_authToken="); - let custom_registry = splitted.next().unwrap_or_default(); - npm_logs.push_str(&format!( - "Using custom npm registry: {custom_registry} {}\n", - if splitted.next().is_some() { - "with authToken" - } else { - "without authToken" - } - )); - - child_cmd.env("NPM_CONFIG_REGISTRY", custom_registry); - write_file(job_dir, ".npmrc", content)?; - true + if let Some(ref npmrc_content) = npmrc { + if !npmrc_content.trim().is_empty() { + npm_logs.push_str("Using .npmrc from instance settings\n"); + write_file(job_dir, ".npmrc", npmrc_content)?; + true + } else { + false + } } else { - false + let registry = if let Some(conn) = db { + read_ee_registry( + NPM_CONFIG_REGISTRY.read().await.clone(), + "npm registry", + job_id, + w_id, + conn, + ) + .await + } else { + NPM_CONFIG_REGISTRY.read().await.clone() + }; + if let Some(registry) = registry { + let content = registry + .trim_start_matches("https:") + .trim_start_matches("http:"); + + let mut splitted = registry.split(":_authToken="); + let custom_registry = splitted.next().unwrap_or_default(); + npm_logs.push_str(&format!( + "Using custom npm registry: {custom_registry} {}\n", + if splitted.next().is_some() { + "with authToken" + } else { + "without authToken" + } + )); + + child_cmd.env("NPM_CONFIG_REGISTRY", custom_registry); + write_file(job_dir, ".npmrc", content)?; + true + } else { + false + } } } else { false @@ -446,9 +476,11 @@ pub async fn install_bun_lockfile( } } - let mut child_process = start_child_process(child_cmd, &*BUN_PATH, false).await?; + if !has_file { + gen_bunfig(job_dir, job_id, w_id, db).await?; + } - gen_bunfig(job_dir, job_id, w_id, db).await?; + let mut child_process = start_child_process(child_cmd, &*BUN_PATH, false).await?; if let Some(db) = db { handle_child( job_id, @@ -977,8 +1009,7 @@ pub async fn handle_bun_job( } }; - let (cache, logs) = - crate::global_cache::load_cache(&local_path, &remote_path, false).await; + let (cache, logs) = crate::global_cache::load_cache(&local_path, &remote_path, false).await; (cache, logs, local_path, remote_path) } else { (false, "".to_string(), "".to_string(), "".to_string()) @@ -1400,13 +1431,7 @@ try {{ #[cfg(feature = "deno_core")] { - let env_code = format!( - "const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}", - reserved_variables - .iter() - .map(|(k, v)| format!("process.env['{}'] = '{}';\n", k, v)) - .collect::>() - .join("\n")); + let env_code = build_nativets_env_code(base_internal_url, &reserved_variables); let js_code = read_file_content(&format!("{job_dir}/main.js")).await?; let started_at = Instant::now(); let args = crate::common::build_args_map(job, client, conn) @@ -1661,6 +1686,21 @@ pub async fn get_common_bun_proc_envs(base_internal_url: Option<&str>) -> HashMa return bun_envs; } +#[cfg(any(feature = "deno_core", feature = "private"))] +pub fn build_nativets_env_code( + base_internal_url: &str, + reserved_variables: &HashMap, +) -> String { + format!( + "const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}", + reserved_variables + .iter() + .map(|(k, v)| format!("process.env['{}'] = '{}';", k, v)) + .collect::>() + .join("\n") + ) +} + #[cfg(feature = "private")] use crate::{ common::build_envs_map, dedicated_worker_oss::handle_dedicated_process, JobCompletedSender, @@ -1672,6 +1712,212 @@ use windmill_common::variables; #[cfg(feature = "private")] use windmill_queue::DedicatedWorkerJob; +#[cfg(feature = "private")] +async fn handle_dedicated_bunnative( + inner_content: &str, + js_code: &str, + env_code: &str, + token: &str, + worker_name: &str, + _w_id: &str, + script_path: &str, + db: &DB, + jobs_rx: Receiver, + killpill_rx: tokio::sync::broadcast::Receiver<()>, + job_completed_tx: JobCompletedSender, + client: &windmill_common::client::AuthedClient, +) -> Result<()> { + #[cfg(not(feature = "deno_core"))] + { + let _ = ( + inner_content, + js_code, + env_code, + token, + worker_name, + script_path, + db, + jobs_rx, + killpill_rx, + job_completed_tx, + client, + ); + return Err(error::Error::internal_err( + "deno_core feature is not activated but native dedicated worker was started" + .to_string(), + )); + } + + #[cfg(feature = "deno_core")] + { + use std::sync::Arc; + + use crate::common::transform_json; + use windmill_common::worker::to_raw_value; + use windmill_queue::{append_logs, JobCompleted, MiniCompletedJob}; + use windmill_runtime_nativets::PrewarmedIsolate; + + let ann = windmill_runtime_nativets::get_annotation(inner_content); + let parsed_args = + windmill_parser_ts::parse_deno_signature(inner_content, true, false, None)?.args; + let arg_names: Vec = parsed_args.into_iter().map(|x| x.name).collect(); + + let env_code = env_code.to_string(); + let js_code = js_code.to_string(); + + let mut warm = PrewarmedIsolate::spawn( + env_code.clone(), + js_code.clone(), + ann.clone(), + arg_names.clone(), + ); + + let init_log = format!("dedicated worker nativets: {worker_name}\n\n"); + let alive = true; + let mut killpill_rx = killpill_rx; + let mut jobs_rx = jobs_rx; + loop { + tokio::select! { + biased; + _ = killpill_rx.recv(), if alive => { + tracing::info!("received killpill for nativets dedicated worker"); + break; + }, + job = jobs_rx.recv(), if alive => { + if let Some(DedicatedWorkerJob { job, flow_runners, done_tx }) = job { + let id = job.id; + tracing::info!( + "received job on nativets dedicated worker for {script_path}: {id}" + ); + + let args = if let Some(args) = job.args.as_ref() { + if let Some(x) = transform_json( + client, &job.workspace_id, &args.0, &job, &db.into(), + ).await? { + serde_json::to_string(&x) + .unwrap_or_else(|_| "{}".to_string()) + } else { + serde_json::to_string(&args) + .unwrap_or_else(|_| "{}".to_string()) + } + } else { + "{}".to_string() + }; + + if let Err(e) = warm.wait_ready().await { + tracing::error!("pre-warmed isolate failed during init: {e}"); + let result = Arc::new(to_raw_value(&serde_json::json!({ + "message": format!("isolate init failed: {e}"), + "name": "Error", + }))); + append_logs(&id, &job.workspace_id, init_log.clone(), &db.into()).await; + job_completed_tx.send_job(JobCompleted { + job: MiniCompletedJob::from(job), + result, + result_columns: None, + mem_peak: 0, + canceled_by: None, + success: false, + cached_res_path: None, + token: token.to_string(), + duration: None, + preprocessed_args: None, + has_stream: Some(false), + from_cache: None, + flow_runners, + done_tx, + }, true).await?; + warm = PrewarmedIsolate::spawn( + env_code.clone(), + js_code.clone(), + ann.clone(), + arg_names.clone(), + ); + continue; + } + + let executing = warm.start_execution(args); + + warm = PrewarmedIsolate::spawn( + env_code.clone(), + js_code.clone(), + ann.clone(), + arg_names.clone(), + ); + + match executing.wait().await { + Ok(prewarmed_result) => { + let mut logs = init_log.clone(); + if !prewarmed_result.logs.is_empty() { + logs.push_str(&prewarmed_result.logs); + } + append_logs(&id, &job.workspace_id, logs, &db.into()).await; + + let (result, success) = match prewarmed_result.result { + Ok(raw) => (Arc::new(raw), true), + Err(e) => ( + Arc::new(to_raw_value(&serde_json::json!({ + "message": e, + "name": "Error", + }))), + false, + ), + }; + + job_completed_tx.send_job(JobCompleted { + job: MiniCompletedJob::from(job), + result, + result_columns: None, + mem_peak: 0, + canceled_by: None, + success, + cached_res_path: None, + token: token.to_string(), + duration: None, + preprocessed_args: None, + has_stream: Some(false), + from_cache: None, + flow_runners, + done_tx, + }, true).await?; + } + Err(e) => { + tracing::error!("isolate execution failed: {e}"); + append_logs(&id, &job.workspace_id, init_log.clone(), &db.into()).await; + let result = Arc::new(to_raw_value(&serde_json::json!({ + "message": format!("{e}"), + "name": "Error", + }))); + job_completed_tx.send_job(JobCompleted { + job: MiniCompletedJob::from(job), + result, + result_columns: None, + mem_peak: 0, + canceled_by: None, + success: false, + cached_res_path: None, + token: token.to_string(), + duration: None, + preprocessed_args: None, + has_stream: Some(false), + from_cache: None, + flow_runners: None, + done_tx: None, + }, true).await?; + } + } + } else { + tracing::debug!("job channel closed for nativets dedicated worker"); + break; + } + } + } + } + + Ok(()) + } +} + #[cfg(feature = "private")] pub async fn start_worker( requirements_o: Option, @@ -1704,7 +1950,9 @@ pub async fn start_worker( let mut annotation = windmill_common::worker::TypeScriptAnnotations::parse(inner_content); //TODO: remove this when bun dedicated workers work without issues - annotation.nodejs = true; + if !annotation.native { + annotation.nodejs = true; + } let context = variables::get_reserved_variables( &Connection::from(db.clone()), @@ -1728,6 +1976,81 @@ pub async fn start_worker( .await; let context_envs = build_envs_map(context.to_vec()).await; + if annotation.native { + // Native (V8) dedicated worker: bundle the code and dispatch to V8 instead of a subprocess. + let main_code = remove_pinned_imports(inner_content)?; + write_file(job_dir, "main.ts", &main_code)?; + + if let Some(reqs) = requirements_o.as_ref() { + let (pkg, lock, empty, is_binary) = split_lockfile(reqs); + write_file(job_dir, "package.json", pkg)?; + if let Some(lock) = lock { + if !empty { + write_lock(lock, job_dir, is_binary).await?; + install_bun_lockfile( + &mut mem_peak, + &mut canceled_by, + &Uuid::nil(), + w_id, + Some(&Connection::from(db.clone())), + job_dir, + worker_name, + common_bun_proc_envs.clone(), + annotation.npm, + &mut None, + ) + .await?; + } + } + } + + build_loader( + job_dir, + base_internal_url, + token, + w_id, + script_path, + LoaderMode::BrowserBundle, + ) + .await?; + generate_bun_bundle( + job_dir, + w_id, + &Uuid::nil(), + worker_name, + Some(&Connection::from(db.clone())), + None, + &mut mem_peak, + &mut canceled_by, + &common_bun_proc_envs, + &mut None, + ) + .await?; + let js_code = read_file_content(&format!("{job_dir}/main.js")).await?; + + let reserved_variables: HashMap = context + .iter() + .map(|x| (x.name.clone(), x.value.clone())) + .collect(); + let env_code = build_nativets_env_code(base_internal_url, &reserved_variables); + + return handle_dedicated_bunnative( + inner_content, + &js_code, + &env_code, + token, + worker_name, + w_id, + script_path, + db, + jobs_rx, + killpill_rx, + job_completed_tx, + &client, + ) + .await; + } + let mut format = BundleFormat::Cjs; if let Some(codebase) = codebase.as_ref() { let pulled_codebase = pull_codebase(w_id, codebase, job_dir).await?; diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index 39b619cae4..66d8ae8673 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -13,7 +13,7 @@ use crate::{ }, get_proxy_envs_for_lang, handle_child::handle_child, - is_sandboxing_enabled, read_ee_registry, DENO_CACHE_DIR, DENO_PATH, HOME_ENV, + is_sandboxing_enabled, read_ee_registry, DENO_CACHE_DIR, DENO_PATH, HOME_ENV, NPMRC, NPM_CONFIG_REGISTRY, PATH_ENV, TZ_ENV, }; use windmill_common::client::AuthedClient; @@ -79,21 +79,29 @@ async fn get_common_deno_proc_envs( ), ]); - let registry = if let Some(conn) = conn { - read_ee_registry( - NPM_CONFIG_REGISTRY.read().await.clone(), - "npm registry", - job_id, - w_id, - conn, - ) - .await + let npmrc = if let Some(conn) = conn { + read_ee_registry(NPMRC.read().await.clone(), "npmrc", job_id, w_id, conn).await } else { - NPM_CONFIG_REGISTRY.read().await.clone() + NPMRC.read().await.clone() }; - if let Some(ref s) = registry { - let (url, _token_opt) = parse_npm_config(s); - deno_envs.insert(String::from("NPM_CONFIG_REGISTRY"), url); + + if npmrc.as_ref().map_or(true, |s| s.trim().is_empty()) { + let registry = if let Some(conn) = conn { + read_ee_registry( + NPM_CONFIG_REGISTRY.read().await.clone(), + "npm registry", + job_id, + w_id, + conn, + ) + .await + } else { + NPM_CONFIG_REGISTRY.read().await.clone() + }; + if let Some(ref s) = registry { + let (url, _token_opt) = parse_npm_config(s); + deno_envs.insert(String::from("NPM_CONFIG_REGISTRY"), url); + } } if DENO_CERT.len() > 0 { deno_envs.insert(String::from("DENO_CERT"), DENO_CERT.clone()); @@ -390,6 +398,21 @@ try {{ common_deno_proc_envs.insert("HOME".to_string(), job_dir.to_string()); } + let npmrc = read_ee_registry( + NPMRC.read().await.clone(), + "npmrc", + &job.id, + &job.workspace_id, + conn, + ) + .await; + if let Some(ref npmrc_content) = npmrc { + if !npmrc_content.trim().is_empty() { + write_file(job_dir, ".npmrc", npmrc_content)?; + write_file(job_dir, "deno.json", "{}")?; + } + } + //do not cache local dependencies let child = { let reload = format!("--reload={base_internal_url}"); diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index 8345cbd7c2..73136e1cc0 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -10,8 +10,6 @@ use serde_json::value::RawValue; use serde_json::{json, Value}; use uuid::Uuid; use windmill_common::error::{to_anyhow, Error, Result}; -use windmill_types::s3::S3Object; -use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE; use windmill_common::utils::sanitize_string_from_password; use windmill_common::worker::{Connection, SqlResultCollectionStrategy}; use windmill_common::workspaces::{ @@ -19,8 +17,10 @@ use windmill_common::workspaces::{ DucklakeCatalogResourceType, }; use windmill_common::PgDatabase; +use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE; use windmill_parser_sql::{parse_duckdb_sig, parse_sql_blocks}; use windmill_queue::{CanceledBy, MiniPulledJob}; +use windmill_types::s3::S3Object; use crate::agent_workers::{get_datatable_resource_from_agent_http, get_ducklake_from_agent_http}; use crate::common::{build_args_values, get_reserved_variables, OccupancyMetrics}; @@ -419,7 +419,7 @@ fn format_attach_db_conn_str(db_resource: Value, db_type: &str) -> Result { let res: PgDatabase = serde_json::from_value(db_resource)?; - res.to_conn_str() + res.to_uri() } #[cfg(feature = "mysql")] "mysql" => { @@ -793,11 +793,9 @@ mod tests { "sslmode": "require" }); let result = format_attach_db_conn_str(db_resource, "postgres").unwrap(); - assert!(result.contains("dbname=mydb")); - assert!(result.contains("user=admin")); - assert!(result.contains("host=localhost")); - assert!(result.contains("password=secret123")); - assert!(result.contains("port=5432")); + // Should be in URI format: postgres://user:password@host:port/dbname?sslmode=require + assert!(result.starts_with("postgres://")); + assert!(result.contains("admin:secret123@localhost:5432/mydb")); assert!(result.contains("sslmode=require")); } @@ -808,11 +806,10 @@ mod tests { "dbname": "production" }); let result = format_attach_db_conn_str(db_resource, "postgres").unwrap(); - assert!(result.contains("dbname=production")); - assert!(result.contains("host=db.example.com")); - // Optional fields should result in empty strings - assert!(!result.contains("user=")); - assert!(!result.contains("password=")); + // Should be in URI format with defaults: postgres://postgres:@host:5432/dbname?sslmode=prefer + assert!(result.starts_with("postgres://")); + assert!(result.contains("@db.example.com:5432/production")); + assert!(result.contains("sslmode=prefer")); } #[test] @@ -822,8 +819,10 @@ mod tests { "dbname": "test" }); let result = format_attach_db_conn_str(db_resource, "postgresql").unwrap(); - assert!(result.contains("dbname=test")); - assert!(result.contains("host=localhost")); + // Should be in URI format (postgresql is treated the same as postgres) + assert!(result.starts_with("postgres://")); + assert!(result.contains("@localhost:5432/test")); + assert!(result.contains("sslmode=prefer")); } #[test] @@ -863,7 +862,9 @@ mod tests { "dbname": "test" }); let result = format_attach_db_conn_str(db_resource, "POSTGRES").unwrap(); - assert!(result.contains("dbname=test")); + // Should be in URI format + assert!(result.starts_with("postgres://")); + assert!(result.contains("@localhost:5432/test")); } #[cfg(feature = "mysql")] diff --git a/backend/windmill-worker/src/mssql_executor.rs b/backend/windmill-worker/src/mssql_executor.rs index 3a62481935..c1f8ae2fe7 100644 --- a/backend/windmill-worker/src/mssql_executor.rs +++ b/backend/windmill-worker/src/mssql_executor.rs @@ -11,7 +11,6 @@ use tiberius::{ use tokio::net::TcpStream; use tokio_util::compat::TokioAsyncWriteCompatExt; use uuid::Uuid; -use windmill_object_store::convert_json_line_stream; use windmill_common::utils::merge_raw_values_to_object; use windmill_common::worker::SqlResultCollectionStrategy; use windmill_common::{ @@ -19,6 +18,7 @@ use windmill_common::{ utils::empty_as_none, worker::{to_raw_value, Connection}, }; +use windmill_object_store::convert_json_line_stream; use windmill_parser_sql::{parse_db_resource, parse_mssql_sig, parse_s3_mode}; use windmill_queue::MiniPulledJob; use windmill_queue::{append_logs, CanceledBy}; @@ -428,9 +428,7 @@ fn sql_to_json_value(val: ColumnData) -> Result, Error> { } fn numeric_to_raw_value(numeric: &tiberius::numeric::Numeric) -> Result, Error> { - // tiberius::Numeric::to_string is broken, don't use it - - let sign = if numeric.int_part().is_negative() { + let sign = if numeric.value().is_negative() { "-" } else { "" @@ -468,6 +466,7 @@ where #[cfg(test)] mod tests { use super::*; + use tiberius::numeric::Numeric; #[test] fn test_sql_to_json_value_numeric_null() { @@ -477,7 +476,6 @@ mod tests { #[test] fn test_sql_to_json_value_numeric_integer() { - use tiberius::numeric::Numeric; let numeric = Numeric::new_with_scale(12345, 0); let result = sql_to_json_value(ColumnData::Numeric(Some(numeric))).unwrap(); assert_eq!(result.get(), "12345"); @@ -485,7 +483,6 @@ mod tests { #[test] fn test_sql_to_json_value_numeric_decimal() { - use tiberius::numeric::Numeric; let numeric = Numeric::new_with_scale(123456, 2); // Represents 1234.56 let result = sql_to_json_value(ColumnData::Numeric(Some(numeric))).unwrap(); assert_eq!(result.get(), "1234.56"); @@ -493,7 +490,6 @@ mod tests { #[test] fn test_sql_to_json_value_numeric_negative() { - use tiberius::numeric::Numeric; let numeric = Numeric::new_with_scale(-98765, 2); // Represents -987.65 let result = sql_to_json_value(ColumnData::Numeric(Some(numeric))).unwrap(); assert_eq!(result.get(), "-987.65"); @@ -501,7 +497,6 @@ mod tests { #[test] fn test_sql_to_json_value_numeric_negative_integer() { - use tiberius::numeric::Numeric; let numeric = Numeric::new_with_scale(-98765, 0); let result = sql_to_json_value(ColumnData::Numeric(Some(numeric))).unwrap(); assert_eq!(result.get(), "-98765"); @@ -509,15 +504,21 @@ mod tests { #[test] fn test_sql_to_json_value_numeric_high_precision() { - use tiberius::numeric::Numeric; let numeric = Numeric::new_with_scale(123456789012345, 10); // High precision let result = sql_to_json_value(ColumnData::Numeric(Some(numeric))).unwrap(); assert_eq!(result.get(), "12345.6789012345"); } + #[test] + fn test_sql_to_json_value_numeric_negative_fractional_only() { + // -0.4: int_part() is 0, so old code lost the negative sign + let numeric = Numeric::new_with_scale(-4, 1); + let result = sql_to_json_value(ColumnData::Numeric(Some(numeric))).unwrap(); + assert_eq!(result.get(), "-0.4"); + } + #[test] fn test_sql_to_json_value_numeric_7_69() { - use tiberius::numeric::Numeric; let numeric = Numeric::new_with_scale(769, 2); let result = sql_to_json_value(ColumnData::Numeric(Some(numeric))).unwrap(); assert_eq!(result.get(), "7.69"); diff --git a/backend/windmill-worker/src/sql_utils.rs b/backend/windmill-worker/src/sql_utils.rs index 6784b7c930..52ab0def25 100644 --- a/backend/windmill-worker/src/sql_utils.rs +++ b/backend/windmill-worker/src/sql_utils.rs @@ -5,16 +5,14 @@ pub fn remove_comments(stmt: &str) -> &str { let mut in_block_comment = false; let mut in_string = false; let mut string_delimiter = '\0'; - let mut start = None; - let mut end = stmt.len(); + let mut start_byte = None; + let mut end_byte = stmt.len(); - let chars: Vec = stmt.chars().collect(); - let len = chars.len(); + let mut prev_char = '\0'; + let mut char_indices = stmt.char_indices().peekable(); - for i in 0..len { - let c = chars[i]; - let next_char = if i + 1 < len { chars[i + 1] } else { '\0' }; - let prev_char = if i > 0 { chars[i - 1] } else { '\0' }; + while let Some((byte_pos, c)) = char_indices.next() { + let next_char = char_indices.peek().map(|(_, ch)| *ch).unwrap_or('\0'); // Handle string literals (single or double quotes) if !in_line_comment && !in_block_comment { @@ -48,7 +46,9 @@ pub fn remove_comments(stmt: &str) -> &str { // Check for end of block comment else if in_block_comment && c == '*' && next_char == '/' { in_block_comment = false; - // Skip the closing '/' by continuing after incrementing i in the loop + // Skip the closing '/' by advancing the iterator + char_indices.next(); + prev_char = '/'; continue; } } @@ -57,18 +57,20 @@ pub fn remove_comments(stmt: &str) -> &str { if !in_line_comment && !in_block_comment && !in_string { // Mark start of statement if !in_stmt && !c.is_whitespace() { - start = Some(i); + start_byte = Some(byte_pos); in_stmt = true; } // Mark end of statement at semicolon if in_stmt && c == ';' { - end = i + 1; + end_byte = byte_pos + c.len_utf8(); break; } } + + prev_char = c; } - &stmt[start.unwrap_or(0)..end] + &stmt[start_byte.unwrap_or(0)..end_byte] } #[cfg(test)] @@ -119,4 +121,22 @@ mod tests { // This correctly handles the subtraction of negative number assert_eq!(result, sql); } + + #[test] + fn test_remove_comments_invalid_truncate() { + let sql = r#"-- Mise à jour de la table café +UPDATE xyz.abcd t +SET + uio = s.uio +FROM table1 s +WHERE t.attrib = s.attrib; +"#; + let expected = r#"UPDATE xyz.abcd t +SET + uio = s.uio +FROM table1 s +WHERE t.attrib = s.attrib;"#; + let result = remove_comments(sql); + assert_eq!(result.trim(), expected); + } } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 547075933a..71d9f12a90 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -571,6 +571,7 @@ lazy_static::lazy_static! { pub static ref NPM_CONFIG_REGISTRY: Arc>> = Arc::new(RwLock::new(None)); pub static ref BUNFIG_INSTALL_SCOPES: Arc>> = Arc::new(RwLock::new(None)); + pub static ref NPMRC: Arc>> = Arc::new(RwLock::new(None)); pub static ref BUN_NO_CACHE: bool = std::env::var("BUN_NO_CACHE") .ok() .and_then(|x| x.parse::().ok()) diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 7b013bda9e..bc630b3190 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -1328,29 +1328,30 @@ pub async fn update_flow_status_after_job_completion_internal( if module_step.is_preprocessor_step() && success { let tag_and_concurrency_key = get_tag_and_concurrency(&flow, db).await; - let require_args = tag_and_concurrency_key.as_ref().is_some_and(|x| { + let has_debouncing = flow_value + .debouncing_settings + .debounce_delay_s + .filter(|x| *x > 0) + .is_some(); + let concurrency_requires_args = tag_and_concurrency_key.as_ref().is_some_and(|x| { x.tag.as_ref().is_some_and(|t| t.contains("$args")) || x.concurrency_key .as_ref() .is_some_and(|ck| ck.contains("$args")) }); - let mut tag = tag_and_concurrency_key - .as_ref() - .map(|x| x.tag.clone()) - .flatten(); + let require_args = concurrency_requires_args || has_debouncing; + let mut tag = tag_and_concurrency_key.as_ref().and_then(|x| x.tag.clone()); let concurrency_key = tag_and_concurrency_key .as_ref() - .map(|x| x.concurrency_key.clone()) - .flatten(); + .and_then(|x| x.concurrency_key.clone()); let concurrent_limit = tag_and_concurrency_key .as_ref() - .map(|x| x.concurrent_limit) - .flatten(); + .and_then(|x| x.concurrent_limit); let concurrency_time_window_s = tag_and_concurrency_key .as_ref() - .map(|x| x.concurrency_time_window_s) - .flatten(); - if require_args { + .and_then(|x| x.concurrency_time_window_s); + + let fetched_args = if require_args { let args = sqlx::query_scalar!( "SELECT result as \"result: Json>>\" FROM v2_job_completed @@ -1362,8 +1363,13 @@ pub async fn update_flow_status_after_job_completion_internal( .map_err(|e| { Error::internal_err(format!("error while fetching preprocessing args: {e:#}")) })?; - let args_hm = args.unwrap_or_default().0; - let args = PushArgs::from(&args_hm); + Some(args.unwrap_or_default().0) + } else { + None + }; + + if concurrency_requires_args { + let args = PushArgs::from(fetched_args.as_ref().unwrap()); if let Some(ck) = concurrency_key { insert_concurrency_key( &flow_job.workspace_id, @@ -1392,8 +1398,31 @@ pub async fn update_flow_status_after_job_completion_internal( .await?; } - // let tag = tag_and_concurrency_key.and_then(|tc| tc.tag.map(|t| interpolate_args(t.clone(), &args, &workspace_id))); - // let concurrency_key = tag_and_concurrency_key.and_then(|tc| tc.concurrency_key.map(|ck| interpolate_args(&ck, &args, &workspace_id))); + let scheduled_for: Option> = { + #[cfg(feature = "private")] + { + if has_debouncing { + let empty_hm = HashMap::new(); + let args = PushArgs::from(fetched_args.as_ref().unwrap_or(&empty_hm)); + windmill_queue::jobs_ee::maybe_debounce_post_preprocessing( + &flow_value.debouncing_settings, + &flow_job.runnable_path, + &flow_job.workspace_id, + flow, + &args, + db, + ) + .await? + } else { + None + } + } + #[cfg(not(feature = "private"))] + { + None + } + }; + sqlx::query!( "WITH job_result AS ( SELECT result @@ -1403,7 +1432,8 @@ pub async fn update_flow_status_after_job_completion_internal( updated_queue AS ( UPDATE v2_job_queue SET running = false, - tag = COALESCE($3, tag) + tag = COALESCE($3, tag), + scheduled_for = COALESCE($6, scheduled_for) WHERE id = $2 ) UPDATE v2_job @@ -1431,6 +1461,7 @@ pub async fn update_flow_status_after_job_completion_internal( tag, concurrent_limit, concurrency_time_window_s, + scheduled_for, ) .execute(db) .await diff --git a/benchmarks/benchmark_oneoff.ts b/benchmarks/benchmark_oneoff.ts index 6685eae1c5..0cd4c3483f 100644 --- a/benchmarks/benchmark_oneoff.ts +++ b/benchmarks/benchmark_oneoff.ts @@ -37,7 +37,7 @@ async function verifyOutputs(uuids: string[], workspace: string) { console.log(`Incorrect results: ${incorrectResults}`); } -export const NON_TEST_TAGS = ["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "flow"] +export const NON_TEST_TAGS = ["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets", "flow"] export async function main({ host, email, @@ -146,7 +146,7 @@ export async function main({ } if ( - ["deno", "python", "go", "bash", "dedicated", "bun", "nativets"].includes( + ["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets"].includes( kind ) ) { @@ -165,7 +165,7 @@ export async function main({ kind: "noop", }); } else if ( - ["deno", "python", "go", "bash", "dedicated", "bun", "nativets"].includes( + ["deno", "python", "go", "bash", "dedicated", "bun", "nativets", "dedicated_nativets"].includes( kind ) ) { @@ -336,6 +336,7 @@ export async function main({ !noVerify && kind !== "noop" && kind !== "nativets" && + kind !== "dedicated_nativets" && !kind.startsWith("flow:") && !kind.startsWith("script:") ) { @@ -386,7 +387,7 @@ if (import.meta.main) { ) .option( "--kind ", - "Specifiy the benchmark kind among: deno, identity, python, go, bash, dedicated, bun, noop, 2steps, nativets", + "Specifiy the benchmark kind among: deno, identity, python, go, bash, dedicated, bun, noop, 2steps, nativets, dedicated_nativets", { required: true, } diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 90ccdff448..138edfcbe0 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.638.4"; +export const VERSION = "v1.642.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ @@ -25,7 +25,7 @@ async function waitForDeployment(workspace: string, hash: string) { if (resp.lock !== null) { return; } - } catch (err) { } + } catch (err) {} await sleep(0.5); } throw new Error("Script did not deploy in time"); @@ -49,7 +49,7 @@ async function waitForDedicatedWorker(workspace: string, path: string) { export async function createBenchScript( scriptPattern: string, - workspace: string + workspace: string, ) { const path = `f/benchmarks/${scriptPattern}`; const exists = await windmill.ScriptService.existsScriptByPath({ @@ -93,11 +93,14 @@ export async function createBenchScript( language = "deno"; } else if (scriptPattern === "nativets") { scriptContent = - 'export async function main(){ return (await fetch(BASE_URL + "/api/version")).text() }'; - language = "nativets"; + '//native\nexport async function main(){ return (await fetch(BASE_URL + "/api/version")).text() }'; + language = "bunnative"; + } else if (scriptPattern === "dedicated_nativets") { + scriptContent = "//native\nexport function main(){ return 42; }"; + language = "bunnative"; } else { throw new Error( - "Could not create script for script pattern " + scriptPattern + "Could not create script for script pattern " + scriptPattern, ); } @@ -109,7 +112,8 @@ export async function createBenchScript( summary: scriptPattern + " benchmark", description: "", language: language as api.NewScript.language, - dedicated_worker: scriptPattern === "dedicated", + dedicated_worker: + scriptPattern === "dedicated" || scriptPattern === "dedicated_nativets", schema: { $schema: "https://json-schema.org/draft/2020-12/schema", properties: schemaProperties, @@ -123,7 +127,7 @@ export async function createBenchScript( console.log("Created benchmark script at path", path); - if (scriptPattern === "dedicated") { + if (scriptPattern === "dedicated" || scriptPattern === "dedicated_nativets") { await waitForDedicatedWorker(workspace, path); } } @@ -246,11 +250,15 @@ export const getFlowPayload = (flowPattern: string): api.FlowPreview => { input_transforms: {}, language: api.RawScript.language.BASH, type: "rawscript", - content: "# let's bloat that bash script, 3.. 2.. 1.. BOOM\n".repeat(100) + `if [[ -z $\{WM_FLOW_JOB_ID+x\} ]]; then\necho "not set"\nelif [[ -z "$WM_FLOW_JOB_ID" ]]; then\necho "empty"\nelse\necho "$WM_FLOW_JOB_ID"\nfi`, + content: + "# let's bloat that bash script, 3.. 2.. 1.. BOOM\n".repeat( + 100, + ) + + `if [[ -z $\{WM_FLOW_JOB_ID+x\} ]]; then\necho "not set"\nelif [[ -z "$WM_FLOW_JOB_ID" ]]; then\necho "empty"\nelse\necho "$WM_FLOW_JOB_ID"\nfi`, }, - } + }, ], - } + }, }; } else { return { diff --git a/benchmarks/suite_dedicated_nativets.json b/benchmarks/suite_dedicated_nativets.json new file mode 100644 index 0000000000..678532a616 --- /dev/null +++ b/benchmarks/suite_dedicated_nativets.json @@ -0,0 +1,6 @@ +[ + { "kind": "nativets", "jobs": 5000, "noSave": true }, + { "kind": "nativets", "jobs": 10000 }, + { "kind": "dedicated_nativets", "jobs": 5000, "noSave": true }, + { "kind": "dedicated_nativets", "jobs": 10000 } +] diff --git a/cli/.npmrc b/cli/.npmrc new file mode 100644 index 0000000000..41583e36ca --- /dev/null +++ b/cli/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/cli/build-npm.ts b/cli/build-npm.ts new file mode 100644 index 0000000000..72be8f1ca9 --- /dev/null +++ b/cli/build-npm.ts @@ -0,0 +1,83 @@ +import { VERSION } from "./src/main.ts"; +import { readFileSync, writeFileSync, rmSync, cpSync } from "node:fs"; +import { join } from "node:path"; + +const outDir = "./npm"; + +// Parser npm packages — used as externals and added to generated package.json +const parserPackages = [ + "windmill-parser-wasm-py", "windmill-parser-wasm-ts", + "windmill-parser-wasm-regex", "windmill-parser-wasm-go", + "windmill-parser-wasm-php", "windmill-parser-wasm-rust", + "windmill-parser-wasm-yaml", "windmill-parser-wasm-csharp", + "windmill-parser-wasm-nu", "windmill-parser-wasm-java", + "windmill-parser-wasm-ruby", +]; +const parserExternals = parserPackages.flatMap(p => ["--external", p]); + +// Clean output directory +rmSync(outDir, { recursive: true, force: true }); + +// Build with bun — bundle everything except esbuild (platform-specific binary), +// svelte (optional, only needed for `wmill app bundle/dev`), and parser packages +// (loaded at runtime via init() with readFileSync for the .wasm binary). +console.log("Bundling with bun build..."); +const buildResult = Bun.spawnSync([ + "bun", "build", "src/main.ts", + "--outdir", join(outDir, "esm"), + "--target", "node", + "--format", "esm", + "--external", "esbuild", + "--external", "svelte", + "--external", "svelte/compiler", + ...parserExternals, +], { cwd: import.meta.dir, stdout: "inherit", stderr: "inherit" }); + +if (buildResult.exitCode !== 0) { + console.error("Build failed"); + process.exit(1); +} + +// Add shebang to main.js +const mainJsPath = join(outDir, "esm", "main.js"); +const mainJs = readFileSync(mainJsPath, "utf-8"); +writeFileSync(mainJsPath, "#!/usr/bin/env node\n" + mainJs, "utf-8"); + +// Copy LICENSE and README +cpSync("../LICENSE", join(outDir, "LICENSE")); +cpSync("README.md", join(outDir, "README.md")); + +// Generate package.json +const packageJson = { + name: "windmill-cli", + version: VERSION, + description: "CLI for Windmill", + license: "Apache 2.0", + type: "module", + main: "esm/main.js", + bin: { + wmill: "esm/main.js", + }, + repository: { + type: "git", + url: "git+https://github.com/windmill-labs/windmill.git", + }, + bugs: { + url: "https://github.com/windmill-labs/windmill/issues", + }, + dependencies: { + esbuild: "^0.24.2", + ...Object.fromEntries(parserPackages.map(p => [p, "*"])), + }, + optionalDependencies: { + svelte: "^5.0.0", + }, +}; + +writeFileSync( + join(outDir, "package.json"), + JSON.stringify(packageJson, null, 2) + "\n", + "utf-8" +); + +console.log(`Built npm package v${VERSION} to ${outDir}/`); diff --git a/cli/build.sh b/cli/build.sh index 9a86ccbb54..62943ce042 100755 --- a/cli/build.sh +++ b/cli/build.sh @@ -9,12 +9,11 @@ set -e # Generate utils client files ./windmill-utils-internal/gen_wm_client.sh -# Add .ts extensions to windmill-utils-internal -./windmill-utils-internal/remove-ts-ext.sh -r +# Install dependencies +bun install -# Run dnt -echo "Running dnt..." -deno run -A dnt.ts +# Build npm package with bun +echo "Building npm package..." +bun run build-npm.ts echo "Build complete!" - diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 0000000000..7fea1c929b --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,312 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "windmill-cli-dev", + "dependencies": { + "@cliffy/ansi": "npm:@jsr/cliffy__ansi@1.0.0", + "@cliffy/command": "npm:@jsr/cliffy__command@1.0.0", + "@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0", + "@cliffy/table": "npm:@jsr/cliffy__table@1.0.0", + "@windmill-labs/shared-utils": "^1.0.12", + "diff": "^5.2.0", + "esbuild": "0.24.2", + "get-port": "7.1.0", + "jszip": "3.8.0", + "minimatch": "^10.0.0", + "open": "^10.0.0", + "svelte": "^5.45.2", + "tar-stream": "^3.1.7", + "windmill-parser-wasm-csharp": "*", + "windmill-parser-wasm-go": "*", + "windmill-parser-wasm-java": "*", + "windmill-parser-wasm-nu": "*", + "windmill-parser-wasm-php": "*", + "windmill-parser-wasm-py": "*", + "windmill-parser-wasm-regex": "*", + "windmill-parser-wasm-ruby": "*", + "windmill-parser-wasm-rust": "*", + "windmill-parser-wasm-ts": "*", + "windmill-parser-wasm-yaml": "*", + "windmill-yaml-validator": "1.1.1", + "ws": "8.18.0", + "yaml": "^2.7.0", + }, + "devDependencies": { + "@types/diff": "^5.2.3", + "@types/node": "^22.0.0", + "@types/tar-stream": "^3.1.4", + "@types/ws": "^8.5.0", + "typescript": "^5.7.0", + }, + }, + }, + "packages": { + "@cliffy/ansi": ["@jsr/cliffy__ansi@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__ansi/1.0.0.tgz", { "dependencies": { "@jsr/cliffy__internal": "1.0.0", "@jsr/std__encoding": "^1.0.10", "@jsr/std__fmt": "^1.0.9", "@jsr/std__io": "~0.225.3" } }, "sha512-JesgTdgR0aW1mZv96VqvRHr2efzr4MgDFMnoT+hkhaiCpmyBz33sHM5peAoMJUbGVfEfQAsysIXvvgoFYoveYg=="], + + "@cliffy/command": ["@jsr/cliffy__command@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__command/1.0.0.tgz", { "dependencies": { "@jsr/cliffy__flags": "1.0.0", "@jsr/cliffy__internal": "1.0.0", "@jsr/cliffy__table": "1.0.0", "@jsr/std__fmt": "^1.0.9", "@jsr/std__semver": "^1.0.8", "@jsr/std__text": "^1.0.17" } }, "sha512-oObplVtu1tvpkhgpuPDHZidx9g3axVOfRMQGmw7ZSGxp0+vZIJGiEtpcSvlN0XfuEhOG8neqfVBSSE9txrKanw=="], + + "@cliffy/prompt": ["@jsr/cliffy__prompt@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__prompt/1.0.0.tgz", { "dependencies": { "@jsr/cliffy__ansi": "1.0.0", "@jsr/cliffy__internal": "1.0.0", "@jsr/cliffy__keycode": "1.0.0", "@jsr/std__assert": "^1.0.18", "@jsr/std__fmt": "^1.0.9", "@jsr/std__io": "~0.225.3", "@jsr/std__path": "^1.1.4", "@jsr/std__text": "^1.0.17" } }, "sha512-JDuHcCAjScV0IUj389brneF6AzJyyP0pK8mymsrGN5/PGQfqK8zr96QpFlo1wmo8BY/3JQAdNfy6NZkPCJ6VWA=="], + + "@cliffy/table": ["@jsr/cliffy__table@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__table/1.0.0.tgz", { "dependencies": { "@jsr/std__fmt": "^1.0.9" } }, "sha512-VoLxH0DjofHWPWKUc5N+oCwXB6O6e+carnhp23yJTa7qokBb+SCrTIABEgQdIe/p0bxgmZhz17xt2efaAxXvbQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@jsr/cliffy__ansi": ["@jsr/cliffy__ansi@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__ansi/1.0.0.tgz", { "dependencies": { "@jsr/cliffy__internal": "1.0.0", "@jsr/std__encoding": "^1.0.10", "@jsr/std__fmt": "^1.0.9", "@jsr/std__io": "~0.225.3" } }, "sha512-JesgTdgR0aW1mZv96VqvRHr2efzr4MgDFMnoT+hkhaiCpmyBz33sHM5peAoMJUbGVfEfQAsysIXvvgoFYoveYg=="], + + "@jsr/cliffy__flags": ["@jsr/cliffy__flags@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__flags/1.0.0.tgz", { "dependencies": { "@jsr/cliffy__internal": "1.0.0", "@jsr/std__text": "^1.0.17" } }, "sha512-j/v3J8MWu0tkYyisZ2w1HxELxxL/qg6vey9+fRkbTJ+S9J0GeLUn2joouikG7aXpULKCXHTjJ9XH9gQx+F3npw=="], + + "@jsr/cliffy__internal": ["@jsr/cliffy__internal@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__internal/1.0.0.tgz", { "dependencies": { "@jsr/std__fmt": "^1.0.9" } }, "sha512-YPkbccbuu+kE55k+nia5jJx5Tu/IolBDXZTAgEA+YRGOzq8I1VkXajwykFXvSbXeVee3zQBU7y0HajVDB7ujQA=="], + + "@jsr/cliffy__keycode": ["@jsr/cliffy__keycode@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__keycode/1.0.0.tgz", {}, "sha512-1ot+y8oZheBTpfgCazWjSOAK2Y2nOQD7NwMuiSAkcRuc1t7VizQZfDpZtBx97NlkYWgjn6ylArt2xyhiyLKRhA=="], + + "@jsr/cliffy__table": ["@jsr/cliffy__table@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__table/1.0.0.tgz", { "dependencies": { "@jsr/std__fmt": "^1.0.9" } }, "sha512-VoLxH0DjofHWPWKUc5N+oCwXB6O6e+carnhp23yJTa7qokBb+SCrTIABEgQdIe/p0bxgmZhz17xt2efaAxXvbQ=="], + + "@jsr/std__assert": ["@jsr/std__assert@1.0.19", "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.19.tgz", { "dependencies": { "@jsr/std__internal": "^1.0.12" } }, "sha512-pEj6RPkGbqlgRmyKwATp4cUs6+ijxtdrv3bq8v1d2I2CEcMEyPaO8cVKro61wGRDH4cNg8Zx6haztvK/9m7gkA=="], + + "@jsr/std__bytes": ["@jsr/std__bytes@1.0.6", "https://npm.jsr.io/~/11/@jsr/std__bytes/1.0.6.tgz", {}, "sha512-St6yKggjFGhxS52IFLJWvkchRFbAKg2Xh8UxA4S1EGz7GJ2Ui+ssDDldj/w2c8vCxvl6qgR0HaYbKeFJNqujmA=="], + + "@jsr/std__encoding": ["@jsr/std__encoding@1.0.10", "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz", {}, "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw=="], + + "@jsr/std__fmt": ["@jsr/std__fmt@1.0.9", "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.9.tgz", {}, "sha512-YFJJMozmORj2K91c5J9opWeh0VUwrd+Mwb7Pr0FkVCAKVLu2UhT4LyvJqWiyUT+eF+MdfqQ9F7RtQj4bXn9Smw=="], + + "@jsr/std__internal": ["@jsr/std__internal@1.0.12", "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz", {}, "sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA=="], + + "@jsr/std__io": ["@jsr/std__io@0.225.3", "https://npm.jsr.io/~/11/@jsr/std__io/0.225.3.tgz", { "dependencies": { "@jsr/std__bytes": "^1.0.6" } }, "sha512-IDXY253ipW6FV34CJVxO+3ubfvSEEzw9N2W303KnLe9K/Y9+v/ID1dQYf9VsCCOFMpFtCmOLqzIZsRqv6yQnWw=="], + + "@jsr/std__path": ["@jsr/std__path@1.1.4", "https://npm.jsr.io/~/11/@jsr/std__path/1.1.4.tgz", { "dependencies": { "@jsr/std__internal": "^1.0.12" } }, "sha512-SK4u9H6NVTfolhPdlvdYXfNFefy1W04AEHWJydryYbk+xqzNiVmr5o7TLJLJFqwHXuwMRhwrn+mcYeUfS0YFaA=="], + + "@jsr/std__regexp": ["@jsr/std__regexp@1.0.1", "https://npm.jsr.io/~/11/@jsr/std__regexp/1.0.1.tgz", {}, "sha512-AnGeP//DHpPvhCWjI5dR4o013JhCQioD8yMF8drD7PWb0X4kvmO35hbZi+NZhfSolz4Ts2cpPzJY+DUpi2XE9A=="], + + "@jsr/std__semver": ["@jsr/std__semver@1.0.8", "https://npm.jsr.io/~/11/@jsr/std__semver/1.0.8.tgz", {}, "sha512-YhkykPU2Majz66e+rQbP0okYc7kKv+U32aguLPCXZZAL+vEVmBA+khHjPHhLBpWR073gzU3WHqGRgB7a/aXCjg=="], + + "@jsr/std__text": ["@jsr/std__text@1.0.17", "https://npm.jsr.io/~/11/@jsr/std__text/1.0.17.tgz", { "dependencies": { "@jsr/std__regexp": "^1.0.1" } }, "sha512-oZsihl1bcTy1Ixzven8rin8kjChj1zDJWqgpS0oSMGCJDzyB365gtIfAvcMmji+M+FcIWo3goDXfHcFYt+k/kg=="], + + "@stoplight/ordered-object-literal": ["@stoplight/ordered-object-literal@1.0.5", "", {}, "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg=="], + + "@stoplight/types": ["@stoplight/types@14.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.4", "utility-types": "^3.10.0" } }, "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g=="], + + "@stoplight/yaml": ["@stoplight/yaml@4.3.0", "", { "dependencies": { "@stoplight/ordered-object-literal": "^1.0.5", "@stoplight/types": "^14.1.1", "@stoplight/yaml-ast-parser": "0.0.50", "tslib": "^2.2.0" } }, "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w=="], + + "@stoplight/yaml-ast-parser": ["@stoplight/yaml-ast-parser@0.0.50", "", {}, "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@types/diff": ["@types/diff@5.2.3", "", {}, "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + + "@types/tar-stream": ["@types/tar-stream@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@windmill-labs/shared-utils": ["@windmill-labs/shared-utils@1.0.12", "", {}, "sha512-n68uEYv2B5q2Pp8J9syMS3qPZbppFEfeM7HIBEUfU5lGqi3hwnv4mPvgRUyb6K9im3frXC4gzdIdZdlrDpudXQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], + + "balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="], + + "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + + "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "devalue": ["devalue@5.6.3", "", {}, "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="], + + "diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], + + "esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "get-port": ["get-port@7.1.0", "", {}, "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "jszip": ["jszip@3.8.0", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "set-immediate-shim": "~1.0.1" } }, "sha512-cnpQrXvFSLdsR9KR5/x7zdf6c3m8IhZfZzSblFEHSqBaVwD2nvJ4CuCKLyvKvwBgZm08CgfSoiTBQLm5WW9hGw=="], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + + "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "set-immediate-shim": ["set-immediate-shim@1.0.1", "", {}, "sha512-Li5AOqrZWCVA2n5kryzEmqai6bKSIvpz5oUJHPVj6+dsbD3X1ixtsY5tEnsaNpH3pFAHmG8eIHUrtEtohrg+UQ=="], + + "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], + + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "svelte": ["svelte@5.53.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.3", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-WzxFHZhhD23Qzu7JCYdvm1rxvRSzdt9HtHO8TScMBX51bLRFTcJmATVqjqXG+6Ln6hrViGCo9DzwOhAasxwC/w=="], + + "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], + + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], + + "windmill-parser-wasm-csharp": ["windmill-parser-wasm-csharp@1.510.1", "", {}, "sha512-qm09YmnbeYHLwYn1jUnObVzPhYO9NZKMlIO7nlo7zPJBXqksgG5fK/KCtwGw9rChrnz+DsvM9wP5FhrwRLMtwQ=="], + + "windmill-parser-wasm-go": ["windmill-parser-wasm-go@1.510.1", "", {}, "sha512-HOkk6LXK0wrwvkn+zjm3Gxo90HmyL6TYqmLo2yp8fZuppy7GOngT27zwYeBtwONiPyvDKskzoqPQoEfd8VuUsQ=="], + + "windmill-parser-wasm-java": ["windmill-parser-wasm-java@1.510.1", "", {}, "sha512-Zle+JZT/ZwUArUVacUudYlS+CaHp2lSnkqD/IhWaRUG+gcv26VbERnrrHPonqXbVMS+eA9ElfXrFM5j0ukaXUw=="], + + "windmill-parser-wasm-nu": ["windmill-parser-wasm-nu@1.510.1", "", {}, "sha512-AJLFiUy6af+LpUe7CddDo4+JOmw3c0K/1iOWh8NdTwXcLDj90lL6089mdsVo1apyloLgrTbcuFDzZMXVGBgtCg=="], + + "windmill-parser-wasm-php": ["windmill-parser-wasm-php@1.574.1", "", {}, "sha512-COyid6B1RYs+bpzUCInsA4HY/WZkpDLfkQ90+AqU/TVTpzYSbAC2JCbIwy0cRElBvlhI4bQ+9Wg6hSQKMpEkpA=="], + + "windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.628.3", "", {}, "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw=="], + + "windmill-parser-wasm-regex": ["windmill-parser-wasm-regex@1.639.0", "", {}, "sha512-qvYM4sYxB6M0xrqwBljS2fWqOMk6rp++60TRltJnzZDzVaWQrKjTGwNMmfepGAIWy1OGVKp0SCVERhe2P+O6tQ=="], + + "windmill-parser-wasm-ruby": ["windmill-parser-wasm-ruby@1.526.1", "", {}, "sha512-rMBQA8s21wmL2kA5ztRs/ZgVA3ckxe9/NLjxl3iQPL0CX6DlvfaUH0O+AnhpXXDMyBs1Y1SZIhcnbnvsHZ3R8g=="], + + "windmill-parser-wasm-rust": ["windmill-parser-wasm-rust@1.558.1", "", {}, "sha512-21S7lm1KF8zO1187rbq14hzPHII2RdM2+D44MoAh1F6VoaScj+Puq0z5B1O/hwn/95R/a9jBlL2D8jbkXtlD1A=="], + + "windmill-parser-wasm-ts": ["windmill-parser-wasm-ts@1.623.1", "", {}, "sha512-FBwi/zXxjhZcCvi04oFdNivazru1ynIqSbafHSArfaaBWesBO3nye9UO/WXUlWZm5a7BExbU+3R/eVJrGaornw=="], + + "windmill-parser-wasm-yaml": ["windmill-parser-wasm-yaml@1.593.0", "", {}, "sha512-Gyx4aR2jsJYuDrD3mCNTmz7LWOQQXPw5yKNCC1xRgUOPfjsD/tINAFfsBLwVOSmlQQcFZO+wHm4KtDtXOcnGVw=="], + + "windmill-yaml-validator": ["windmill-yaml-validator@1.1.1", "", { "dependencies": { "@stoplight/yaml": "^4.3.0", "ajv": "^8.17.1" } }, "sha512-CVgAwEoBdJhF39q2N012QffhlGPRIyIWd8gj7NnfG+/lMWgH2k5CBLtKIt6cPF8Bxz+6DGC3st1ARSsecDtbTg=="], + + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + } +} diff --git a/cli/bunfig.toml b/cli/bunfig.toml new file mode 100644 index 0000000000..7fe1604012 --- /dev/null +++ b/cli/bunfig.toml @@ -0,0 +1,4 @@ +[test] +preload = ["./test/setup.ts"] +timeout = 60000 +root = "./test" diff --git a/cli/deno.json b/cli/deno.json deleted file mode 100644 index 0c14793da7..0000000000 --- a/cli/deno.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "imports": { - "@cliffy/ansi": "jsr:@windmill-labs/cliffy-ansi@^1.0.0-rc.5", - "@cliffy/command": "jsr:@windmill-labs/cliffy-command@^1.0.0-rc.5", - "@cliffy/prompt": "jsr:@windmill-labs/cliffy-prompt@^1.0.0-rc.6", - "@cliffy/table": "jsr:@windmill-labs/cliffy-table@^1.0.0-rc.5", - "@deno/dnt": "jsr:@deno/dnt@^0.41.3", - "@std/encoding": "jsr:@std/encoding@^1.0.10", - "@std/fs": "jsr:@std/fs@^1.0.21", - "@std/io": "jsr:@std/io@^0.224.9", - "@std/log": "jsr:@std/log@^0.224.14", - "@std/net": "jsr:@std/net@^1.0.6", - "@std/path": "jsr:@std/path@^1.1.4", - "@std/streams": "jsr:@std/streams@^1.0.16", - "@std/yaml": "jsr:@std/yaml@^1.0.10", - "@types/diff": "npm:@types/diff@^5.2.3", - "ws": "npm:ws@8.18.0" - }, - "nodeModulesDir": "auto" -} \ No newline at end of file diff --git a/cli/deno.lock b/cli/deno.lock deleted file mode 100644 index 9fcd2b7a64..0000000000 --- a/cli/deno.lock +++ /dev/null @@ -1,1780 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@david/code-block-writer@^13.0.2": "13.0.2", - "jsr:@david/code-block-writer@^13.0.3": "13.0.3", - "jsr:@deno/cache-dir@~0.10.3": "0.10.3", - "jsr:@deno/dnt@0.41.3": "0.41.3", - "jsr:@deno/dnt@0.42.3": "0.42.3", - "jsr:@deno/dnt@~0.41.3": "0.41.3", - "jsr:@deno/graph@~0.73.1": "0.73.1", - "jsr:@std/assert@0.223": "0.223.0", - "jsr:@std/assert@0.226": "0.226.0", - "jsr:@std/assert@1.0.0-rc.2": "1.0.0-rc.2", - "jsr:@std/bytes@0.223": "0.223.0", - "jsr:@std/bytes@^1.0.5": "1.0.6", - "jsr:@std/cli@1.0.0-rc.2": "1.0.0-rc.2", - "jsr:@std/encoding@1.0.0-rc.2": "1.0.0-rc.2", - "jsr:@std/encoding@1.0.4": "1.0.4", - "jsr:@std/fmt@0.223": "0.223.0", - "jsr:@std/fmt@1": "1.0.8", - "jsr:@std/fmt@^1.0.5": "1.0.8", - "jsr:@std/fmt@~0.225.4": "0.225.6", - "jsr:@std/fs@*": "1.0.22", - "jsr:@std/fs@0.223": "0.223.0", - "jsr:@std/fs@1": "1.0.22", - "jsr:@std/fs@^1.0.11": "1.0.22", - "jsr:@std/fs@^1.0.21": "1.0.22", - "jsr:@std/fs@~0.229.3": "0.229.3", - "jsr:@std/internal@^1.0.12": "1.0.12", - "jsr:@std/io@*": "0.225.2", - "jsr:@std/io@0.223": "0.223.0", - "jsr:@std/io@~0.224.2": "0.224.9", - "jsr:@std/io@~0.225.2": "0.225.2", - "jsr:@std/log@*": "0.224.14", - "jsr:@std/path@*": "1.1.4", - "jsr:@std/path@0.223": "0.223.0", - "jsr:@std/path@1": "1.1.4", - "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", - "jsr:@std/path@1.0.0-rc.2": "1.0.0-rc.2", - "jsr:@std/path@^1.1.3": "1.1.4", - "jsr:@std/path@^1.1.4": "1.1.4", - "jsr:@std/path@~0.225.2": "0.225.2", - "jsr:@std/text@1.0.0-rc.1": "1.0.0-rc.1", - "jsr:@std/yaml@*": "1.0.10", - "jsr:@std/yaml@^1.0.10": "1.0.10", - "jsr:@ts-morph/bootstrap@0.24": "0.24.0", - "jsr:@ts-morph/bootstrap@0.27": "0.27.0", - "jsr:@ts-morph/common@0.24": "0.24.0", - "jsr:@ts-morph/common@0.27": "0.27.0", - "jsr:@windmill-labs/cliffy-ansi@1.0.0-rc.5": "1.0.0-rc.5", - "jsr:@windmill-labs/cliffy-command@1.0.0-rc.5": "1.0.0-rc.5", - "jsr:@windmill-labs/cliffy-flags@1.0.0-rc.5": "1.0.0-rc.5", - "jsr:@windmill-labs/cliffy-internal@1.0.0-rc.5": "1.0.0-rc.5", - "jsr:@windmill-labs/cliffy-keycode@1.0.0-rc.5": "1.0.0-rc.5", - "jsr:@windmill-labs/cliffy-prompt@1.0.0-rc.6": "1.0.0-rc.6", - "jsr:@windmill-labs/cliffy-table@1.0.0-rc.5": "1.0.0-rc.5", - "jsr:@windmill-labs/shared-utils@1.0.10": "1.0.10", - "jsr:@windmill-labs/shared-utils@1.0.11": "1.0.11", - "jsr:@windmill-labs/shared-utils@1.0.12": "1.0.12", - "jsr:@windmill-labs/shared-utils@1.0.3": "1.0.3", - "jsr:@windmill-labs/shared-utils@1.0.5": "1.0.5", - "jsr:@windmill-labs/shared-utils@1.0.6": "1.0.6", - "jsr:@windmill-labs/shared-utils@1.0.7": "1.0.7", - "jsr:@windmill-labs/shared-utils@^1.0.10": "1.0.12", - "jsr:@windmill-labs/shared-utils@^1.0.12": "1.0.12", - "jsr:@windmill-labs/shared-utils@^1.0.8": "1.0.12", - "jsr:@windmill-labs/shared-utils@^1.0.9": "1.0.12", - "npm:@ayonli/jsext@*": "1.8.0", - "npm:@types/diff@^5.2.3": "5.2.3", - "npm:@types/node@*": "24.2.0", - "npm:@types/ws@*": "8.18.1", - "npm:@windmill-labs/shared-utils@1.0.1": "1.0.1", - "npm:@windmill-labs/shared-utils@1.0.2": "1.0.2", - "npm:centdix-utils@*": "1.0.15", - "npm:diff@*": "8.0.2", - "npm:es-main@*": "1.3.0", - "npm:esbuild-plugin-vue3@0.5.1": "0.5.1_vue@3.5.25__typescript@4.9.5_typescript@4.9.5", - "npm:esbuild-svelte@0.9.3": "0.9.3_esbuild@0.24.2_svelte@5.45.2__acorn@8.14.1", - "npm:esbuild@*": "0.24.2", - "npm:esbuild@0.24.2": "0.24.2", - "npm:express@*": "5.1.0", - "npm:get-port@7.1.0": "7.1.0", - "npm:jszip@3.7.1": "3.7.1", - "npm:jszip@3.8.0": "3.8.0", - "npm:minimatch@*": "10.0.3", - "npm:open@*": "10.2.0", - "npm:svelte-preprocess@6.0.3": "6.0.3_svelte@5.45.2__acorn@8.14.1", - "npm:svelte@5.45.2": "5.45.2_acorn@8.14.1", - "npm:windmill-yaml-validator@1.1.0": "1.1.0", - "npm:windmill-yaml-validator@1.1.1": "1.1.1", - "npm:ws@*": "8.18.3", - "npm:ws@8.18.0": "8.18.0", - "npm:ws@8.18.3": "8.18.3" - }, - "jsr": { - "@david/code-block-writer@13.0.2": { - "integrity": "14dd3baaafa3a2dea8bf7dfbcddeccaa13e583da2d21d666c01dc6d681cd74ad" - }, - "@david/code-block-writer@13.0.3": { - "integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f" - }, - "@deno/cache-dir@0.10.3": { - "integrity": "eb022f84ecc49c91d9d98131c6e6b118ff63a29e343624d058646b9d50404776", - "dependencies": [ - "jsr:@deno/graph", - "jsr:@std/fmt@0.223", - "jsr:@std/fs@0.223", - "jsr:@std/io@0.223", - "jsr:@std/path@0.223" - ] - }, - "@deno/dnt@0.41.3": { - "integrity": "b2ef2c8a5111eef86cb5bfcae103d6a2938e8e649e2461634a7befb7fc59d6d2", - "dependencies": [ - "jsr:@david/code-block-writer@^13.0.2", - "jsr:@deno/cache-dir", - "jsr:@std/fmt@1", - "jsr:@std/fs@1", - "jsr:@std/path@1", - "jsr:@ts-morph/bootstrap@0.24" - ] - }, - "@deno/dnt@0.42.3": { - "integrity": "62a917a0492f3c8af002dce90605bb0d41f7d29debc06aca40dba72ab65d8ae3", - "dependencies": [ - "jsr:@david/code-block-writer@^13.0.3", - "jsr:@std/fmt@1", - "jsr:@std/fs@1", - "jsr:@std/path@1", - "jsr:@ts-morph/bootstrap@0.27" - ] - }, - "@deno/graph@0.73.1": { - "integrity": "cd69639d2709d479037d5ce191a422eabe8d71bb68b0098344f6b07411c84d41" - }, - "@std/assert@0.223.0": { - "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" - }, - "@std/assert@0.226.0": { - "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3" - }, - "@std/assert@1.0.0-rc.2": { - "integrity": "0484eab1d76b55fca1c3beaff485a274e67dd3b9f065edcbe70030dfc0b964d3" - }, - "@std/bytes@0.223.0": { - "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" - }, - "@std/bytes@1.0.6": { - "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" - }, - "@std/cli@1.0.0-rc.2": { - "integrity": "97dfae82b9f0e189768ebfa7a5da53375955b94bad0a1804f8e3b73563b03787" - }, - "@std/encoding@1.0.0-rc.2": { - "integrity": "160d7674a20ebfbccdf610b3801fee91cf6e42d1c106dd46bbaf46e395cd35ef" - }, - "@std/encoding@1.0.4": { - "integrity": "2266cd516b32369e3dc5695717c96bf88343a1f761d6e6187a02a2bbe2af86ae" - }, - "@std/fmt@0.223.0": { - "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" - }, - "@std/fmt@0.225.6": { - "integrity": "aba6aea27f66813cecfd9484e074a9e9845782ab0685c030e453a8a70b37afc8" - }, - "@std/fmt@1.0.8": { - "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" - }, - "@std/fs@0.223.0": { - "integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c" - }, - "@std/fs@0.229.3": { - "integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb", - "dependencies": [ - "jsr:@std/path@1.0.0-rc.1" - ] - }, - "@std/fs@1.0.20": { - "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187", - "dependencies": [ - "jsr:@std/internal", - "jsr:@std/path@^1.1.3" - ] - }, - "@std/fs@1.0.22": { - "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308", - "dependencies": [ - "jsr:@std/internal", - "jsr:@std/path@^1.1.4" - ] - }, - "@std/internal@1.0.12": { - "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" - }, - "@std/io@0.223.0": { - "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", - "dependencies": [ - "jsr:@std/assert@0.223", - "jsr:@std/bytes@0.223" - ] - }, - "@std/io@0.224.9": { - "integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3" - }, - "@std/io@0.225.2": { - "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", - "dependencies": [ - "jsr:@std/bytes@^1.0.5" - ] - }, - "@std/log@0.224.14": { - "integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e", - "dependencies": [ - "jsr:@std/fmt@^1.0.5", - "jsr:@std/fs@^1.0.11", - "jsr:@std/io@~0.225.2" - ] - }, - "@std/path@0.223.0": { - "integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989", - "dependencies": [ - "jsr:@std/assert@0.223" - ] - }, - "@std/path@0.225.2": { - "integrity": "0f2db41d36b50ef048dcb0399aac720a5348638dd3cb5bf80685bf2a745aa506", - "dependencies": [ - "jsr:@std/assert@0.226" - ] - }, - "@std/path@1.0.0-rc.1": { - "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" - }, - "@std/path@1.0.0-rc.2": { - "integrity": "39f20d37a44d1867abac8d91c169359ea6e942237a45a99ee1e091b32b921c7d" - }, - "@std/path@1.1.3": { - "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/path@1.1.4": { - "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/text@1.0.0-rc.1": { - "integrity": "34c722203e87ee12792c8d4a0cd2ee0e001341cbce75b860fc21be19d62232b0" - }, - "@std/yaml@1.0.10": { - "integrity": "245706ea3511cc50c8c6d00339c23ea2ffa27bd2c7ea5445338f8feff31fa58e" - }, - "@ts-morph/bootstrap@0.24.0": { - "integrity": "a826a2ef7fa8a7c3f1042df2c034d20744d94da2ee32bf29275bcd4dffd3c060", - "dependencies": [ - "jsr:@ts-morph/common@0.24" - ] - }, - "@ts-morph/bootstrap@0.27.0": { - "integrity": "b8d7bc8f7942ce853dde4161b28f9aa96769cef3d8eebafb379a81800b9e2448", - "dependencies": [ - "jsr:@ts-morph/common@0.27" - ] - }, - "@ts-morph/common@0.24.0": { - "integrity": "12b625b8e562446ba658cdbe9ad77774b4bd96b992ae8bd34c60dbf24d06c1f3", - "dependencies": [ - "jsr:@std/fs@~0.229.3", - "jsr:@std/path@~0.225.2" - ] - }, - "@ts-morph/common@0.27.0": { - "integrity": "c7b73592d78ce8479b356fd4f3d6ec3c460d77753a8680ff196effea7a939052", - "dependencies": [ - "jsr:@std/fs@1", - "jsr:@std/path@1" - ] - }, - "@windmill-labs/cliffy-ansi@1.0.0-rc.5": { - "integrity": "1109cbcb0c415b57779352f708f5969b8c645f56bc555cbafc6ea5e0c6a360a4", - "dependencies": [ - "jsr:@std/encoding@1.0.0-rc.2", - "jsr:@std/fmt@~0.225.4", - "jsr:@std/io@~0.224.2", - "jsr:@windmill-labs/cliffy-internal" - ] - }, - "@windmill-labs/cliffy-command@1.0.0-rc.5": { - "integrity": "3eaa9def5f5afa1028f4a60ee4d9065ccc5f194d032824365c6ebcb9f46db66e", - "dependencies": [ - "jsr:@std/fmt@~0.225.4", - "jsr:@std/text", - "jsr:@windmill-labs/cliffy-flags", - "jsr:@windmill-labs/cliffy-internal", - "jsr:@windmill-labs/cliffy-table" - ] - }, - "@windmill-labs/cliffy-flags@1.0.0-rc.5": { - "integrity": "0e4b5b53a02295f8bf27d93b3bcca5d5d001a0286aea17404e6ef6347f69363c", - "dependencies": [ - "jsr:@std/text" - ] - }, - "@windmill-labs/cliffy-internal@1.0.0-rc.5": { - "integrity": "876b989ad2d1b739cc4a4f1386dbb80f819d2851ad583c1f486fc6ebe9beadf6" - }, - "@windmill-labs/cliffy-keycode@1.0.0-rc.5": { - "integrity": "2bc1b1af363528e38ed47bce525f417cab278b829a423353ffd589622f5a746e" - }, - "@windmill-labs/cliffy-prompt@1.0.0-rc.6": { - "integrity": "ffe09bee0e1e07bc12b2147be509d10b47eb6a36cbfea80fc206b4ae86693205", - "dependencies": [ - "jsr:@std/assert@1.0.0-rc.2", - "jsr:@std/fmt@~0.225.4", - "jsr:@std/io@~0.224.2", - "jsr:@std/path@1.0.0-rc.2", - "jsr:@std/text", - "jsr:@windmill-labs/cliffy-ansi", - "jsr:@windmill-labs/cliffy-internal", - "jsr:@windmill-labs/cliffy-keycode" - ] - }, - "@windmill-labs/cliffy-table@1.0.0-rc.5": { - "integrity": "5f26cb6ccbc2fbf1b1f79f9062d166e32e11d34398c0deac7e6f9d8378970546", - "dependencies": [ - "jsr:@std/cli", - "jsr:@std/fmt@~0.225.4" - ] - }, - "@windmill-labs/shared-utils@1.0.3": { - "integrity": "35bafaf74092ebb63e96c75897337320378c04f93cf9b352fcc2137ffdb3e862" - }, - "@windmill-labs/shared-utils@1.0.5": { - "integrity": "3709140dc40f89443dff5953ec2e7c35d964b71c5e1245fba4072cf513e0db91" - }, - "@windmill-labs/shared-utils@1.0.6": { - "integrity": "34965cbc8e4fda69835fed37435468e8ca1123dabe4ea395d700ecdb2fa49738" - }, - "@windmill-labs/shared-utils@1.0.7": { - "integrity": "528638c7c508910e7f51b1ad9a5f1ff394e3fefb28fd3f96ab958c258a26e978" - }, - "@windmill-labs/shared-utils@1.0.10": { - "integrity": "bd1993eb8d693c8ba49da1618f82ff4601eeb59011b2cac13e664291f7a299d8" - }, - "@windmill-labs/shared-utils@1.0.11": { - "integrity": "4878a841480ad98213759495d72d40be1aebbbacc693f8aa9fc649127722580b" - }, - "@windmill-labs/shared-utils@1.0.12": { - "integrity": "fc9d19d42523fa99d19168b762ce0649b10f34d5889f948d70afe73278ce4381" - } - }, - "npm": { - "@ayonli/jsext@1.8.0": { - "integrity": "sha512-haJSYDLDaddK2LV1vr/n34lfLqIMdy0PH4+mumLBWMFzjJXhTXew9v6cpkaj9ZJhTbKRb+v+ny/0x3RxlkABZw==", - "dependencies": [ - "iconv-lite", - "sudo-prompt", - "ws@8.18.3", - "zod" - ] - }, - "@babel/helper-string-parser@7.27.1": { - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==" - }, - "@babel/helper-validator-identifier@7.28.5": { - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==" - }, - "@babel/parser@7.28.5": { - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dependencies": [ - "@babel/types" - ], - "bin": true - }, - "@babel/types@7.28.5": { - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dependencies": [ - "@babel/helper-string-parser", - "@babel/helper-validator-identifier" - ] - }, - "@esbuild/aix-ppc64@0.24.2": { - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", - "os": ["aix"], - "cpu": ["ppc64"] - }, - "@esbuild/android-arm64@0.24.2": { - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@esbuild/android-arm@0.24.2": { - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", - "os": ["android"], - "cpu": ["arm"] - }, - "@esbuild/android-x64@0.24.2": { - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", - "os": ["android"], - "cpu": ["x64"] - }, - "@esbuild/darwin-arm64@0.24.2": { - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@esbuild/darwin-x64@0.24.2": { - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@esbuild/freebsd-arm64@0.24.2": { - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, - "@esbuild/freebsd-x64@0.24.2": { - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@esbuild/linux-arm64@0.24.2": { - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@esbuild/linux-arm@0.24.2": { - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@esbuild/linux-ia32@0.24.2": { - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", - "os": ["linux"], - "cpu": ["ia32"] - }, - "@esbuild/linux-loong64@0.24.2": { - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", - "os": ["linux"], - "cpu": ["loong64"] - }, - "@esbuild/linux-mips64el@0.24.2": { - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", - "os": ["linux"], - "cpu": ["mips64el"] - }, - "@esbuild/linux-ppc64@0.24.2": { - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@esbuild/linux-riscv64@0.24.2": { - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@esbuild/linux-s390x@0.24.2": { - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@esbuild/linux-x64@0.24.2": { - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@esbuild/netbsd-arm64@0.24.2": { - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", - "os": ["netbsd"], - "cpu": ["arm64"] - }, - "@esbuild/netbsd-x64@0.24.2": { - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", - "os": ["netbsd"], - "cpu": ["x64"] - }, - "@esbuild/openbsd-arm64@0.24.2": { - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", - "os": ["openbsd"], - "cpu": ["arm64"] - }, - "@esbuild/openbsd-x64@0.24.2": { - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", - "os": ["openbsd"], - "cpu": ["x64"] - }, - "@esbuild/sunos-x64@0.24.2": { - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", - "os": ["sunos"], - "cpu": ["x64"] - }, - "@esbuild/win32-arm64@0.24.2": { - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@esbuild/win32-ia32@0.24.2": { - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", - "os": ["win32"], - "cpu": ["ia32"] - }, - "@esbuild/win32-x64@0.24.2": { - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@isaacs/balanced-match@4.0.1": { - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==" - }, - "@isaacs/brace-expansion@5.0.0": { - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dependencies": [ - "@isaacs/balanced-match" - ] - }, - "@jridgewell/gen-mapping@0.3.13": { - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dependencies": [ - "@jridgewell/sourcemap-codec", - "@jridgewell/trace-mapping" - ] - }, - "@jridgewell/remapping@2.3.5": { - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dependencies": [ - "@jridgewell/gen-mapping", - "@jridgewell/trace-mapping" - ] - }, - "@jridgewell/resolve-uri@3.1.2": { - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" - }, - "@jridgewell/sourcemap-codec@1.5.5": { - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" - }, - "@jridgewell/trace-mapping@0.3.31": { - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dependencies": [ - "@jridgewell/resolve-uri", - "@jridgewell/sourcemap-codec" - ] - }, - "@stoplight/ordered-object-literal@1.0.5": { - "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==" - }, - "@stoplight/types@14.1.1": { - "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", - "dependencies": [ - "@types/json-schema", - "utility-types" - ] - }, - "@stoplight/yaml-ast-parser@0.0.50": { - "integrity": "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ==" - }, - "@stoplight/yaml@4.3.0": { - "integrity": "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w==", - "dependencies": [ - "@stoplight/ordered-object-literal", - "@stoplight/types", - "@stoplight/yaml-ast-parser", - "tslib" - ] - }, - "@sveltejs/acorn-typescript@1.0.7_acorn@8.14.1": { - "integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==", - "dependencies": [ - "acorn" - ] - }, - "@types/diff@5.2.3": { - "integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==" - }, - "@types/estree@1.0.8": { - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" - }, - "@types/json-schema@7.0.15": { - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" - }, - "@types/node@24.2.0": { - "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", - "dependencies": [ - "undici-types" - ] - }, - "@types/ws@8.18.1": { - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dependencies": [ - "@types/node" - ] - }, - "@vue/compiler-core@3.5.25": { - "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", - "dependencies": [ - "@babel/parser", - "@vue/shared", - "entities", - "estree-walker", - "source-map-js" - ] - }, - "@vue/compiler-dom@3.5.25": { - "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", - "dependencies": [ - "@vue/compiler-core", - "@vue/shared" - ] - }, - "@vue/compiler-sfc@3.5.25": { - "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", - "dependencies": [ - "@babel/parser", - "@vue/compiler-core", - "@vue/compiler-dom", - "@vue/compiler-ssr", - "@vue/shared", - "estree-walker", - "magic-string", - "postcss", - "source-map-js" - ] - }, - "@vue/compiler-ssr@3.5.25": { - "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", - "dependencies": [ - "@vue/compiler-dom", - "@vue/shared" - ] - }, - "@vue/reactivity@3.5.25": { - "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", - "dependencies": [ - "@vue/shared" - ] - }, - "@vue/runtime-core@3.5.25": { - "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", - "dependencies": [ - "@vue/reactivity", - "@vue/shared" - ] - }, - "@vue/runtime-dom@3.5.25": { - "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", - "dependencies": [ - "@vue/reactivity", - "@vue/runtime-core", - "@vue/shared", - "csstype" - ] - }, - "@vue/server-renderer@3.5.25_vue@3.5.25__typescript@4.9.5_typescript@4.9.5": { - "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", - "dependencies": [ - "@vue/compiler-ssr", - "@vue/shared", - "vue" - ] - }, - "@vue/shared@3.5.25": { - "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==" - }, - "@windmill-labs/shared-utils@1.0.1": { - "integrity": "sha512-DUMzPIFCKImuGpbuHXXmGGUT3VXYlgrv/jIIEOW+Iig+9tZvYqOUxfgn32lDhm73k82xBg8MdAf+0qABzfqFeQ==" - }, - "@windmill-labs/shared-utils@1.0.2": { - "integrity": "sha512-3LwALmwMeO3MqglGlyTtBUF05/ogpdDM5GiZKGN7271AEctS+ZJi3pXMHZ+YZdLxdgi2qLNNnVHO8qG5vFud2Q==" - }, - "accepts@2.0.0": { - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dependencies": [ - "mime-types", - "negotiator" - ] - }, - "acorn@8.14.1": { - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "bin": true - }, - "ajv@8.17.1": { - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dependencies": [ - "fast-deep-equal", - "fast-uri", - "json-schema-traverse", - "require-from-string" - ] - }, - "aria-query@5.3.2": { - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" - }, - "axobject-query@4.1.0": { - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" - }, - "body-parser@2.2.0": { - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "dependencies": [ - "bytes", - "content-type", - "debug", - "http-errors", - "iconv-lite", - "on-finished", - "qs", - "raw-body", - "type-is" - ] - }, - "bundle-name@4.1.0": { - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dependencies": [ - "run-applescript" - ] - }, - "bytes@3.1.2": { - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, - "call-bind-apply-helpers@1.0.2": { - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": [ - "es-errors", - "function-bind" - ] - }, - "call-bound@1.0.4": { - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dependencies": [ - "call-bind-apply-helpers", - "get-intrinsic" - ] - }, - "centdix-utils@1.0.15": { - "integrity": "sha512-bf7a8yAzEiA7a64dQZPZoAt2uGF4m2POEOSyxha6qRUe0j0HVj+WmOuBkFmFJMQlBxQmBxhj2o6lxZ+NtSFyGQ==", - "dependencies": [ - "windmill-client" - ] - }, - "clsx@2.1.1": { - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" - }, - "content-disposition@1.0.0": { - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dependencies": [ - "safe-buffer@5.2.1" - ] - }, - "content-type@1.0.5": { - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" - }, - "cookie-signature@1.2.2": { - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" - }, - "cookie@0.7.2": { - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" - }, - "core-util-is@1.0.3": { - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "csstype@3.2.3": { - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" - }, - "debug@4.4.1": { - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dependencies": [ - "ms" - ] - }, - "default-browser-id@5.0.0": { - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==" - }, - "default-browser@5.2.1": { - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "dependencies": [ - "bundle-name", - "default-browser-id" - ] - }, - "define-lazy-prop@3.0.0": { - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==" - }, - "depd@2.0.0": { - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "devalue@5.5.0": { - "integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==" - }, - "diff@8.0.2": { - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==" - }, - "dunder-proto@1.0.1": { - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": [ - "call-bind-apply-helpers", - "es-errors", - "gopd" - ] - }, - "ee-first@1.1.1": { - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "encodeurl@2.0.0": { - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" - }, - "entities@4.5.0": { - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" - }, - "es-define-property@1.0.1": { - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" - }, - "es-errors@1.3.0": { - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" - }, - "es-main@1.3.0": { - "integrity": "sha512-AzORKdz1Zt97TzbYQnIrI3ZiibWpRXUfpo/w0xOJ20GpNYd2bd3MU9m31zS/aJ1TJl6JfLTok83Y8HjNunYT0A==" - }, - "es-object-atoms@1.1.1": { - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": [ - "es-errors" - ] - }, - "esbuild-plugin-vue3@0.5.1_vue@3.5.25__typescript@4.9.5_typescript@4.9.5": { - "integrity": "sha512-rhTPImJ1Zi7FbVa4xWlu9dJdt+mqWxc9Z+AQd+ArbHHwtyQRe8FvER8gaTw0O6bNsBjAtU5rq0rpZEkP3QaThg==", - "dependencies": [ - "typescript", - "vue" - ] - }, - "esbuild-svelte@0.9.3_esbuild@0.24.2_svelte@5.45.2__acorn@8.14.1": { - "integrity": "sha512-CgEcGY1r/d16+aggec3czoFBEBaYIrFOnMxpsO6fWNaNEqHregPN5DLAPZDqrL7rXDNplW+WMu8s3GMq9FqgJA==", - "dependencies": [ - "@jridgewell/trace-mapping", - "esbuild", - "svelte" - ] - }, - "esbuild@0.24.2": { - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", - "optionalDependencies": [ - "@esbuild/aix-ppc64", - "@esbuild/android-arm", - "@esbuild/android-arm64", - "@esbuild/android-x64", - "@esbuild/darwin-arm64", - "@esbuild/darwin-x64", - "@esbuild/freebsd-arm64", - "@esbuild/freebsd-x64", - "@esbuild/linux-arm", - "@esbuild/linux-arm64", - "@esbuild/linux-ia32", - "@esbuild/linux-loong64", - "@esbuild/linux-mips64el", - "@esbuild/linux-ppc64", - "@esbuild/linux-riscv64", - "@esbuild/linux-s390x", - "@esbuild/linux-x64", - "@esbuild/netbsd-arm64", - "@esbuild/netbsd-x64", - "@esbuild/openbsd-arm64", - "@esbuild/openbsd-x64", - "@esbuild/sunos-x64", - "@esbuild/win32-arm64", - "@esbuild/win32-ia32", - "@esbuild/win32-x64" - ], - "scripts": true, - "bin": true - }, - "escape-html@1.0.3": { - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "esm-env@1.2.2": { - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" - }, - "esrap@2.2.0": { - "integrity": "sha512-WBmtxe7R9C5mvL4n2le8nMUe4mD5V9oiK2vJpQ9I3y20ENPUomPcphBXE8D1x/Bm84oN1V+lOfgXxtqmxTp3Xg==", - "dependencies": [ - "@jridgewell/sourcemap-codec" - ] - }, - "estree-walker@2.0.2": { - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" - }, - "etag@1.8.1": { - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" - }, - "express@5.1.0": { - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "dependencies": [ - "accepts", - "body-parser", - "content-disposition", - "content-type", - "cookie", - "cookie-signature", - "debug", - "encodeurl", - "escape-html", - "etag", - "finalhandler", - "fresh", - "http-errors", - "merge-descriptors", - "mime-types", - "on-finished", - "once", - "parseurl", - "proxy-addr", - "qs", - "range-parser", - "router", - "send", - "serve-static", - "statuses", - "type-is", - "vary" - ] - }, - "fast-deep-equal@3.1.3": { - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-uri@3.1.0": { - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==" - }, - "finalhandler@2.1.0": { - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "dependencies": [ - "debug", - "encodeurl", - "escape-html", - "on-finished", - "parseurl", - "statuses" - ] - }, - "forwarded@0.2.0": { - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" - }, - "fresh@2.0.0": { - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" - }, - "function-bind@1.1.2": { - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" - }, - "get-intrinsic@1.3.0": { - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": [ - "call-bind-apply-helpers", - "es-define-property", - "es-errors", - "es-object-atoms", - "function-bind", - "get-proto", - "gopd", - "has-symbols", - "hasown", - "math-intrinsics" - ] - }, - "get-port@7.1.0": { - "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==" - }, - "get-proto@1.0.1": { - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": [ - "dunder-proto", - "es-object-atoms" - ] - }, - "gopd@1.2.0": { - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" - }, - "has-symbols@1.1.0": { - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" - }, - "hasown@2.0.2": { - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": [ - "function-bind" - ] - }, - "http-errors@2.0.0": { - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": [ - "depd", - "inherits", - "setprototypeof", - "statuses", - "toidentifier" - ] - }, - "iconv-lite@0.6.3": { - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dependencies": [ - "safer-buffer" - ] - }, - "immediate@3.0.6": { - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" - }, - "inherits@2.0.4": { - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ipaddr.js@1.9.1": { - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" - }, - "is-docker@3.0.0": { - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "bin": true - }, - "is-inside-container@1.0.0": { - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dependencies": [ - "is-docker" - ], - "bin": true - }, - "is-promise@4.0.0": { - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" - }, - "is-reference@3.0.3": { - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dependencies": [ - "@types/estree" - ] - }, - "is-wsl@3.1.0": { - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dependencies": [ - "is-inside-container" - ] - }, - "isarray@1.0.0": { - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "json-schema-traverse@1.0.0": { - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "jszip@3.7.1": { - "integrity": "sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==", - "dependencies": [ - "lie", - "pako", - "readable-stream", - "set-immediate-shim" - ] - }, - "jszip@3.8.0": { - "integrity": "sha512-cnpQrXvFSLdsR9KR5/x7zdf6c3m8IhZfZzSblFEHSqBaVwD2nvJ4CuCKLyvKvwBgZm08CgfSoiTBQLm5WW9hGw==", - "dependencies": [ - "lie", - "pako", - "readable-stream", - "set-immediate-shim" - ] - }, - "lie@3.3.0": { - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dependencies": [ - "immediate" - ] - }, - "locate-character@3.0.0": { - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" - }, - "magic-string@0.30.21": { - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dependencies": [ - "@jridgewell/sourcemap-codec" - ] - }, - "math-intrinsics@1.1.0": { - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" - }, - "media-typer@1.1.0": { - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" - }, - "merge-descriptors@2.0.0": { - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" - }, - "mime-db@1.54.0": { - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" - }, - "mime-types@3.0.1": { - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dependencies": [ - "mime-db" - ] - }, - "minimatch@10.0.3": { - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "dependencies": [ - "@isaacs/brace-expansion" - ] - }, - "ms@2.1.3": { - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "nanoid@3.3.11": { - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "bin": true - }, - "negotiator@1.0.0": { - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" - }, - "object-inspect@1.13.4": { - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" - }, - "on-finished@2.4.1": { - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": [ - "ee-first" - ] - }, - "once@1.4.0": { - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": [ - "wrappy" - ] - }, - "open@10.2.0": { - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dependencies": [ - "default-browser", - "define-lazy-prop", - "is-inside-container", - "wsl-utils" - ] - }, - "pako@1.0.11": { - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" - }, - "parseurl@1.3.3": { - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" - }, - "path-to-regexp@8.2.0": { - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==" - }, - "picocolors@1.1.1": { - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "postcss@8.5.6": { - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dependencies": [ - "nanoid", - "picocolors", - "source-map-js" - ] - }, - "process-nextick-args@2.0.1": { - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "proxy-addr@2.0.7": { - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": [ - "forwarded", - "ipaddr.js" - ] - }, - "qs@6.14.0": { - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dependencies": [ - "side-channel" - ] - }, - "range-parser@1.2.1": { - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" - }, - "raw-body@3.0.0": { - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dependencies": [ - "bytes", - "http-errors", - "iconv-lite", - "unpipe" - ] - }, - "readable-stream@2.3.8": { - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": [ - "core-util-is", - "inherits", - "isarray", - "process-nextick-args", - "safe-buffer@5.1.2", - "string_decoder", - "util-deprecate" - ] - }, - "require-from-string@2.0.2": { - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" - }, - "router@2.2.0": { - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dependencies": [ - "debug", - "depd", - "is-promise", - "parseurl", - "path-to-regexp" - ] - }, - "run-applescript@7.0.0": { - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==" - }, - "safe-buffer@5.1.2": { - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "safe-buffer@5.2.1": { - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "safer-buffer@2.1.2": { - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "send@1.2.0": { - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "dependencies": [ - "debug", - "encodeurl", - "escape-html", - "etag", - "fresh", - "http-errors", - "mime-types", - "ms", - "on-finished", - "range-parser", - "statuses" - ] - }, - "serve-static@2.2.0": { - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "dependencies": [ - "encodeurl", - "escape-html", - "parseurl", - "send" - ] - }, - "set-immediate-shim@1.0.1": { - "integrity": "sha512-Li5AOqrZWCVA2n5kryzEmqai6bKSIvpz5oUJHPVj6+dsbD3X1ixtsY5tEnsaNpH3pFAHmG8eIHUrtEtohrg+UQ==" - }, - "setprototypeof@1.2.0": { - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "side-channel-list@1.0.0": { - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dependencies": [ - "es-errors", - "object-inspect" - ] - }, - "side-channel-map@1.0.1": { - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dependencies": [ - "call-bound", - "es-errors", - "get-intrinsic", - "object-inspect" - ] - }, - "side-channel-weakmap@1.0.2": { - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dependencies": [ - "call-bound", - "es-errors", - "get-intrinsic", - "object-inspect", - "side-channel-map" - ] - }, - "side-channel@1.1.0": { - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dependencies": [ - "es-errors", - "object-inspect", - "side-channel-list", - "side-channel-map", - "side-channel-weakmap" - ] - }, - "source-map-js@1.2.1": { - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" - }, - "statuses@2.0.1": { - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" - }, - "string_decoder@1.1.1": { - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": [ - "safe-buffer@5.1.2" - ] - }, - "sudo-prompt@9.2.1": { - "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", - "deprecated": true - }, - "svelte-preprocess@6.0.3_svelte@5.45.2__acorn@8.14.1": { - "integrity": "sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==", - "dependencies": [ - "svelte" - ], - "scripts": true - }, - "svelte@5.45.2_acorn@8.14.1": { - "integrity": "sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==", - "dependencies": [ - "@jridgewell/remapping", - "@jridgewell/sourcemap-codec", - "@sveltejs/acorn-typescript", - "@types/estree", - "acorn", - "aria-query", - "axobject-query", - "clsx", - "devalue", - "esm-env", - "esrap", - "is-reference", - "locate-character", - "magic-string", - "zimmerframe" - ] - }, - "toidentifier@1.0.1": { - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" - }, - "tslib@2.8.1": { - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "type-is@2.0.1": { - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dependencies": [ - "content-type", - "media-typer", - "mime-types" - ] - }, - "typescript@4.9.5": { - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "bin": true - }, - "undici-types@7.10.0": { - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" - }, - "unpipe@1.0.0": { - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" - }, - "util-deprecate@1.0.2": { - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "utility-types@3.11.0": { - "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==" - }, - "vary@1.1.2": { - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" - }, - "vue@3.5.25_typescript@4.9.5": { - "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", - "dependencies": [ - "@vue/compiler-dom", - "@vue/compiler-sfc", - "@vue/runtime-dom", - "@vue/server-renderer", - "@vue/shared", - "typescript" - ], - "optionalPeers": [ - "typescript" - ] - }, - "windmill-client@1.515.1": { - "integrity": "sha512-o6qynOEbPubZTZUOLLs2Z9f+uBZQJUCw/+YWgvI6p8nu5BJ6J3N/wEfbY1X5TTnJNuqahQ0UgimYzhurT5XQFw==" - }, - "windmill-yaml-validator@1.1.0": { - "integrity": "sha512-TM9rl6NycP4eXYOzi4Y8/EXHU4phzFUJWN28IlHDx4eRDNPEkj+6jAF4xUaBvLeFEJl0CfznEdhtM61vDgomKQ==", - "dependencies": [ - "@stoplight/yaml", - "ajv" - ] - }, - "windmill-yaml-validator@1.1.1": { - "integrity": "sha512-CVgAwEoBdJhF39q2N012QffhlGPRIyIWd8gj7NnfG+/lMWgH2k5CBLtKIt6cPF8Bxz+6DGC3st1ARSsecDtbTg==", - "dependencies": [ - "@stoplight/yaml", - "ajv" - ] - }, - "wrappy@1.0.2": { - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "ws@8.18.0": { - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==" - }, - "ws@8.18.3": { - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" - }, - "wsl-utils@0.1.0": { - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dependencies": [ - "is-wsl" - ] - }, - "zimmerframe@1.1.4": { - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" - }, - "zod@3.25.76": { - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" - } - }, - "remote": { - "https://deno.land/std@0.207.0/yaml/_dumper/dumper.ts": "717403d0e700de783f2ef5c906b3d7245383e1509fc050e7ff5d4a53a03dbf40", - "https://deno.land/std@0.207.0/yaml/_dumper/dumper_state.ts": "f0d0673ceea288334061ca34b63954c2bb5feb5bf6de5e4cfe9a942cdf6e5efe", - "https://deno.land/std@0.207.0/yaml/_error.ts": "b59e2c76ce5a47b1b9fa0ff9f96c1dd92ea1e1b17ce4347ece5944a95c3c1a84", - "https://deno.land/std@0.207.0/yaml/_loader/loader.ts": "63ec7f0a265dbbabc54b25a4beefff7650e205160a2d75c7d8f8363b5f84851a", - "https://deno.land/std@0.207.0/yaml/_loader/loader_state.ts": "0841870b467169269d7c2dfa75cd288c319bc06f65edd9e42c29e5fced91c7a4", - "https://deno.land/std@0.207.0/yaml/_mark.ts": "dcd8585dee585e024475e9f3fe27d29740670fb64ebb970388094cad0fc11d5d", - "https://deno.land/std@0.207.0/yaml/_state.ts": "ef03d55ec235d48dcfbecc0ab3ade90bfae69a61094846e08003421c2cf5cfc6", - "https://deno.land/std@0.207.0/yaml/_type/binary.ts": "24d49614463a7339a8a16d894919c2ec18a10588ae360ec352093b60e2cc8b0d", - "https://deno.land/std@0.207.0/yaml/_type/bool.ts": "5bfa75da84343d45347b521ba4e5aeace9fe6f53447405290d53315a3fc20e66", - "https://deno.land/std@0.207.0/yaml/_type/float.ts": "056bd3cb9c5586238b20517511014fb24b0e36f98f9f6073e12da308b6b9808a", - "https://deno.land/std@0.207.0/yaml/_type/function.ts": "ff574fe84a750695302864e1c31b93f12d14ada4bde79a5f93197fc33ad17471", - "https://deno.land/std@0.207.0/yaml/_type/int.ts": "563ad074f0fa7aecf6b6c3d84135bcc95a8269dcc15de878de20ce868fd773fa", - "https://deno.land/std@0.207.0/yaml/_type/map.ts": "7b105e4ab03a361c61e7e335a0baf4d40f06460b13920e5af3fb2783a1464000", - "https://deno.land/std@0.207.0/yaml/_type/merge.ts": "8192bf3e4d637f32567917f48bb276043da9cf729cf594e5ec191f7cd229337e", - "https://deno.land/std@0.207.0/yaml/_type/mod.ts": "060e2b3d38725094b77ea3a3f05fc7e671fced8e67ca18e525be98c4aa8f4bbb", - "https://deno.land/std@0.207.0/yaml/_type/nil.ts": "606e8f0c44d73117c81abec822f89ef81e40f712258c74f186baa1af659b8887", - "https://deno.land/std@0.207.0/yaml/_type/omap.ts": "cfe59a294726f5cea705c39a61fd2b08199cf48f4ccd6b040cb550ec0f38d0a1", - "https://deno.land/std@0.207.0/yaml/_type/pairs.ts": "0032fdfe57558d21696a4f8cf5b5cfd1f698743177080affc18629685c905666", - "https://deno.land/std@0.207.0/yaml/_type/regexp.ts": "1ce118de15b2da43b4bd8e4395f42d448b731acf3bdaf7c888f40789f9a95f8b", - "https://deno.land/std@0.207.0/yaml/_type/seq.ts": "95333abeec8a7e4d967b8c8328b269e342a4bbdd2585395549b9c4f58c8533a2", - "https://deno.land/std@0.207.0/yaml/_type/set.ts": "f28ba44e632ef2a6eb580486fd47a460445eeddbdf1dbc739c3e62486f566092", - "https://deno.land/std@0.207.0/yaml/_type/str.ts": "a67a3c6e429d95041399e964015511779b1130ea5889fa257c48457bd3446e31", - "https://deno.land/std@0.207.0/yaml/_type/timestamp.ts": "706ea80a76a73e48efaeb400ace087da1f927647b53ad6f754f4e06d51af087f", - "https://deno.land/std@0.207.0/yaml/_type/undefined.ts": "94a316ca450597ccbc6750cbd79097ad0d5f3a019797eed3c841a040c29540ba", - "https://deno.land/std@0.207.0/yaml/_utils.ts": "26b311f0d42a7ce025060bd6320a68b50e52fd24a839581eb31734cd48e20393", - "https://deno.land/std@0.207.0/yaml/mod.ts": "28ecda6652f3e7a7735ee29c247bfbd32a2e2fc5724068e9fd173ec4e59f66f7", - "https://deno.land/std@0.207.0/yaml/parse.ts": "1fbbda572bf3fff578b6482c0d8b85097a38de3176bf3ab2ca70c25fb0c960ef", - "https://deno.land/std@0.207.0/yaml/schema.ts": "96908b78dc50c340074b93fc1598d5e7e2fe59103f89ff81e5a49b2dedf77a67", - "https://deno.land/std@0.207.0/yaml/schema/core.ts": "fa406f18ceedc87a50e28bb90ec7a4c09eebb337f94ef17468349794fa828639", - "https://deno.land/std@0.207.0/yaml/schema/default.ts": "0047e80ae8a4a93293bc4c557ae8a546aabd46bb7165b9d9b940d57b4d88bde9", - "https://deno.land/std@0.207.0/yaml/schema/extended.ts": "0784416bf062d20a1626b53c03380e265b3e39b9409afb9f4cb7d659fd71e60d", - "https://deno.land/std@0.207.0/yaml/schema/failsafe.ts": "d219ab5febc43f770917d8ec37735a4b1ad671149846cbdcade767832b42b92b", - "https://deno.land/std@0.207.0/yaml/schema/json.ts": "5f41dd7c2f1ad545ef6238633ce9ee3d444dfc5a18101e1768bd5504bf90e5e5", - "https://deno.land/std@0.207.0/yaml/schema/mod.ts": "4472e827bab5025e92bc2eb2eeefa70ecbefc64b2799b765c69af84822efef32", - "https://deno.land/std@0.207.0/yaml/stringify.ts": "fffc09c65c68d3d63f8159e8cbaa3f489bc20a8e55b4fbb61a8c2e9f914d1d02", - "https://deno.land/std@0.207.0/yaml/type.ts": "65553da3da3c029b6589c6e4903f0afbea6768be8fca61580711457151f2b30f", - "https://deno.land/std@0.208.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", - "https://deno.land/std@0.208.0/assert/_diff.ts": "58e1461cc61d8eb1eacbf2a010932bf6a05b79344b02ca38095f9b805795dc48", - "https://deno.land/std@0.208.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.208.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", - "https://deno.land/std@0.208.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", - "https://deno.land/std@0.208.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", - "https://deno.land/std@0.208.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", - "https://deno.land/std@0.208.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", - "https://deno.land/std@0.208.0/assert/assert_false.ts": "0ccbcaae910f52c857192ff16ea08bda40fdc79de80846c206bfc061e8c851c6", - "https://deno.land/std@0.208.0/assert/assert_greater.ts": "ae2158a2d19313bf675bf7251d31c6dc52973edb12ac64ac8fc7064152af3e63", - "https://deno.land/std@0.208.0/assert/assert_greater_or_equal.ts": "1439da5ebbe20855446cac50097ac78b9742abe8e9a43e7de1ce1426d556e89c", - "https://deno.land/std@0.208.0/assert/assert_instance_of.ts": "3aedb3d8186e120812d2b3a5dea66a6e42bf8c57a8bd927645770bd21eea554c", - "https://deno.land/std@0.208.0/assert/assert_is_error.ts": "c21113094a51a296ffaf036767d616a78a2ae5f9f7bbd464cd0197476498b94b", - "https://deno.land/std@0.208.0/assert/assert_less.ts": "aec695db57db42ec3e2b62e97e1e93db0063f5a6ec133326cc290ff4b71b47e4", - "https://deno.land/std@0.208.0/assert/assert_less_or_equal.ts": "5fa8b6a3ffa20fd0a05032fe7257bf985d207b85685fdbcd23651b70f928c848", - "https://deno.land/std@0.208.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", - "https://deno.land/std@0.208.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", - "https://deno.land/std@0.208.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", - "https://deno.land/std@0.208.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", - "https://deno.land/std@0.208.0/assert/assert_not_strict_equals.ts": "4cdef83df17488df555c8aac1f7f5ec2b84ad161b6d0645ccdbcc17654e80c99", - "https://deno.land/std@0.208.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", - "https://deno.land/std@0.208.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", - "https://deno.land/std@0.208.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", - "https://deno.land/std@0.208.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", - "https://deno.land/std@0.208.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", - "https://deno.land/std@0.208.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", - "https://deno.land/std@0.208.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", - "https://deno.land/std@0.208.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", - "https://deno.land/std@0.208.0/assert/mod.ts": "37c49a26aae2b254bbe25723434dc28cd7532e444cf0b481a97c045d110ec085", - "https://deno.land/std@0.208.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", - "https://deno.land/std@0.208.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", - "https://deno.land/std@0.208.0/fmt/colors.ts": "34b3f77432925eb72cf0bfb351616949746768620b8e5ead66da532f93d10ba2", - "https://deno.land/std@0.208.0/path/_common/assert_path.ts": "061e4d093d4ba5aebceb2c4da3318bfe3289e868570e9d3a8e327d91c2958946", - "https://deno.land/std@0.208.0/path/_common/basename.ts": "0d978ff818f339cd3b1d09dc914881f4d15617432ae519c1b8fdc09ff8d3789a", - "https://deno.land/std@0.208.0/path/_common/common.ts": "9e4233b2eeb50f8b2ae10ecc2108f58583aea6fd3e8907827020282dc2b76143", - "https://deno.land/std@0.208.0/path/_common/constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", - "https://deno.land/std@0.208.0/path/_common/dirname.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397", - "https://deno.land/std@0.208.0/path/_common/format.ts": "11aa62e316dfbf22c126917f5e03ea5fe2ee707386555a8f513d27ad5756cf96", - "https://deno.land/std@0.208.0/path/_common/from_file_url.ts": "ef1bf3197d2efbf0297a2bdbf3a61d804b18f2bcce45548ae112313ec5be3c22", - "https://deno.land/std@0.208.0/path/_common/glob_to_reg_exp.ts": "5c3c2b79fc2294ec803d102bd9855c451c150021f452046312819fbb6d4dc156", - "https://deno.land/std@0.208.0/path/_common/normalize.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397", - "https://deno.land/std@0.208.0/path/_common/normalize_string.ts": "88c472f28ae49525f9fe82de8c8816d93442d46a30d6bb5063b07ff8a89ff589", - "https://deno.land/std@0.208.0/path/_common/relative.ts": "1af19d787a2a84b8c534cc487424fe101f614982ae4851382c978ab2216186b4", - "https://deno.land/std@0.208.0/path/_common/strip_trailing_separators.ts": "7ffc7c287e97bdeeee31b155828686967f222cd73f9e5780bfe7dfb1b58c6c65", - "https://deno.land/std@0.208.0/path/_common/to_file_url.ts": "a8cdd1633bc9175b7eebd3613266d7c0b6ae0fb0cff24120b6092ac31662f9ae", - "https://deno.land/std@0.208.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", - "https://deno.land/std@0.208.0/path/_os.ts": "30b0c2875f360c9296dbe6b7f2d528f0f9c741cecad2e97f803f5219e91b40a2", - "https://deno.land/std@0.208.0/path/basename.ts": "04bb5ef3e86bba8a35603b8f3b69537112cdd19ce64b77f2522006da2977a5f3", - "https://deno.land/std@0.208.0/path/common.ts": "f4d061c7d0b95a65c2a1a52439edec393e906b40f1caf4604c389fae7caa80f5", - "https://deno.land/std@0.208.0/path/dirname.ts": "88a0a71c21debafc4da7a4cd44fd32e899462df458fbca152390887d41c40361", - "https://deno.land/std@0.208.0/path/extname.ts": "2da4e2490f3b48b7121d19fb4c91681a5e11bd6bd99df4f6f47d7a71bb6ecdf2", - "https://deno.land/std@0.208.0/path/format.ts": "3457530cc85d1b4bab175f9ae73998b34fd456c830d01883169af0681b8894fb", - "https://deno.land/std@0.208.0/path/from_file_url.ts": "e7fa233ea1dff9641e8d566153a24d95010110185a6f418dd2e32320926043f8", - "https://deno.land/std@0.208.0/path/glob_to_regexp.ts": "74d7448c471e293d03f05ccb968df4365fed6aaa508506b6325a8efdc01d8271", - "https://deno.land/std@0.208.0/path/is_absolute.ts": "67232b41b860571c5b7537f4954c88d86ae2ba45e883ee37d3dec27b74909d13", - "https://deno.land/std@0.208.0/path/is_glob.ts": "567dce5c6656bdedfc6b3ee6c0833e1e4db2b8dff6e62148e94a917f289c06ad", - "https://deno.land/std@0.208.0/path/join.ts": "98d3d76c819af4a11a81d5ba2dbb319f1ce9d63fc2b615597d4bcfddd4a89a09", - "https://deno.land/std@0.208.0/path/join_globs.ts": "9b84d5103b63d3dbed4b2cf8b12477b2ad415c7d343f1488505162dc0e5f4db8", - "https://deno.land/std@0.208.0/path/mod.ts": "3defabebc98279e62b392fee7a6937adc932a8f4dcd2471441e36c15b97b00e0", - "https://deno.land/std@0.208.0/path/normalize.ts": "aa95be9a92c7bd4f9dc0ba51e942a1973e2b93d266cd74f5ca751c136d520b66", - "https://deno.land/std@0.208.0/path/normalize_glob.ts": "674baa82e1c00b6cb153bbca36e06f8e0337cb8062db6d905ab5de16076ca46b", - "https://deno.land/std@0.208.0/path/parse.ts": "d87ff0deef3fb495bc0d862278ff96da5a06acf0625ca27769fc52ac0d3d6ece", - "https://deno.land/std@0.208.0/path/posix/_util.ts": "ecf49560fedd7dd376c6156cc5565cad97c1abe9824f4417adebc7acc36c93e5", - "https://deno.land/std@0.208.0/path/posix/basename.ts": "a630aeb8fd8e27356b1823b9dedd505e30085015407caa3396332752f6b8406a", - "https://deno.land/std@0.208.0/path/posix/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b", - "https://deno.land/std@0.208.0/path/posix/dirname.ts": "f48c9c42cc670803b505478b7ef162c7cfa9d8e751b59d278b2ec59470531472", - "https://deno.land/std@0.208.0/path/posix/extname.ts": "ee7f6571a9c0a37f9218fbf510c440d1685a7c13082c348d701396cc795e0be0", - "https://deno.land/std@0.208.0/path/posix/format.ts": "b94876f77e61bfe1f147d5ccb46a920636cd3cef8be43df330f0052b03875968", - "https://deno.land/std@0.208.0/path/posix/from_file_url.ts": "b97287a83e6407ac27bdf3ab621db3fccbf1c27df0a1b1f20e1e1b5acf38a379", - "https://deno.land/std@0.208.0/path/posix/glob_to_regexp.ts": "6ed00c71fbfe0ccc35977c35444f94e82200b721905a60bd1278b1b768d68b1a", - "https://deno.land/std@0.208.0/path/posix/is_absolute.ts": "159900a3422d11069d48395568217eb7fc105ceda2683d03d9b7c0f0769e01b8", - "https://deno.land/std@0.208.0/path/posix/is_glob.ts": "ec4fbc604b9db8487f7b56ab0e759b24a971ab6a45f7b0b698bc39b8b9f9680f", - "https://deno.land/std@0.208.0/path/posix/join.ts": "0c0d84bdc344876930126640011ec1b888e6facf74153ffad9ef26813aa2a076", - "https://deno.land/std@0.208.0/path/posix/join_globs.ts": "f4838d54b1f60a34a40625a3293f6e583135348be1b2974341ac04743cb26121", - "https://deno.land/std@0.208.0/path/posix/mod.ts": "f1b08a7f64294b7de87fc37190d63b6ce5b02889af9290c9703afe01951360ae", - "https://deno.land/std@0.208.0/path/posix/normalize.ts": "11de90a94ab7148cc46e5a288f7d732aade1d616bc8c862f5560fa18ff987b4b", - "https://deno.land/std@0.208.0/path/posix/normalize_glob.ts": "10a1840c628ebbab679254d5fa1c20e59106102354fb648a1765aed72eb9f3f9", - "https://deno.land/std@0.208.0/path/posix/parse.ts": "199208f373dd93a792e9c585352bfc73a6293411bed6da6d3bc4f4ef90b04c8e", - "https://deno.land/std@0.208.0/path/posix/relative.ts": "e2f230608b0f083e6deaa06e063943e5accb3320c28aef8d87528fbb7fe6504c", - "https://deno.land/std@0.208.0/path/posix/resolve.ts": "51579d83159d5c719518c9ae50812a63959bbcb7561d79acbdb2c3682236e285", - "https://deno.land/std@0.208.0/path/posix/separator.ts": "0b6573b5f3269a3164d8edc9cefc33a02dd51003731c561008c8bb60220ebac1", - "https://deno.land/std@0.208.0/path/posix/to_file_url.ts": "08d43ea839ee75e9b8b1538376cfe95911070a655cd312bc9a00f88ef14967b6", - "https://deno.land/std@0.208.0/path/posix/to_namespaced_path.ts": "c9228a0e74fd37e76622cd7b142b8416663a9b87db643302fa0926b5a5c83bdc", - "https://deno.land/std@0.208.0/path/relative.ts": "23d45ede8b7ac464a8299663a43488aad6b561414e7cbbe4790775590db6349c", - "https://deno.land/std@0.208.0/path/resolve.ts": "5b184efc87155a0af9fa305ff68a109e28de9aee81fc3e77cd01380f19daf867", - "https://deno.land/std@0.208.0/path/separator.ts": "40a3e9a4ad10bef23bc2cd6c610291b6c502a06237c2c4cd034a15ca78dedc1f", - "https://deno.land/std@0.208.0/path/to_file_url.ts": "edaafa089e0bce386e1b2d47afe7c72e379ff93b28a5829a5885e4b6c626d864", - "https://deno.land/std@0.208.0/path/to_namespaced_path.ts": "cf8734848aac3c7527d1689d2adf82132b1618eff3cc523a775068847416b22a", - "https://deno.land/std@0.208.0/path/windows/_util.ts": "f32b9444554c8863b9b4814025c700492a2b57ff2369d015360970a1b1099d54", - "https://deno.land/std@0.208.0/path/windows/basename.ts": "8a9dbf7353d50afbc5b221af36c02a72c2d1b2b5b9f7c65bf6a5a2a0baf88ad3", - "https://deno.land/std@0.208.0/path/windows/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b", - "https://deno.land/std@0.208.0/path/windows/dirname.ts": "5c2aa541384bf0bd9aca821275d2a8690e8238fa846198ef5c7515ce31a01a94", - "https://deno.land/std@0.208.0/path/windows/extname.ts": "07f4fa1b40d06a827446b3e3bcc8d619c5546b079b8ed0c77040bbef716c7614", - "https://deno.land/std@0.208.0/path/windows/format.ts": "343019130d78f172a5c49fdc7e64686a7faf41553268961e7b6c92a6d6548edf", - "https://deno.land/std@0.208.0/path/windows/from_file_url.ts": "d53335c12b0725893d768be3ac6bf0112cc5b639d2deb0171b35988493b46199", - "https://deno.land/std@0.208.0/path/windows/glob_to_regexp.ts": "290755e18ec6c1a4f4d711c3390537358e8e3179581e66261a0cf348b1a13395", - "https://deno.land/std@0.208.0/path/windows/is_absolute.ts": "245b56b5f355ede8664bd7f080c910a97e2169972d23075554ae14d73722c53c", - "https://deno.land/std@0.208.0/path/windows/is_glob.ts": "ec4fbc604b9db8487f7b56ab0e759b24a971ab6a45f7b0b698bc39b8b9f9680f", - "https://deno.land/std@0.208.0/path/windows/join.ts": "e6600bf88edeeef4e2276e155b8de1d5dec0435fd526ba2dc4d37986b2882f16", - "https://deno.land/std@0.208.0/path/windows/join_globs.ts": "f4838d54b1f60a34a40625a3293f6e583135348be1b2974341ac04743cb26121", - "https://deno.land/std@0.208.0/path/windows/mod.ts": "d7040f461465c2c21c1c68fc988ef0bdddd499912138cde3abf6ad60c7fb3814", - "https://deno.land/std@0.208.0/path/windows/normalize.ts": "9deebbf40c81ef540b7b945d4ccd7a6a2c5a5992f791e6d3377043031e164e69", - "https://deno.land/std@0.208.0/path/windows/normalize_glob.ts": "344ff5ed45430495b9a3d695567291e50e00b1b3b04ea56712a2acf07ab5c128", - "https://deno.land/std@0.208.0/path/windows/parse.ts": "120faf778fe1f22056f33ded069b68e12447668fcfa19540c0129561428d3ae5", - "https://deno.land/std@0.208.0/path/windows/relative.ts": "026855cd2c36c8f28f1df3c6fbd8f2449a2aa21f48797a74700c5d872b86d649", - "https://deno.land/std@0.208.0/path/windows/resolve.ts": "5ff441ab18a2346abadf778121128ee71bda4d0898513d4639a6ca04edca366b", - "https://deno.land/std@0.208.0/path/windows/separator.ts": "ae21f27015f10510ed1ac4a0ba9c4c9c967cbdd9d9e776a3e4967553c397bd5d", - "https://deno.land/std@0.208.0/path/windows/to_file_url.ts": "8e9ea9e1ff364aa06fa72999204229952d0a279dbb876b7b838b2b2fea55cce3", - "https://deno.land/std@0.208.0/path/windows/to_namespaced_path.ts": "e0f4d4a5e77f28a5708c1a33ff24360f35637ba6d8f103d19661255ef7bfd50d", - "https://deno.land/std@0.208.0/testing/asserts.ts": "605bbd2ef0695e2a4324d810c4ad22e56041d51afb9584fc0b4e81084b14b1d6", - "https://deno.land/std@0.213.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", - "https://deno.land/std@0.213.0/assert/_diff.ts": "dcc63d94ca289aec80644030cf88ccbf7acaa6fbd7b0f22add93616b36593840", - "https://deno.land/std@0.213.0/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4", - "https://deno.land/std@0.213.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", - "https://deno.land/std@0.213.0/assert/assert_almost_equals.ts": "8b96b7385cc117668b0720115eb6ee73d04c9bcb2f5d2344d674918c9113688f", - "https://deno.land/std@0.213.0/assert/assert_array_includes.ts": "1688d76317fd45b7e93ef9e2765f112fdf2b7c9821016cdfb380b9445374aed1", - "https://deno.land/std@0.213.0/assert/assert_equals.ts": "4497c56fe7d2993b0d447926702802fc0becb44e319079e8eca39b482ee01b4e", - "https://deno.land/std@0.213.0/assert/assert_exists.ts": "24a7bf965e634f909242cd09fbaf38bde6b791128ece08e33ab08586a7cc55c9", - "https://deno.land/std@0.213.0/assert/assert_false.ts": "6f382568e5128c0f855e5f7dbda8624c1ed9af4fcc33ef4a9afeeedcdce99769", - "https://deno.land/std@0.213.0/assert/assert_greater.ts": "4945cf5729f1a38874d7e589e0fe5cc5cd5abe5573ca2ddca9d3791aa891856c", - "https://deno.land/std@0.213.0/assert/assert_greater_or_equal.ts": "573ed8823283b8d94b7443eb69a849a3c369a8eb9666b2d1db50c33763a5d219", - "https://deno.land/std@0.213.0/assert/assert_instance_of.ts": "72dc1faff1e248692d873c89382fa1579dd7b53b56d52f37f9874a75b11ba444", - "https://deno.land/std@0.213.0/assert/assert_is_error.ts": "6596f2b5ba89ba2fe9b074f75e9318cda97a2381e59d476812e30077fbdb6ed2", - "https://deno.land/std@0.213.0/assert/assert_less.ts": "2b4b3fe7910f65f7be52212f19c3977ecb8ba5b2d6d0a296c83cde42920bb005", - "https://deno.land/std@0.213.0/assert/assert_less_or_equal.ts": "b93d212fe669fbde959e35b3437ac9a4468f2e6b77377e7b6ea2cfdd825d38a0", - "https://deno.land/std@0.213.0/assert/assert_match.ts": "ec2d9680ed3e7b9746ec57ec923a17eef6d476202f339ad91d22277d7f1d16e1", - "https://deno.land/std@0.213.0/assert/assert_not_equals.ts": "f3edda73043bc2c9fae6cbfaa957d5c69bbe76f5291a5b0466ed132c8789df4c", - "https://deno.land/std@0.213.0/assert/assert_not_instance_of.ts": "8f720d92d83775c40b2542a8d76c60c2d4aeddaf8713c8d11df8984af2604931", - "https://deno.land/std@0.213.0/assert/assert_not_match.ts": "b4b7c77f146963e2b673c1ce4846473703409eb93f5ab0eb60f6e6f8aeffe39f", - "https://deno.land/std@0.213.0/assert/assert_not_strict_equals.ts": "da0b8ab60a45d5a9371088378e5313f624799470c3b54c76e8b8abeec40a77be", - "https://deno.land/std@0.213.0/assert/assert_object_match.ts": "e85e5eef62a56ce364c3afdd27978ccab979288a3e772e6855c270a7b118fa49", - "https://deno.land/std@0.213.0/assert/assert_rejects.ts": "e9e0c8d9c3e164c7ac962c37b3be50577c5a2010db107ed272c4c1afb1269f54", - "https://deno.land/std@0.213.0/assert/assert_strict_equals.ts": "0425a98f70badccb151644c902384c12771a93e65f8ff610244b8147b03a2366", - "https://deno.land/std@0.213.0/assert/assert_string_includes.ts": "dfb072a890167146f8e5bdd6fde887ce4657098e9f71f12716ef37f35fb6f4a7", - "https://deno.land/std@0.213.0/assert/assert_throws.ts": "edddd86b39606c342164b49ad88dd39a26e72a26655e07545d172f164b617fa7", - "https://deno.land/std@0.213.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", - "https://deno.land/std@0.213.0/assert/equal.ts": "fae5e8a52a11d3ac694bbe1a53e13a7969e3f60791262312e91a3e741ae519e2", - "https://deno.land/std@0.213.0/assert/fail.ts": "f310e51992bac8e54f5fd8e44d098638434b2edb802383690e0d7a9be1979f1c", - "https://deno.land/std@0.213.0/assert/mod.ts": "325df8c0683ad83a873b9691aa66b812d6275fc9fec0b2d180ac68a2c5efed3b", - "https://deno.land/std@0.213.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd", - "https://deno.land/std@0.213.0/assert/unreachable.ts": "38cfecb95d8b06906022d2f9474794fca4161a994f83354fd079cac9032b5145", - "https://deno.land/std@0.213.0/fmt/colors.ts": "aeaee795471b56fc62a3cb2e174ed33e91551b535f44677f6320336aabb54fbb", - "https://deno.land/std@0.213.0/testing/_test_suite.ts": "f10a8a6338b60c403f07a76f3f46bdc9f1e1a820c0a1decddeb2949f7a8a0546", - "https://deno.land/std@0.213.0/testing/bdd.ts": "3cbd17bd35f629a76ce63446238dfb4632240dd46b3b205027c45fa3dd67e554", - "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", - "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", - "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", - "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", - "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", - "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", - "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", - "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", - "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", - "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", - "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", - "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", - "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", - "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", - "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", - "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", - "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", - "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", - "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", - "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", - "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", - "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", - "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", - "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", - "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", - "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", - "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", - "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", - "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", - "https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece", - "https://deno.land/std@0.224.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376", - "https://deno.land/std@0.224.0/encoding/hex.ts": "6270f25e5d85f99fcf315278670ba012b04b7c94b67715b53f30d03249687c07", - "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", - "https://deno.land/std@0.224.0/fs/_create_walk_entry.ts": "5d9d2aaec05bcf09a06748b1684224d33eba7a4de24cf4cf5599991ca6b5b412", - "https://deno.land/std@0.224.0/fs/_get_file_info_type.ts": "da7bec18a7661dba360a1db475b826b18977582ce6fc9b25f3d4ee0403fe8cbd", - "https://deno.land/std@0.224.0/fs/_is_same_path.ts": "709c95868345fea051c58b9e96af95cff94e6ae98dfcff2b66dee0c212c4221f", - "https://deno.land/std@0.224.0/fs/_is_subdir.ts": "c68b309d46cc8568ed83c000f608a61bbdba0943b7524e7a30f9e450cf67eecd", - "https://deno.land/std@0.224.0/fs/_to_path_string.ts": "29bfc9c6c112254961d75cbf6ba814d6de5349767818eb93090cecfa9665591e", - "https://deno.land/std@0.224.0/fs/copy.ts": "7ab12a16adb65d155d4943c88081ca16ce3b0b5acada64c1ce93800653678039", - "https://deno.land/std@0.224.0/fs/empty_dir.ts": "e400e96e1d2c8c558a5a1712063bd43939e00619c1d1cc29959babc6f1639418", - "https://deno.land/std@0.224.0/fs/ensure_dir.ts": "51a6279016c65d2985f8803c848e2888e206d1b510686a509fa7cc34ce59d29f", - "https://deno.land/std@0.224.0/fs/ensure_file.ts": "67608cf550529f3d4aa1f8b6b36bf817bdc40b14487bf8f60e61cbf68f507cf3", - "https://deno.land/std@0.224.0/fs/ensure_link.ts": "5c98503ebfa9cc05e2f2efaa30e91e60b4dd5b43ebbda82f435c0a5c6e3ffa01", - "https://deno.land/std@0.224.0/fs/ensure_symlink.ts": "cafe904cebacb9a761977d6dbf5e3af938be946a723bb394080b9a52714fafe4", - "https://deno.land/std@0.224.0/fs/eol.ts": "18c4ac009d0318504c285879eb7f47942643f13619e0ff070a0edc59353306bd", - "https://deno.land/std@0.224.0/fs/exists.ts": "3d38cb7dcbca3cf313be343a7b8af18a87bddb4b5ca1bd2314be12d06533b50f", - "https://deno.land/std@0.224.0/fs/expand_glob.ts": "2e428d90acc6676b2aa7b5c78ef48f30641b13f1fe658e7976c9064fb4b05309", - "https://deno.land/std@0.224.0/fs/mod.ts": "c25e6802cbf27f3050f60b26b00c2d8dba1cb7fcdafe34c66006a7473b7b34d4", - "https://deno.land/std@0.224.0/fs/move.ts": "ca205d848908d7f217353bc5c623627b1333490b8b5d3ef4cab600a700c9bd8f", - "https://deno.land/std@0.224.0/fs/walk.ts": "cddf87d2705c0163bff5d7767291f05b0f46ba10b8b28f227c3849cace08d303", - "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", - "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", - "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", - "https://deno.land/std@0.224.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8", - "https://deno.land/std@0.224.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", - "https://deno.land/std@0.224.0/path/_common/common.ts": "ef73c2860694775fe8ffcbcdd387f9f97c7a656febf0daa8c73b56f4d8a7bd4c", - "https://deno.land/std@0.224.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", - "https://deno.land/std@0.224.0/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", - "https://deno.land/std@0.224.0/path/_common/format.ts": "92500e91ea5de21c97f5fe91e178bae62af524b72d5fcd246d6d60ae4bcada8b", - "https://deno.land/std@0.224.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf", - "https://deno.land/std@0.224.0/path/_common/glob_to_reg_exp.ts": "6cac16d5c2dc23af7d66348a7ce430e5de4e70b0eede074bdbcf4903f4374d8d", - "https://deno.land/std@0.224.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", - "https://deno.land/std@0.224.0/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3", - "https://deno.land/std@0.224.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607", - "https://deno.land/std@0.224.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", - "https://deno.land/std@0.224.0/path/_common/to_file_url.ts": "7f76adbc83ece1bba173e6e98a27c647712cab773d3f8cbe0398b74afc817883", - "https://deno.land/std@0.224.0/path/_interface.ts": "8dfeb930ca4a772c458a8c7bbe1e33216fe91c253411338ad80c5b6fa93ddba0", - "https://deno.land/std@0.224.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", - "https://deno.land/std@0.224.0/path/basename.ts": "7ee495c2d1ee516ffff48fb9a93267ba928b5a3486b550be73071bc14f8cc63e", - "https://deno.land/std@0.224.0/path/common.ts": "03e52e22882402c986fe97ca3b5bb4263c2aa811c515ce84584b23bac4cc2643", - "https://deno.land/std@0.224.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36", - "https://deno.land/std@0.224.0/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c", - "https://deno.land/std@0.224.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441", - "https://deno.land/std@0.224.0/path/format.ts": "6ce1779b0980296cf2bc20d66436b12792102b831fd281ab9eb08fa8a3e6f6ac", - "https://deno.land/std@0.224.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069", - "https://deno.land/std@0.224.0/path/glob_to_regexp.ts": "7f30f0a21439cadfdae1be1bf370880b415e676097fda584a63ce319053b5972", - "https://deno.land/std@0.224.0/path/is_absolute.ts": "4791afc8bfd0c87f0526eaa616b0d16e7b3ab6a65b62942e50eac68de4ef67d7", - "https://deno.land/std@0.224.0/path/is_glob.ts": "a65f6195d3058c3050ab905705891b412ff942a292bcbaa1a807a74439a14141", - "https://deno.land/std@0.224.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a", - "https://deno.land/std@0.224.0/path/join_globs.ts": "5b3bf248b93247194f94fa6947b612ab9d3abd571ca8386cf7789038545e54a0", - "https://deno.land/std@0.224.0/path/mod.ts": "f6bd79cb08be0e604201bc9de41ac9248582699d1b2ee0ab6bc9190d472cf9cd", - "https://deno.land/std@0.224.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352", - "https://deno.land/std@0.224.0/path/normalize_glob.ts": "cc89a77a7d3b1d01053b9dcd59462b75482b11e9068ae6c754b5cf5d794b374f", - "https://deno.land/std@0.224.0/path/parse.ts": "77ad91dcb235a66c6f504df83087ce2a5471e67d79c402014f6e847389108d5a", - "https://deno.land/std@0.224.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", - "https://deno.land/std@0.224.0/path/posix/basename.ts": "d2fa5fbbb1c5a3ab8b9326458a8d4ceac77580961b3739cd5bfd1d3541a3e5f0", - "https://deno.land/std@0.224.0/path/posix/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", - "https://deno.land/std@0.224.0/path/posix/constants.ts": "93481efb98cdffa4c719c22a0182b994e5a6aed3047e1962f6c2c75b7592bef1", - "https://deno.land/std@0.224.0/path/posix/dirname.ts": "76cd348ffe92345711409f88d4d8561d8645353ac215c8e9c80140069bf42f00", - "https://deno.land/std@0.224.0/path/posix/extname.ts": "e398c1d9d1908d3756a7ed94199fcd169e79466dd88feffd2f47ce0abf9d61d2", - "https://deno.land/std@0.224.0/path/posix/format.ts": "185e9ee2091a42dd39e2a3b8e4925370ee8407572cee1ae52838aed96310c5c1", - "https://deno.land/std@0.224.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40", - "https://deno.land/std@0.224.0/path/posix/glob_to_regexp.ts": "76f012fcdb22c04b633f536c0b9644d100861bea36e9da56a94b9c589a742e8f", - "https://deno.land/std@0.224.0/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede", - "https://deno.land/std@0.224.0/path/posix/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", - "https://deno.land/std@0.224.0/path/posix/join.ts": "7fc2cb3716aa1b863e990baf30b101d768db479e70b7313b4866a088db016f63", - "https://deno.land/std@0.224.0/path/posix/join_globs.ts": "a9475b44645feddceb484ee0498e456f4add112e181cb94042cdc6d47d1cdd25", - "https://deno.land/std@0.224.0/path/posix/mod.ts": "2301fc1c54a28b349e20656f68a85f75befa0ee9b6cd75bfac3da5aca9c3f604", - "https://deno.land/std@0.224.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91", - "https://deno.land/std@0.224.0/path/posix/normalize_glob.ts": "9c87a829b6c0f445d03b3ecadc14492e2864c3ebb966f4cea41e98326e4435c6", - "https://deno.land/std@0.224.0/path/posix/parse.ts": "09dfad0cae530f93627202f28c1befa78ea6e751f92f478ca2cc3b56be2cbb6a", - "https://deno.land/std@0.224.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c", - "https://deno.land/std@0.224.0/path/posix/resolve.ts": "08b699cfeee10cb6857ccab38fa4b2ec703b0ea33e8e69964f29d02a2d5257cf", - "https://deno.land/std@0.224.0/path/posix/to_file_url.ts": "7aa752ba66a35049e0e4a4be5a0a31ac6b645257d2e031142abb1854de250aaf", - "https://deno.land/std@0.224.0/path/posix/to_namespaced_path.ts": "28b216b3c76f892a4dca9734ff1cc0045d135532bfd9c435ae4858bfa5a2ebf0", - "https://deno.land/std@0.224.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add", - "https://deno.land/std@0.224.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d", - "https://deno.land/std@0.224.0/path/to_file_url.ts": "88f049b769bce411e2d2db5bd9e6fd9a185a5fbd6b9f5ad8f52bef517c4ece1b", - "https://deno.land/std@0.224.0/path/to_namespaced_path.ts": "b706a4103b104cfadc09600a5f838c2ba94dbcdb642344557122dda444526e40", - "https://deno.land/std@0.224.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", - "https://deno.land/std@0.224.0/path/windows/basename.ts": "6bbc57bac9df2cec43288c8c5334919418d784243a00bc10de67d392ab36d660", - "https://deno.land/std@0.224.0/path/windows/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", - "https://deno.land/std@0.224.0/path/windows/constants.ts": "5afaac0a1f67b68b0a380a4ef391bf59feb55856aa8c60dfc01bd3b6abb813f5", - "https://deno.land/std@0.224.0/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9", - "https://deno.land/std@0.224.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef", - "https://deno.land/std@0.224.0/path/windows/format.ts": "bbb5ecf379305b472b1082cd2fdc010e44a0020030414974d6029be9ad52aeb6", - "https://deno.land/std@0.224.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01", - "https://deno.land/std@0.224.0/path/windows/glob_to_regexp.ts": "e45f1f89bf3fc36f94ab7b3b9d0026729829fabc486c77f414caebef3b7304f8", - "https://deno.land/std@0.224.0/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a", - "https://deno.land/std@0.224.0/path/windows/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", - "https://deno.land/std@0.224.0/path/windows/join.ts": "8d03530ab89195185103b7da9dfc6327af13eabdcd44c7c63e42e27808f50ecf", - "https://deno.land/std@0.224.0/path/windows/join_globs.ts": "a9475b44645feddceb484ee0498e456f4add112e181cb94042cdc6d47d1cdd25", - "https://deno.land/std@0.224.0/path/windows/mod.ts": "2301fc1c54a28b349e20656f68a85f75befa0ee9b6cd75bfac3da5aca9c3f604", - "https://deno.land/std@0.224.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780", - "https://deno.land/std@0.224.0/path/windows/normalize_glob.ts": "9c87a829b6c0f445d03b3ecadc14492e2864c3ebb966f4cea41e98326e4435c6", - "https://deno.land/std@0.224.0/path/windows/parse.ts": "08804327b0484d18ab4d6781742bf374976de662f8642e62a67e93346e759707", - "https://deno.land/std@0.224.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7", - "https://deno.land/std@0.224.0/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972", - "https://deno.land/std@0.224.0/path/windows/to_file_url.ts": "40e560ee4854fe5a3d4d12976cef2f4e8914125c81b11f1108e127934ced502e", - "https://deno.land/std@0.224.0/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c", - "https://deno.land/std@0.224.0/yaml/_dumper/dumper.ts": "08b595b40841a2e1c75303f5096392323b6baf8e9662430a91e3b36fbe175fe9", - "https://deno.land/std@0.224.0/yaml/_dumper/dumper_state.ts": "9e29f700ea876ed230b43f11fa006fcb1a62eedc1e27d32baaeaf3210f19f1e7", - "https://deno.land/std@0.224.0/yaml/_error.ts": "f38cdebdb69cde16903d9aa2f3b8a3dd9d13e5f7f3570bf662bfaca69fef669e", - "https://deno.land/std@0.224.0/yaml/_loader/loader.ts": "bf9e8a99770b59bc887b43ebccea108cbe9146ae32d91f7ce558d62c946d3fe3", - "https://deno.land/std@0.224.0/yaml/_loader/loader_state.ts": "ee216de6040551940b85473c3185fdb7a6f3030b77153f87a6b7f63f82e489ea", - "https://deno.land/std@0.224.0/yaml/_mark.ts": "61097a614857fcebf7b2ecad057916d74c90cd160117a33c9e74bac60457410a", - "https://deno.land/std@0.224.0/yaml/_state.ts": "f3b1c1fd11860302f1f33e35e9ce089bf069d4943e8d67516cd6bedbba058c13", - "https://deno.land/std@0.224.0/yaml/_type/binary.ts": "f1a6e1d83dcc52b21cc3639cd98be44051cfc54065cc4f2a42065bce07ebc07d", - "https://deno.land/std@0.224.0/yaml/_type/bool.ts": "121743b23ba82a27ad6a3ec6298c7f5b0908f90e52707f8644a91f7ad51ed2ef", - "https://deno.land/std@0.224.0/yaml/_type/float.ts": "c5ed84b0aec1ec5dc05f6abfaaff672e8890d4d44a42120b4445c9754fca4eba", - "https://deno.land/std@0.224.0/yaml/_type/function.ts": "bbf705058942bf3370604b37eb77a10aadd72f986c237c9f69b43378a42202c1", - "https://deno.land/std@0.224.0/yaml/_type/int.ts": "c2dc88438a60fccc8d2226042bd18b9967753adaf6bd145feb8b99d567e432ce", - "https://deno.land/std@0.224.0/yaml/_type/map.ts": "ae2acb1cb837fb8e96c75c98611cfd45af847d0114ab5336333c318e7d4b12f4", - "https://deno.land/std@0.224.0/yaml/_type/merge.ts": "ad0d971f91d2fb9f4ab3eba0c837eae357b1804d6b798adc99dc917bc5306b11", - "https://deno.land/std@0.224.0/yaml/_type/mod.ts": "e8929d7b1c969a74f76338d4eb380ef8c4a26cd6441117d521f076b766e9c265", - "https://deno.land/std@0.224.0/yaml/_type/nil.ts": "cbe4387d02d5933322c21b25d8955c5e6228c492e391a6fb82dcf4f498cc421c", - "https://deno.land/std@0.224.0/yaml/_type/omap.ts": "cda915105ab22ba9e1d6317adacee8eec2d8ddaf864cc2f814e3e476946e72c6", - "https://deno.land/std@0.224.0/yaml/_type/pairs.ts": "dd39bb44c1b9abaf6172c63f73350475933151f07e05253b81f7860c9b507177", - "https://deno.land/std@0.224.0/yaml/_type/regexp.ts": "e49eb9e1c9356fd142bc15f7f323820d411fcc537b5ba3896df9a8b812d270a4", - "https://deno.land/std@0.224.0/yaml/_type/seq.ts": "2deffc7f970869bc01a1541b4961d076329a1c2b30b95e07918f3132db7c3fe2", - "https://deno.land/std@0.224.0/yaml/_type/set.ts": "be8a9e7237a7ffc92dfbe7f5e552d84b7eeba60f3f73cc77fc3c59d3506c74ea", - "https://deno.land/std@0.224.0/yaml/_type/str.ts": "88f0a1ba12295520cd57e96cd78d53aa0787d53c7a1c506155f418c496c2f550", - "https://deno.land/std@0.224.0/yaml/_type/timestamp.ts": "277a41a40fb93c3b2b3f5c373bf11b0b7856cc6a7b919e8ea130755e4029edc5", - "https://deno.land/std@0.224.0/yaml/_type/undefined.ts": "9d215953c65740f1764e0bdca021007573473f0c49e087f00d9ff02817ecfc97", - "https://deno.land/std@0.224.0/yaml/_utils.ts": "91bbe28b5e7000b9594e40ff5353f8fe7a7ba914eec917e1202cbaf5ac931c58", - "https://deno.land/std@0.224.0/yaml/mod.ts": "54e9bfad77c8cd58f49b65f4d568045ff08989ed36318a2ca733a43cb6f1bc00", - "https://deno.land/std@0.224.0/yaml/parse.ts": "f45278d9ebccb789af4eceeffa5c291e194bcf1fa9aab1b34ff52c2bd4a9d886", - "https://deno.land/std@0.224.0/yaml/schema.ts": "a0f7956d997852b5d1c6564bd73eb7352175cfba439707ac819b65b5a2ec173a", - "https://deno.land/std@0.224.0/yaml/schema/core.ts": "0a37c07710e3df4eb4edc02f4edf623bf8df5af72b34d8a7c0229d0bac2a7043", - "https://deno.land/std@0.224.0/yaml/schema/default.ts": "1367fd30420c7071ecc67e5b470838474e8259aaf64460f314af4b6bd8da497c", - "https://deno.land/std@0.224.0/yaml/schema/extended.ts": "248180c22697f37ed173057eae62ce4879865bb59f30c4908d698bed5edcc7c5", - "https://deno.land/std@0.224.0/yaml/schema/failsafe.ts": "0ac1cae5b86d8fe2c83ad0a17f8adc33106a452b7139f84e4b0bfaee2206730e", - "https://deno.land/std@0.224.0/yaml/schema/json.ts": "a0228a0c0bad7dece17ab848774fcadc2ccb5e51775c2d58d21d486917ba3ba1", - "https://deno.land/std@0.224.0/yaml/schema/mod.ts": "0e1558a4823834f106675e48ddc15338e04f6f18469d1a7d6b3f0e1ab06abcb2", - "https://deno.land/std@0.224.0/yaml/stringify.ts": "f0ed4e419cb40c807cf79ae4039d6cdf492be9a947121fff4d4b7cd1d4738bae", - "https://deno.land/std@0.224.0/yaml/type.ts": "708dde5f20b01cc1096489b7155b6af79a217d585afb841128e78c3c2391eb5c" - }, - "workspace": { - "dependencies": [ - "jsr:@deno/dnt@~0.41.3", - "jsr:@std/encoding@^1.0.10", - "jsr:@std/fs@^1.0.21", - "jsr:@std/io@~0.224.9", - "jsr:@std/log@~0.224.14", - "jsr:@std/net@^1.0.6", - "jsr:@std/path@^1.1.4", - "jsr:@std/streams@^1.0.16", - "jsr:@std/yaml@^1.0.10", - "jsr:@windmill-labs/cliffy-ansi@^1.0.0-rc.5", - "jsr:@windmill-labs/cliffy-command@^1.0.0-rc.5", - "jsr:@windmill-labs/cliffy-prompt@^1.0.0-rc.6", - "jsr:@windmill-labs/cliffy-table@^1.0.0-rc.5", - "npm:@types/diff@^5.2.3", - "npm:ws@8.18.0" - ] - } -} diff --git a/cli/deps.ts b/cli/deps.ts deleted file mode 100644 index 51e2d29b54..0000000000 --- a/cli/deps.ts +++ /dev/null @@ -1,83 +0,0 @@ -// cliffy -export { Command } from "jsr:@windmill-labs/cliffy-command@1.0.0-rc.5"; -export { Table } from "jsr:@windmill-labs/cliffy-table@1.0.0-rc.5"; -export { colors } from "jsr:@windmill-labs/cliffy-ansi@1.0.0-rc.5/colors"; -export { Secret } from "jsr:@windmill-labs/cliffy-prompt@1.0.0-rc.6/secret"; -export { Select } from "jsr:@windmill-labs/cliffy-prompt@1.0.0-rc.6/select"; -export { Confirm } from "jsr:@windmill-labs/cliffy-prompt@1.0.0-rc.6/confirm"; -export { Input } from "jsr:@windmill-labs/cliffy-prompt@1.0.0-rc.6/input"; -export { UpgradeCommand } from "jsr:@windmill-labs/cliffy-command@1.0.0-rc.5/upgrade"; -export { NpmProvider } from "jsr:@windmill-labs/cliffy-command@1.0.0-rc.5/upgrade/provider/npm"; -export { Provider } from "jsr:@windmill-labs/cliffy-command@1.0.0-rc.5/upgrade"; - -export { CompletionsCommand } from "jsr:@windmill-labs/cliffy-command@1.0.0-rc.5/completions"; -// std -export { ensureDir } from "jsr:@std/fs"; -export { SEPARATOR as SEP } from "jsr:@std/path"; -export * as path from "jsr:@std/path"; -export { encodeHex } from "jsr:@std/encoding@1.0.4"; -export { writeAllSync } from "jsr:@std/io/write-all"; -export { copy } from "jsr:@std/io/copy"; -export { readAll } from "jsr:@std/io/read-all"; - -export * as log from "jsr:@std/log"; -export { stringify as yamlStringify } from "jsr:@std/yaml"; - -import { parse as yamlParse, ParseOptions } from "jsr:@std/yaml"; - -export async function yamlParseFile(path: string, options: ParseOptions = {}) { - try { - return yamlParse(await Deno.readTextFile(path), options); - } catch (e) { - throw new Error(`Error parsing yaml ${path}`, { cause: e }); - } -} - -export function yamlParseContent( - path: string, - content: string, - options: ParseOptions = {}, -) { - try { - return yamlParse(content, options); - } catch (e) { - throw new Error(`Error parsing yaml ${path}`, { cause: e }); - } -} - -// other - -export * as Diff from "npm:diff"; -export { minimatch } from "npm:minimatch"; -export { default as JSZip } from "npm:jszip@3.8.0"; - -export * as express from "npm:express"; -export * as http from "node:http"; -export { WebSocket, WebSocketServer } from "npm:ws"; -export * as getPort from "npm:get-port@7.1.0"; -export * as open from "npm:open"; -export * as esMain from "npm:es-main"; -export * as windmillUtils from "jsr:@windmill-labs/shared-utils@1.0.12"; - -// needed for dnt transform -import * as wsTypes from "npm:@types/ws"; - -import { OpenAPI } from "./gen/index.ts"; - -export function setClient(token?: string, baseUrl?: string) { - if (baseUrl === undefined) { - baseUrl = getEnv("BASE_INTERNAL_URL") ?? - getEnv("BASE_URL") ?? - "http://localhost:8000"; - } - if (token === undefined) { - token = getEnv("WM_TOKEN") ?? "no_token"; - } - OpenAPI.WITH_CREDENTIALS = true; - OpenAPI.TOKEN = token; - OpenAPI.BASE = baseUrl + "/api"; -} - -const getEnv = (key: string) => { - return Deno.env.get(key); -}; diff --git a/cli/dnt.ts b/cli/dnt.ts deleted file mode 100644 index dd4ce1110e..0000000000 --- a/cli/dnt.ts +++ /dev/null @@ -1,87 +0,0 @@ -// ex. scripts/build_npm.ts -import { build, emptyDir } from "jsr:@deno/dnt@0.42.3"; -import { VERSION } from "./src/main.ts"; -await emptyDir("./npm"); - -await build({ - entryPoints: [ - "src/main.ts", - { - kind: "bin", - name: "wmill", // command name - path: "./src/main.ts", - }, - ], - outDir: "./npm", - test: false, // Disable all tests in npm build since they use Deno-specific APIs - shims: { - // see JS docs for overview and more options - deno: true, - // shims to only use in the tests - customDev: [{ - // this is what `timers: "dev"` does internally - package: { - name: "@deno/shim-timers", - version: "~0.1.0", - }, - globalNames: ["setTimeout", "setInterval"], - }], - }, - scriptModule: false, - filterDiagnostic(diagnostic) { - if ( - diagnostic.file?.fileName.includes("node_modules/") || - diagnostic.file?.fileName.includes("src/deps/") || - diagnostic.file?.fileName.includes("src/deps.ts") || - diagnostic.file?.fileName.includes("src/utils/utils.ts") - ) { - return false; // ignore all diagnostics in this file - } - // console.log(diagnostic.file?.fileName); - return true; - }, - declaration: "separate", - package: { - // package.json properties - name: "windmill-cli", - version: VERSION, - description: "CLI for Windmill", - license: "Apache 2.0", - main: "esm/main.js", - repository: { - type: "git", - url: "git+https://github.com/windmill-labs/windmill.git", - }, - bugs: { - url: "https://github.com/windmill-labs/windmill/issues", - }, - }, - - postBuild() { - // steps to run after building and before running the tests - // add shebang to npm/esm/main.js - const dirs = [ - "nu", - "ts", - "regex", - "py", - "go", - "php", - "rust", - "yaml", - "csharp", - "java", - "ruby", - // for related places search: ADD_NEW_LANG - ]; - - for (const l of dirs) { - Deno.copyFileSync( - "wasm/" + l + "/windmill_parser_wasm_bg.wasm", - "npm/esm/wasm/" + l + "/windmill_parser_wasm_bg.wasm" - ); - } - Deno.copyFileSync("../LICENSE", "npm/LICENSE"); - Deno.copyFileSync("README.md", "npm/README.md"); - }, -}); diff --git a/cli/gen_wm_client.sh b/cli/gen_wm_client.sh index f6e5a5e094..af64fe5e59 100755 --- a/cli/gen_wm_client.sh +++ b/cli/gen_wm_client.sh @@ -6,8 +6,8 @@ rm -rf "${script_dirpath}/gen" npx --yes @hey-api/openapi-ts@0.53.1 --input "${script_dirpath}/../backend/windmill-api/openapi.yaml" --output "${script_dirpath}/gen" --useOptions --client legacy/fetch --schemas false cat < temp_file && mv temp_file gen/core/OpenAPI.ts -const getEnv = (key: string) => { - return Deno.env.get(key) +const getEnv = (key: string): string | undefined => { + return process.env[key] }; const baseUrl = getEnv("BASE_INTERNAL_URL") ?? getEnv("BASE_URL") ?? "http://localhost:8000"; diff --git a/cli/install_dev.sh b/cli/install_dev.sh index e172ac1288..ebfa99bd69 100755 --- a/cli/install_dev.sh +++ b/cli/install_dev.sh @@ -2,14 +2,55 @@ set -e -if [ -z "$1" ]; then - name="wmill" -else - name="$1" +# Parse options +USE_NODE=false +name="" +for arg in "$@"; do + case "$arg" in + --node|-node|---node) USE_NODE=true ;; + -*) echo "Unknown option: $arg"; echo "Usage: $0 [name] [--node]"; exit 1 ;; + *) [ -z "$name" ] && name="$arg" ;; + esac +done + +if [ -z "$name" ]; then + name="wmill-dev" fi -./gen_wm_client.sh +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +./gen_wm_client.sh ./windmill-utils-internal/gen_wm_client.sh -echo "Installing dev cli as $name (pass arg to override)" -deno install -f -A -g src/main.ts --name $name --unstable \ No newline at end of file +bun install + +INSTALL_DIR="$HOME/.local/bin" +mkdir -p "$INSTALL_DIR" + +if [ "$USE_NODE" = true ]; then + echo "Building npm bundle..." + bun run build-npm.ts + + NPM_DIR="$SCRIPT_DIR/npm" + cd "$NPM_DIR" && npm install + cd "$SCRIPT_DIR" + + cat > "$INSTALL_DIR/$name" < "$INSTALL_DIR/$name" <=14.18" + } + }, + "node_modules/@cliffy/ansi": { + "name": "@jsr/cliffy__ansi", + "version": "1.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__ansi/1.0.0.tgz", + "integrity": "sha512-JesgTdgR0aW1mZv96VqvRHr2efzr4MgDFMnoT+hkhaiCpmyBz33sHM5peAoMJUbGVfEfQAsysIXvvgoFYoveYg==", + "dependencies": { + "@jsr/cliffy__internal": "1.0.0", + "@jsr/std__encoding": "^1.0.10", + "@jsr/std__fmt": "^1.0.9", + "@jsr/std__io": "~0.225.3" + } + }, + "node_modules/@cliffy/command": { + "name": "@jsr/cliffy__command", + "version": "1.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__command/1.0.0.tgz", + "integrity": "sha512-oObplVtu1tvpkhgpuPDHZidx9g3axVOfRMQGmw7ZSGxp0+vZIJGiEtpcSvlN0XfuEhOG8neqfVBSSE9txrKanw==", + "dependencies": { + "@jsr/cliffy__flags": "1.0.0", + "@jsr/cliffy__internal": "1.0.0", + "@jsr/cliffy__table": "1.0.0", + "@jsr/std__fmt": "^1.0.9", + "@jsr/std__semver": "^1.0.8", + "@jsr/std__text": "^1.0.17" + } + }, + "node_modules/@cliffy/prompt": { + "name": "@jsr/cliffy__prompt", + "version": "1.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__prompt/1.0.0.tgz", + "integrity": "sha512-JDuHcCAjScV0IUj389brneF6AzJyyP0pK8mymsrGN5/PGQfqK8zr96QpFlo1wmo8BY/3JQAdNfy6NZkPCJ6VWA==", + "dependencies": { + "@jsr/cliffy__ansi": "1.0.0", + "@jsr/cliffy__internal": "1.0.0", + "@jsr/cliffy__keycode": "1.0.0", + "@jsr/std__assert": "^1.0.18", + "@jsr/std__fmt": "^1.0.9", + "@jsr/std__io": "~0.225.3", + "@jsr/std__path": "^1.1.4", + "@jsr/std__text": "^1.0.17" + } + }, + "node_modules/@cliffy/table": { + "name": "@jsr/cliffy__table", + "version": "1.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__table/1.0.0.tgz", + "integrity": "sha512-VoLxH0DjofHWPWKUc5N+oCwXB6O6e+carnhp23yJTa7qokBb+SCrTIABEgQdIe/p0bxgmZhz17xt2efaAxXvbQ==", + "dependencies": { + "@jsr/std__fmt": "^1.0.9" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsr/cliffy__ansi": { + "version": "1.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__ansi/1.0.0.tgz", + "integrity": "sha512-JesgTdgR0aW1mZv96VqvRHr2efzr4MgDFMnoT+hkhaiCpmyBz33sHM5peAoMJUbGVfEfQAsysIXvvgoFYoveYg==", + "dependencies": { + "@jsr/cliffy__internal": "1.0.0", + "@jsr/std__encoding": "^1.0.10", + "@jsr/std__fmt": "^1.0.9", + "@jsr/std__io": "~0.225.3" + } + }, + "node_modules/@jsr/cliffy__flags": { + "version": "1.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__flags/1.0.0.tgz", + "integrity": "sha512-j/v3J8MWu0tkYyisZ2w1HxELxxL/qg6vey9+fRkbTJ+S9J0GeLUn2joouikG7aXpULKCXHTjJ9XH9gQx+F3npw==", + "dependencies": { + "@jsr/cliffy__internal": "1.0.0", + "@jsr/std__text": "^1.0.17" + } + }, + "node_modules/@jsr/cliffy__internal": { + "version": "1.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__internal/1.0.0.tgz", + "integrity": "sha512-YPkbccbuu+kE55k+nia5jJx5Tu/IolBDXZTAgEA+YRGOzq8I1VkXajwykFXvSbXeVee3zQBU7y0HajVDB7ujQA==", + "dependencies": { + "@jsr/std__fmt": "^1.0.9" + } + }, + "node_modules/@jsr/cliffy__keycode": { + "version": "1.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__keycode/1.0.0.tgz", + "integrity": "sha512-1ot+y8oZheBTpfgCazWjSOAK2Y2nOQD7NwMuiSAkcRuc1t7VizQZfDpZtBx97NlkYWgjn6ylArt2xyhiyLKRhA==" + }, + "node_modules/@jsr/cliffy__table": { + "version": "1.0.0", + "resolved": "https://npm.jsr.io/~/11/@jsr/cliffy__table/1.0.0.tgz", + "integrity": "sha512-VoLxH0DjofHWPWKUc5N+oCwXB6O6e+carnhp23yJTa7qokBb+SCrTIABEgQdIe/p0bxgmZhz17xt2efaAxXvbQ==", + "dependencies": { + "@jsr/std__fmt": "^1.0.9" + } + }, + "node_modules/@jsr/std__assert": { + "version": "1.0.19", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.19.tgz", + "integrity": "sha512-pEj6RPkGbqlgRmyKwATp4cUs6+ijxtdrv3bq8v1d2I2CEcMEyPaO8cVKro61wGRDH4cNg8Zx6haztvK/9m7gkA==", + "dependencies": { + "@jsr/std__internal": "^1.0.12" + } + }, + "node_modules/@jsr/std__bytes": { + "version": "1.0.6", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__bytes/1.0.6.tgz", + "integrity": "sha512-St6yKggjFGhxS52IFLJWvkchRFbAKg2Xh8UxA4S1EGz7GJ2Ui+ssDDldj/w2c8vCxvl6qgR0HaYbKeFJNqujmA==" + }, + "node_modules/@jsr/std__encoding": { + "version": "1.0.10", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz", + "integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw==" + }, + "node_modules/@jsr/std__fmt": { + "version": "1.0.9", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.9.tgz", + "integrity": "sha512-YFJJMozmORj2K91c5J9opWeh0VUwrd+Mwb7Pr0FkVCAKVLu2UhT4LyvJqWiyUT+eF+MdfqQ9F7RtQj4bXn9Smw==" + }, + "node_modules/@jsr/std__fs": { + "version": "1.0.23", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.23.tgz", + "integrity": "sha512-e8jspB3M44E5YhWiLCTqibBBTwVmxQaHN06WvFa/elAKm5E/LfAe8Hj5XGNC8P7a0MIPASlNJsnF1bgO/g+aqg==", + "dependencies": { + "@jsr/std__internal": "^1.0.12", + "@jsr/std__path": "^1.1.4" + } + }, + "node_modules/@jsr/std__internal": { + "version": "1.0.12", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz", + "integrity": "sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA==" + }, + "node_modules/@jsr/std__io": { + "version": "0.225.3", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__io/0.225.3.tgz", + "integrity": "sha512-IDXY253ipW6FV34CJVxO+3ubfvSEEzw9N2W303KnLe9K/Y9+v/ID1dQYf9VsCCOFMpFtCmOLqzIZsRqv6yQnWw==", + "dependencies": { + "@jsr/std__bytes": "^1.0.6" + } + }, + "node_modules/@jsr/std__path": { + "version": "1.1.4", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.4.tgz", + "integrity": "sha512-SK4u9H6NVTfolhPdlvdYXfNFefy1W04AEHWJydryYbk+xqzNiVmr5o7TLJLJFqwHXuwMRhwrn+mcYeUfS0YFaA==", + "dependencies": { + "@jsr/std__internal": "^1.0.12" + } + }, + "node_modules/@jsr/std__regexp": { + "version": "1.0.1", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__regexp/1.0.1.tgz", + "integrity": "sha512-AnGeP//DHpPvhCWjI5dR4o013JhCQioD8yMF8drD7PWb0X4kvmO35hbZi+NZhfSolz4Ts2cpPzJY+DUpi2XE9A==" + }, + "node_modules/@jsr/std__semver": { + "version": "1.0.8", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__semver/1.0.8.tgz", + "integrity": "sha512-YhkykPU2Majz66e+rQbP0okYc7kKv+U32aguLPCXZZAL+vEVmBA+khHjPHhLBpWR073gzU3WHqGRgB7a/aXCjg==" + }, + "node_modules/@jsr/std__text": { + "version": "1.0.17", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__text/1.0.17.tgz", + "integrity": "sha512-oZsihl1bcTy1Ixzven8rin8kjChj1zDJWqgpS0oSMGCJDzyB365gtIfAvcMmji+M+FcIWo3goDXfHcFYt+k/kg==", + "dependencies": { + "@jsr/std__regexp": "^1.0.1" + } + }, + "node_modules/@std/encoding": { + "name": "@jsr/std__encoding", + "version": "1.0.10", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz", + "integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw==" + }, + "node_modules/@std/log": { + "name": "@jsr/std__log", + "version": "0.224.14", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__log/0.224.14.tgz", + "integrity": "sha512-EHT7E0plakyzk/gxMrwqUf3YGCCxN3Is25QrEh7toYA7qwj46R4qY7cIaDEKy8QqI5JHOFHwWXOClcPK6goIoQ==", + "dependencies": { + "@jsr/std__fmt": "^1.0.5", + "@jsr/std__fs": "^1.0.11", + "@jsr/std__io": "^0.225.2" + } + }, + "node_modules/@std/path": { + "name": "@jsr/std__path", + "version": "1.1.4", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.4.tgz", + "integrity": "sha512-SK4u9H6NVTfolhPdlvdYXfNFefy1W04AEHWJydryYbk+xqzNiVmr5o7TLJLJFqwHXuwMRhwrn+mcYeUfS0YFaA==", + "dependencies": { + "@jsr/std__internal": "^1.0.12" + } + }, + "node_modules/@std/yaml": { + "name": "@jsr/std__yaml", + "version": "1.0.10", + "resolved": "https://npm.jsr.io/~/11/@jsr/std__yaml/1.0.10.tgz", + "integrity": "sha512-1WIM023Kvi48pvPE3UO5YcieambLgywUooLhAkkaObIcMB77F/YP2ILdl+vNfik+vElkl9znmuST9AZo8mbCpA==" + }, + "node_modules/@stoplight/ordered-object-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz", + "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.3.0.tgz", + "integrity": "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.5", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml-ast-parser": "0.0.50", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.50.tgz", + "integrity": "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ==", + "license": "Apache-2.0" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@types/diff": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-5.2.3.tgz", + "integrity": "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@windmill-labs/shared-utils": { + "name": "@jsr/windmill-labs__shared-utils", + "version": "1.0.12", + "resolved": "https://npm.jsr.io/~/11/@jsr/windmill-labs__shared-utils/1.0.12.tgz", + "integrity": "sha512-bJOacyfxxNPwNTzA4AxCB5iGFop0h3mCgs+E9j3ZaJYDo1soblY16CebnQ56EPy/M3V344X/QoOFBORyRo1Mnw==" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/devalue": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", + "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.8.0.tgz", + "integrity": "sha512-cnpQrXvFSLdsR9KR5/x7zdf6c3m8IhZfZzSblFEHSqBaVwD2nvJ4CuCKLyvKvwBgZm08CgfSoiTBQLm5WW9hGw==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "set-immediate-shim": "~1.0.1" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha512-Li5AOqrZWCVA2n5kryzEmqai6bKSIvpz5oUJHPVj6+dsbD3X1ixtsY5tEnsaNpH3pFAHmG8eIHUrtEtohrg+UQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/sudo-prompt": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz", + "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.53.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.2.tgz", + "integrity": "sha512-yGONuIrcl/BMmqbm6/52Q/NYzfkta7uVlos5NSzGTfNJTTFtPPzra6rAQoQIwAqupeM3s9uuTf5PvioeiCdg9g==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.3", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/windmill-parser-wasm-csharp": { + "version": "1.510.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-csharp/-/windmill-parser-wasm-csharp-1.510.1.tgz", + "integrity": "sha512-qm09YmnbeYHLwYn1jUnObVzPhYO9NZKMlIO7nlo7zPJBXqksgG5fK/KCtwGw9rChrnz+DsvM9wP5FhrwRLMtwQ==" + }, + "node_modules/windmill-parser-wasm-go": { + "version": "1.510.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-go/-/windmill-parser-wasm-go-1.510.1.tgz", + "integrity": "sha512-HOkk6LXK0wrwvkn+zjm3Gxo90HmyL6TYqmLo2yp8fZuppy7GOngT27zwYeBtwONiPyvDKskzoqPQoEfd8VuUsQ==" + }, + "node_modules/windmill-parser-wasm-java": { + "version": "1.510.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-java/-/windmill-parser-wasm-java-1.510.1.tgz", + "integrity": "sha512-Zle+JZT/ZwUArUVacUudYlS+CaHp2lSnkqD/IhWaRUG+gcv26VbERnrrHPonqXbVMS+eA9ElfXrFM5j0ukaXUw==" + }, + "node_modules/windmill-parser-wasm-nu": { + "version": "1.510.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-nu/-/windmill-parser-wasm-nu-1.510.1.tgz", + "integrity": "sha512-AJLFiUy6af+LpUe7CddDo4+JOmw3c0K/1iOWh8NdTwXcLDj90lL6089mdsVo1apyloLgrTbcuFDzZMXVGBgtCg==" + }, + "node_modules/windmill-parser-wasm-php": { + "version": "1.574.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-php/-/windmill-parser-wasm-php-1.574.1.tgz", + "integrity": "sha512-COyid6B1RYs+bpzUCInsA4HY/WZkpDLfkQ90+AqU/TVTpzYSbAC2JCbIwy0cRElBvlhI4bQ+9Wg6hSQKMpEkpA==" + }, + "node_modules/windmill-parser-wasm-py": { + "version": "1.628.3", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.628.3.tgz", + "integrity": "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw==" + }, + "node_modules/windmill-parser-wasm-regex": { + "version": "1.639.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz", + "integrity": "sha512-qvYM4sYxB6M0xrqwBljS2fWqOMk6rp++60TRltJnzZDzVaWQrKjTGwNMmfepGAIWy1OGVKp0SCVERhe2P+O6tQ==" + }, + "node_modules/windmill-parser-wasm-ruby": { + "version": "1.526.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ruby/-/windmill-parser-wasm-ruby-1.526.1.tgz", + "integrity": "sha512-rMBQA8s21wmL2kA5ztRs/ZgVA3ckxe9/NLjxl3iQPL0CX6DlvfaUH0O+AnhpXXDMyBs1Y1SZIhcnbnvsHZ3R8g==" + }, + "node_modules/windmill-parser-wasm-rust": { + "version": "1.558.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-rust/-/windmill-parser-wasm-rust-1.558.1.tgz", + "integrity": "sha512-21S7lm1KF8zO1187rbq14hzPHII2RdM2+D44MoAh1F6VoaScj+Puq0z5B1O/hwn/95R/a9jBlL2D8jbkXtlD1A==" + }, + "node_modules/windmill-parser-wasm-ts": { + "version": "1.623.1", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.623.1.tgz", + "integrity": "sha512-FBwi/zXxjhZcCvi04oFdNivazru1ynIqSbafHSArfaaBWesBO3nye9UO/WXUlWZm5a7BExbU+3R/eVJrGaornw==" + }, + "node_modules/windmill-parser-wasm-yaml": { + "version": "1.593.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-yaml/-/windmill-parser-wasm-yaml-1.593.0.tgz", + "integrity": "sha512-Gyx4aR2jsJYuDrD3mCNTmz7LWOQQXPw5yKNCC1xRgUOPfjsD/tINAFfsBLwVOSmlQQcFZO+wHm4KtDtXOcnGVw==" + }, + "node_modules/windmill-yaml-validator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/windmill-yaml-validator/-/windmill-yaml-validator-1.1.1.tgz", + "integrity": "sha512-CVgAwEoBdJhF39q2N012QffhlGPRIyIWd8gj7NnfG+/lMWgH2k5CBLtKIt6cPF8Bxz+6DGC3st1ARSsecDtbTg==", + "license": "Apache 2.0", + "dependencies": { + "@stoplight/yaml": "^4.3.0", + "ajv": "^8.17.1" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000000..a07d43a1a4 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,51 @@ +{ + "name": "wmill-dev", + "private": true, + "type": "module", + "bin": { + "wmill": "src/main.ts" + }, + "scripts": { + "dev": "bun run src/main.ts", + "build": "./build.sh", + "test": "bun test test/", + "check": "bunx tsc --noEmit", + "gen-client": "./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh" + }, + "dependencies": { + "@cliffy/ansi": "npm:@jsr/cliffy__ansi@1.0.0", + "@cliffy/command": "npm:@jsr/cliffy__command@1.0.0", + "@cliffy/prompt": "npm:@jsr/cliffy__prompt@1.0.0", + "@cliffy/table": "npm:@jsr/cliffy__table@1.0.0", + "@windmill-labs/shared-utils": "^1.0.12", + "diff": "^5.2.0", + "esbuild": "0.24.2", + "get-port": "7.1.0", + "jszip": "3.8.0", + "minimatch": "^10.0.0", + "open": "^10.0.0", + "svelte": "^5.45.2", + "tar-stream": "^3.1.7", + "windmill-parser-wasm-csharp": "*", + "windmill-parser-wasm-go": "*", + "windmill-parser-wasm-java": "*", + "windmill-parser-wasm-nu": "*", + "windmill-parser-wasm-php": "*", + "windmill-parser-wasm-py": "*", + "windmill-parser-wasm-regex": "*", + "windmill-parser-wasm-ruby": "*", + "windmill-parser-wasm-rust": "*", + "windmill-parser-wasm-ts": "*", + "windmill-parser-wasm-yaml": "*", + "windmill-yaml-validator": "1.1.1", + "ws": "8.18.0", + "yaml": "^2.7.0" + }, + "devDependencies": { + "@types/diff": "^5.2.3", + "@types/node": "^22.0.0", + "@types/tar-stream": "^3.1.4", + "@types/ws": "^8.5.0", + "typescript": "^5.7.0" + } +} diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 8eedc2a2c2..3aa8305542 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -1,15 +1,12 @@ -// deno-lint-ignore-file no-explicit-any import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; -import { - colors, - Command, - log, - SEP, - Table, - windmillUtils, - yamlParseFile, -} from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; +import * as windmillUtils from "@windmill-labs/shared-utils"; +import { yamlParseFile } from "../../utils/yaml.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { ListableApp, Policy } from "../../../gen/types.gen.ts"; @@ -188,7 +185,7 @@ export async function generatingPolicy( } } -async function list(opts: GlobalOptions & { includeDraftOnly?: boolean }) { +async function list(opts: GlobalOptions & { includeDraftOnly?: boolean; json?: boolean }) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -209,12 +206,32 @@ async function list(opts: GlobalOptions & { includeDraftOnly?: boolean }) { } } - new Table() - .header(["path", "summary"]) - .padding(2) - .border(true) - .body(total.map((x) => [x.path, x.summary])) - .render(); + if (opts.json) { + console.log(JSON.stringify(total)); + } else { + new Table() + .header(["path", "summary"]) + .padding(2) + .border(true) + .body(total.map((x) => [x.path, x.summary])) + .render(); + } +} + +async function get(opts: GlobalOptions & { json?: boolean }, path: string) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + const a = await wmill.getAppByPath({ + workspace: workspace.workspaceId, + path, + }); + if (opts.json) { + console.log(JSON.stringify(a)); + } else { + console.log(colors.bold("Path:") + " " + a.path); + console.log(colors.bold("Summary:") + " " + (a.summary ?? "")); + console.log(colors.bold("Created by:") + " " + (a.created_by ?? "")); + } } async function push(opts: GlobalOptions, filePath: string, remotePath: string) { @@ -230,7 +247,15 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) { const command = new Command() .description("app related commands") + .option("--json", "Output as JSON (for piping to jq)") .action(list as any) + .command("list", "list all apps") + .option("--json", "Output as JSON (for piping to jq)") + .action(list as any) + .command("get", "get an app's details") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any) .command("push", "push a local app ") .arguments(" ") .action(push as any) diff --git a/cli/src/commands/app/app_metadata.ts b/cli/src/commands/app/app_metadata.ts index 51c8a97ffd..3a28770225 100644 --- a/cli/src/commands/app/app_metadata.ts +++ b/cli/src/commands/app/app_metadata.ts @@ -1,12 +1,10 @@ -// deno-lint-ignore-file no-explicit-any import path from "node:path"; -import { - SEP, - colors, - log, - yamlParseFile, - yamlStringify, -} from "../../../deps.ts"; +import { readFile, mkdir } from "node:fs/promises"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; +import { yamlParseFile } from "../../utils/yaml.ts"; +import { stringify as yamlStringify } from "yaml"; import { GlobalOptions } from "../../types.ts"; import { checkifMetadataUptodate, @@ -86,7 +84,7 @@ async function generateAppHash( } } catch (error: any) { // If runnables folder doesn't exist, that's okay - if (error.name !== "NotFound") { + if (error.code !== "ENOENT") { throw error; } } @@ -351,7 +349,7 @@ async function updateRawAppRunnables( // Ensure runnables folder exists try { - await Deno.mkdir(runnablesFolder, { recursive: true }); + await mkdir(runnablesFolder, { recursive: true }); } catch { // Folder may already exist } @@ -736,7 +734,7 @@ export async function inferRunnableSchemaFromFile( ); let content: string; try { - content = await Deno.readTextFile(fullFilePath); + content = await readFile(fullFilePath, "utf-8"); } catch { log.warn(colors.yellow(`Could not read file: ${fullFilePath}`)); return undefined; @@ -786,7 +784,7 @@ export async function generateLocksCommand( const { generateAppLocksInternal } = await import("./app_metadata.ts"); const { elementsToMap, FSFSElement } = await import("../sync/sync.ts"); const { ignoreF } = await import("../sync/sync.ts"); - const { Confirm } = await import("../../../deps.ts"); + const { Confirm } = await import("@cliffy/prompt/confirm"); if (appPath == "") { appPath = undefined; @@ -813,7 +811,7 @@ export async function generateLocksCommand( // Generate metadata for all apps const ignore = await ignoreF(opts); const elems = await elementsToMap( - await FSFSElement(Deno.cwd(), [], true), + await FSFSElement(process.cwd(), [], true), (p, isD) => { return ( ignore(p, isD) || diff --git a/cli/src/commands/app/bundle.ts b/cli/src/commands/app/bundle.ts index 3b8ebc03c5..998546d364 100644 --- a/cli/src/commands/app/bundle.ts +++ b/cli/src/commands/app/bundle.ts @@ -1,10 +1,10 @@ -// deno-lint-ignore-file no-explicit-any import * as fs from "node:fs"; import * as path from "node:path"; import process from "node:process"; import { spawn } from "node:child_process"; -import { log, colors } from "../../../deps.ts"; -import { windmillUtils } from "../../../deps.ts"; +import * as log from "../../core/log.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as windmillUtils from "@windmill-labs/shared-utils"; export interface BundleOptions { entryPoint?: string; outDir?: string; @@ -66,7 +66,7 @@ function createSveltePlugin(appDir: string): any { setup(build: any) { build.onLoad({ filter: /\.svelte$/ }, async (args: any) => { // Import svelte compiler from the project's node_modules - const svelte = await import("npm:svelte@5.45.2/compiler"); + const svelte = await import("svelte/compiler"); // Load the file from the file system const source = await fs.promises.readFile(args.path, "utf8"); @@ -118,7 +118,7 @@ export async function createFrameworkPlugins(appDir: string): Promise { log.info(colors.blue("🔧 Vue detected, adding vue plugin...")); throw new Error("Vue plugin not supported yet"); // try { - // const esbuildPluginVue = await import("npm:esbuild-plugin-vue3@0.5.1"); + // const esbuildPluginVue = await import("esbuild-plugin-vue3"); // plugins.push(esbuildPluginVue.default()); // } catch (error: any) { // log.warn(colors.yellow(`Failed to load vue plugin: ${error.message}`)); @@ -164,7 +164,7 @@ export async function createBundle( options: BundleOptions = {} ): Promise { // Dynamically import esbuild - const esbuild = await import("npm:esbuild@0.24.2"); + const esbuild = await import("esbuild"); // Detect frameworks to determine default entry point const frameworks = detectFrameworks(process.cwd()); diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index 76f3930fa5..ad704ee8dd 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -1,14 +1,11 @@ -// deno-lint-ignore-file no-explicit-any -import { - colors, - Command, - getPort, - log, - open, - SEP, - windmillUtils, - yamlParseFile, -} from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; +import * as windmillUtils from "@windmill-labs/shared-utils"; +import { yamlParseFile } from "../../utils/yaml.ts"; +import * as getPort from "get-port"; +import * as open from "open"; import { GlobalOptions } from "../../types.ts"; import * as http from "node:http"; import * as fs from "node:fs"; @@ -16,7 +13,8 @@ import * as path from "node:path"; import process from "node:process"; import { Buffer } from "node:buffer"; import { writeFileSync } from "node:fs"; -import { WebSocket, WebSocketServer } from "npm:ws"; +import { readFile } from "node:fs/promises"; +import { WebSocket, WebSocketServer } from "ws"; import { createFrameworkPlugins, detectFrameworks, @@ -336,7 +334,7 @@ async function dev(opts: DevOptions, appFolder?: string) { if (!fs.existsSync(targetDir)) { log.error(colors.red(`Error: Directory not found: ${targetDir}`)); - Deno.exit(1); + process.exit(1); } } @@ -355,7 +353,7 @@ async function dev(opts: DevOptions, appFolder?: string) { }' or specify one as argument.`, ), ); - Deno.exit(1); + process.exit(1); } // Check for raw_app.yaml in target directory @@ -369,7 +367,7 @@ async function dev(opts: DevOptions, appFolder?: string) { } folder containing a raw_app.yaml file.`, ), ); - Deno.exit(1); + process.exit(1); } // Resolve workspace and authenticate (from original cwd to find wmill.yaml) @@ -387,7 +385,7 @@ async function dev(opts: DevOptions, appFolder?: string) { const appPath = rawApp?.custom_path ?? "u/unknown/newapp"; // Dynamically import esbuild only when the dev command is called - const esbuild = await import("npm:esbuild@0.24.2"); + const esbuild = await import("esbuild"); const port = opts.port ?? (await getPort.default({ @@ -410,7 +408,7 @@ async function dev(opts: DevOptions, appFolder?: string) { `Entry point "${entryPoint}" not found. Please specify a valid entry point with --entry.`, ), ); - Deno.exit(1); + process.exit(1); } // Ensure node_modules exists @@ -525,99 +523,85 @@ async function dev(opts: DevOptions, appFolder?: string) { // Watch runnables folder for changes const runnablesPath = path.join(process.cwd(), APP_BACKEND_FOLDER); - let runnablesWatcher: Deno.FsWatcher | undefined; + let runnablesWatcher: fs.FSWatcher | undefined; if (fs.existsSync(runnablesPath)) { log.info( colors.blue(`👁️ Watching runnables folder at: ${runnablesPath}\n`), ); - runnablesWatcher = Deno.watchFs(runnablesPath); + runnablesWatcher = fs.watch(runnablesPath, { recursive: true }); // Per-file debounce timeouts for schema inference (longer debounce for typing) const schemaInferenceTimeouts: Record> = {}; const SCHEMA_DEBOUNCE_MS = 500; // Wait 500ms after last change before inferring schema - // Handle runnables file changes in the background - (async () => { - try { - for await (const event of runnablesWatcher!) { - // Process each changed path with individual debouncing - for (const changedPath of event.paths) { - const relativePath = path.relative(process.cwd(), changedPath); - const relativeToRunnables = path.relative( - runnablesPath, - changedPath, - ); + // Handle runnables file changes via callback + runnablesWatcher.on("change", (_eventType, filename) => { + if (!filename) return; + const fileStr = typeof filename === "string" ? filename : filename.toString(); + const changedPath = path.join(runnablesPath, fileStr); + const relativePath = path.relative(process.cwd(), changedPath); + const relativeToRunnables = fileStr; - // Skip non-modify events for schema inference - if (event.kind !== "modify" && event.kind !== "create") { - continue; - } + // Skip lock files + if (changedPath.endsWith(".lock")) { + return; + } - // Skip lock files - if (changedPath.endsWith(".lock")) { - continue; - } + // Log the change event + log.info( + colors.cyan( + `📝 Runnable changed [${_eventType}]: ${relativePath}`, + ), + ); - // Log the change event + // Debounce schema inference per file (wait for typing to finish) + if (schemaInferenceTimeouts[changedPath]) { + clearTimeout(schemaInferenceTimeouts[changedPath]); + } + + schemaInferenceTimeouts[changedPath] = setTimeout(async () => { + delete schemaInferenceTimeouts[changedPath]; + + try { + log.info( + colors.cyan( + `📝 Inferring schema for: ${relativeToRunnables}`, + ), + ); + // Infer schema for this runnable (returns schema in memory, doesn't write to file) + const result = await inferRunnableSchemaFromFile( + process.cwd(), + relativeToRunnables, + ); + if (result) { + // Store inferred schema in memory + inferredSchemas[result.runnableId] = result.schema; log.info( - colors.cyan( - `📝 Runnable changed [${event.kind}]: ${relativePath}`, + colors.green( + ` Inferred Schemas: ${ + JSON.stringify( + inferredSchemas, + null, + 2, + ) + }`, ), ); - - // Debounce schema inference per file (wait for typing to finish) - if (schemaInferenceTimeouts[changedPath]) { - clearTimeout(schemaInferenceTimeouts[changedPath]); - } - - schemaInferenceTimeouts[changedPath] = setTimeout(async () => { - delete schemaInferenceTimeouts[changedPath]; - - try { - log.info( - colors.cyan( - `📝 Inferring schema for: ${relativeToRunnables}`, - ), - ); - // Infer schema for this runnable (returns schema in memory, doesn't write to file) - const result = await inferRunnableSchemaFromFile( - process.cwd(), - relativeToRunnables, - ); - if (result) { - // log.info(colors.green(` Schema: ${JSON.stringify(result.schema, null, 2)}`)); - // log.info(colors.green(` Runnable ID: ${result.runnableId}`)); - // Store inferred schema in memory - inferredSchemas[result.runnableId] = result.schema; - log.info( - colors.green( - ` Inferred Schemas: ${ - JSON.stringify( - inferredSchemas, - null, - 2, - ) - }`, - ), - ); - // Regenerate wmill.d.ts with updated schema from memory - await genRunnablesTs(inferredSchemas); - } - } catch (error: any) { - log.error( - colors.red(`Error inferring schema: ${error.message}`), - ); - } - }, SCHEMA_DEBOUNCE_MS); + // Regenerate wmill.d.ts with updated schema from memory + await genRunnablesTs(inferredSchemas); } + } catch (error: any) { + log.error( + colors.red(`Error inferring schema: ${error.message}`), + ); } - } catch (error: any) { - if (error.name !== "Interrupted") { - log.error(colors.red(`Error watching runnables: ${error.message}`)); - } - } - })(); + }, SCHEMA_DEBOUNCE_MS); + }); + + runnablesWatcher.on("error", (error: Error) => { + log.error(colors.red(`Error watching runnables: ${error.message}`)); + }); } else { log.info( colors.gray( @@ -781,7 +765,7 @@ async function dev(opts: DevOptions, appFolder?: string) { const fileName = path.basename(filePath); try { - const sqlContent = await Deno.readTextFile(filePath); + const sqlContent = await readFile(filePath, "utf-8"); if (!sqlContent.trim()) { log.info(colors.gray(`Skipping empty file: ${fileName}`)); @@ -837,7 +821,7 @@ async function dev(opts: DevOptions, appFolder?: string) { // If there's a current SQL file being shown, send it to the new client if (currentSqlFile && fs.existsSync(currentSqlFile)) { try { - const sqlContent = await Deno.readTextFile(currentSqlFile); + const sqlContent = await readFile(currentSqlFile, "utf-8"); const datatable = await getDatatableConfig(); const fileName = path.basename(currentSqlFile); @@ -1164,7 +1148,7 @@ async function dev(opts: DevOptions, appFolder?: string) { }); // Watch sql_to_apply folder for SQL migration files - let sqlWatcher: Deno.FsWatcher | undefined; + let sqlWatcher: fs.FSWatcher | undefined; // Helper to scan for existing SQL files and add them to the queue async function scanExistingSqlFiles(): Promise { @@ -1207,53 +1191,46 @@ async function dev(opts: DevOptions, appFolder?: string) { log.info( colors.blue(`🗃️ Watching sql_to_apply folder at: ${sqlToApplyPath}\n`), ); - sqlWatcher = Deno.watchFs(sqlToApplyPath); + sqlWatcher = fs.watch(sqlToApplyPath, { recursive: true }); // Debounce timeout for SQL file changes const sqlDebounceTimeouts: Record> = {}; const SQL_DEBOUNCE_MS = 300; - // Handle SQL file changes in the background - (async () => { - try { - for await (const event of sqlWatcher!) { - for (const changedPath of event.paths) { - // Only handle .sql files - if (!changedPath.endsWith(".sql")) { - continue; - } + // Handle SQL file changes via callback + sqlWatcher.on("change", (_eventType, filename) => { + if (!filename) return; + const fileStr = typeof filename === "string" ? filename : filename.toString(); + const changedPath = path.join(sqlToApplyPath, fileStr); - // Only handle modify and create events - if (event.kind !== "modify" && event.kind !== "create") { - continue; - } - - const fileName = path.basename(changedPath); - - // Debounce per file - if (sqlDebounceTimeouts[changedPath]) { - clearTimeout(sqlDebounceTimeouts[changedPath]); - } - - sqlDebounceTimeouts[changedPath] = setTimeout(async () => { - delete sqlDebounceTimeouts[changedPath]; - - log.info(colors.cyan(`📋 SQL file detected: ${fileName}`)); - - // Add to queue and process - queueSqlFile(changedPath); - await processNextSqlFile(); - }, SQL_DEBOUNCE_MS); - } - } - } catch (error: any) { - if (error.name !== "Interrupted") { - log.error( - colors.red(`Error watching sql_to_apply: ${error.message}`), - ); - } + // Only handle .sql files + if (!changedPath.endsWith(".sql")) { + return; } - })(); + + const fileName = path.basename(changedPath); + + // Debounce per file + if (sqlDebounceTimeouts[changedPath]) { + clearTimeout(sqlDebounceTimeouts[changedPath]); + } + + sqlDebounceTimeouts[changedPath] = setTimeout(async () => { + delete sqlDebounceTimeouts[changedPath]; + + log.info(colors.cyan(`📋 SQL file detected: ${fileName}`)); + + // Add to queue and process + queueSqlFile(changedPath); + await processNextSqlFile(); + }, SQL_DEBOUNCE_MS); + }); + + sqlWatcher.on("error", (error: Error) => { + log.error( + colors.red(`Error watching sql_to_apply: ${error.message}`), + ); + }); // Scan for existing SQL files after a delay (to let WebSocket clients connect) setTimeout(() => { diff --git a/cli/src/commands/app/generate_agents.ts b/cli/src/commands/app/generate_agents.ts index d2811e0b07..eec86a8b19 100644 --- a/cli/src/commands/app/generate_agents.ts +++ b/cli/src/commands/app/generate_agents.ts @@ -1,12 +1,18 @@ -import { colors, Command, log, yamlParseFile } from "../../../deps.ts"; +import * as fs from "node:fs"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +import { Command } from "@cliffy/command"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { yamlParseFile } from "../../utils/yaml.ts"; import { GlobalOptions } from "../../types.ts"; import { resolveWorkspace } from "../../core/context.ts"; import { requireLogin } from "../../core/auth.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { DataTableSchema } from "../../../gen/types.gen.ts"; import { generateAgentsDocumentation } from "../sync/sync.ts"; -import path from "node:path"; -import * as fs from "node:fs"; import { getFolderSuffix, hasFolderSuffix, @@ -192,14 +198,14 @@ export async function regenerateAgentDocs( // Generate and write AGENTS.md const agentsContent = generateAgentsDocumentation(localData); - await Deno.writeTextFile(path.join(targetDir, "AGENTS.md"), agentsContent); + await writeFile(path.join(targetDir, "AGENTS.md"), agentsContent, "utf-8"); // Generate and write CLAUDE.md referencing AGENTS.md - await Deno.writeTextFile(path.join(targetDir, "CLAUDE.md"), `Instructions are in @AGENTS.md\n`); + await writeFile(path.join(targetDir, "CLAUDE.md"), `Instructions are in @AGENTS.md\n`, "utf-8"); // Generate and write DATATABLES.md const datatablesContent = generateDatatablesMarkdown(schemas, localData); - await Deno.writeTextFile(path.join(targetDir, "DATATABLES.md"), datatablesContent); + await writeFile(path.join(targetDir, "DATATABLES.md"), datatablesContent, "utf-8"); if (!silent) { log.info(colors.green(`✓ Generated AGENTS.md, CLAUDE.md, and DATATABLES.md`)); @@ -229,7 +235,7 @@ async function generateAgents( appFolder?: string ) { // Resolve the app folder - const cwd = Deno.cwd(); + const cwd = process.cwd(); let targetDir = cwd; if (appFolder) { @@ -252,7 +258,7 @@ async function generateAgents( ) ); log.info(colors.gray("Usage: wmill app generate-agents [app_folder]")); - Deno.exit(1); + process.exit(1); } } @@ -262,7 +268,7 @@ async function generateAgents( log.error( colors.red(`Error: raw_app.yaml not found in ${targetDir}`) ); - Deno.exit(1); + process.exit(1); } // Resolve workspace and authenticate @@ -272,7 +278,6 @@ async function generateAgents( await regenerateAgentDocs(workspace.workspaceId, targetDir); } -// deno-lint-ignore no-explicit-any const command = new Command() .description("regenerate AGENTS.md and DATATABLES.md from remote workspace") .arguments("[app_folder:string]") diff --git a/cli/src/commands/app/lint.ts b/cli/src/commands/app/lint.ts index af8d7e5ab3..12014cc7d5 100644 --- a/cli/src/commands/app/lint.ts +++ b/cli/src/commands/app/lint.ts @@ -1,8 +1,10 @@ -// deno-lint-ignore-file no-explicit-any import * as fs from "node:fs"; import * as path from "node:path"; import process from "node:process"; -import { colors, Command, log, yamlParseFile } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { yamlParseFile } from "../../utils/yaml.ts"; import { GlobalOptions } from "../../types.ts"; import { createBundle } from "./bundle.ts"; import { APP_BACKEND_FOLDER } from "./app_metadata.ts"; @@ -224,7 +226,7 @@ async function lint(opts: LintOptions, appFolder?: string) { log.info(colors.red(` - ${error}`)); }); log.info(colors.red("\n❌ Lint failed\n")); - Deno.exit(1); + process.exit(1); } log.info(colors.green("\n✅ All checks passed\n")); diff --git a/cli/src/commands/app/new.ts b/cli/src/commands/app/new.ts index d79600118d..0ae5065251 100644 --- a/cli/src/commands/app/new.ts +++ b/cli/src/commands/app/new.ts @@ -1,13 +1,11 @@ -import { - colors, - Command, - Confirm, - ensureDir, - Input, - log, - Select, - yamlStringify, -} from "../../../deps.ts"; +import { stat, writeFile, mkdir } from "node:fs/promises"; +import { Command } from "@cliffy/command"; +import { colors } from "@cliffy/ansi/colors"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { Input } from "@cliffy/prompt/input"; +import { Select } from "@cliffy/prompt/select"; +import * as log from "../../core/log.ts"; +import { stringify as yamlStringify } from "yaml"; import { GlobalOptions } from "../../types.ts"; import { generateAgentsDocumentation, generateDatatablesDocumentation, yamlOptions } from "../sync/sync.ts"; import { resolveWorkspace } from "../../core/context.ts"; @@ -480,11 +478,11 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName}; // Create the directory structure - preserve full path (e.g., f/foobar/x/y becomes f/foobar/x/y.raw_app) const folderName = buildFolderPath(appPath, "raw_app"); - const appDir = path.join(Deno.cwd(), folderName); + const appDir = path.join(process.cwd(), folderName); // Check if directory already exists try { - await Deno.stat(appDir); + await stat(appDir); const overwrite = await Confirm.prompt({ message: `Directory '${folderName}' already exists. Overwrite?`, default: false, @@ -497,9 +495,9 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName}; // Directory doesn't exist, which is good } - await ensureDir(appDir); - await ensureDir(path.join(appDir, "backend")); - await ensureDir(path.join(appDir, "sql_to_apply")); + await mkdir(appDir, { recursive: true }); + await mkdir(path.join(appDir, "backend"), { recursive: true }); + await mkdir(path.join(appDir, "sql_to_apply"), { recursive: true }); // Create raw_app.yaml with data configuration const rawAppConfig: Record = { @@ -511,15 +509,15 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName}; rawAppConfig.data = dataConfig; } - await Deno.writeTextFile( + await writeFile( path.join(appDir, "raw_app.yaml"), - yamlStringify(rawAppConfig, yamlOptions) + yamlStringify(rawAppConfig, yamlOptions), "utf-8" ); // Create template files for (const [filePath, content] of Object.entries(template.files)) { const fullPath = path.join(appDir, filePath.slice(1)); // Remove leading slash - await Deno.writeTextFile(fullPath, content.trim() + "\n"); + await writeFile(fullPath, content.trim() + "\n", "utf-8"); } // Create AGENTS.md - main documentation for AI agents @@ -532,22 +530,22 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName}; : undefined; const agentsContent = generateAgentsDocumentation(dataForDocs); - await Deno.writeTextFile( + await writeFile( path.join(appDir, "AGENTS.md"), - agentsContent + agentsContent, "utf-8" ); // Create CLAUDE.md referencing AGENTS.md - await Deno.writeTextFile( + await writeFile( path.join(appDir, "CLAUDE.md"), - `Instructions are in @AGENTS.md\n` + `Instructions are in @AGENTS.md\n`, "utf-8" ); // Create DATATABLES.md with the configured data const datatablesContent = generateDatatablesDocumentation(dataForDocs); - await Deno.writeTextFile( + await writeFile( path.join(appDir, "DATATABLES.md"), - datatablesContent + datatablesContent, "utf-8" ); // Create example backend runnable @@ -555,20 +553,20 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName}; type: "inline", path: undefined, }; - await Deno.writeTextFile( + await writeFile( path.join(appDir, "backend", "a.yaml"), - yamlStringify(exampleRunnable, yamlOptions) + yamlStringify(exampleRunnable, yamlOptions), "utf-8" ); - await Deno.writeTextFile( + await writeFile( path.join(appDir, "backend", "a.ts"), `export async function main(x: number): Promise { return \`Hello from backend! x = \${x}\`; } -` +`, "utf-8" ); // Create sql_to_apply README - await Deno.writeTextFile( + await writeFile( path.join(appDir, "sql_to_apply", "README.md"), `# SQL Migrations Folder @@ -601,9 +599,9 @@ This folder is for SQL migration files that will be applied to datatables during // Create schema creation SQL file if a new schema was requested if (createSchemaSQL && schemaName) { - await Deno.writeTextFile( + await writeFile( path.join(appDir, "sql_to_apply", `000_create_schema_${schemaName}.sql`), - createSchemaSQL + createSchemaSQL, "utf-8" ); } @@ -666,7 +664,6 @@ This folder is for SQL migration files that will be applied to datatables during log.info(colors.gray(" 4. wmill sync push (to deploy when ready)")); } -// deno-lint-ignore no-explicit-any const command = new Command() .description("create a new raw app from a template") .action(newApp as any); diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index c6c408d5b2..6e71a3bf30 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -1,17 +1,15 @@ -// deno-lint-ignore-file no-explicit-any import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; -import { - colors, - log, - SEP, - windmillUtils, - yamlParseFile, - yamlStringify, -} from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; +import * as windmillUtils from "@windmill-labs/shared-utils"; +import { yamlParseFile } from "../../utils/yaml.ts"; +import { stringify as yamlStringify } from "yaml"; import * as wmill from "../../../gen/services.gen.ts"; import { Policy } from "../../../gen/types.gen.ts"; import path from "node:path"; +import { readFile, readdir } from "node:fs/promises"; import { GlobalOptions, isSuperset } from "../../types.ts"; import { deepEqual } from "../../utils/utils.ts"; @@ -67,8 +65,8 @@ async function findRunnableContentFile( // Check if this is a recognized extension if (EXTENSION_TO_LANGUAGE[ext]) { try { - const content = await Deno.readTextFile( - path.join(backendPath, fileName), + const content = await readFile( + path.join(backendPath, fileName), "utf-8", ); return { ext, content }; } catch { @@ -130,8 +128,9 @@ export async function loadRunnablesFromBackend( try { // First, collect all files in the backend folder const allFiles: string[] = []; - for await (const entry of Deno.readDir(backendPath)) { - if (entry.isFile) { + const _entries = await readdir(backendPath, { withFileTypes: true }); + for (const entry of _entries) { + if (entry.isFile()) { allFiles.push(entry.name); } } @@ -165,8 +164,9 @@ export async function loadRunnablesFromBackend( // Try to load lock file let lock: string | undefined; try { - lock = await Deno.readTextFile( + lock = await readFile( path.join(backendPath, `${runnableId}.lock`), + "utf-8", ); } catch { // No lock file, that's fine @@ -226,8 +226,8 @@ export async function loadRunnablesFromBackend( // Try to load lock file let lock: string | undefined; try { - lock = await Deno.readTextFile( - path.join(backendPath, `${runnableId}.lock`), + lock = await readFile( + path.join(backendPath, `${runnableId}.lock`), "utf-8", ); } catch { // No lock file, that's fine @@ -245,7 +245,7 @@ export async function loadRunnablesFromBackend( } } } catch (error: any) { - if (error.name !== "NotFound") { + if (error.code !== "ENOENT") { throw error; } } @@ -291,11 +291,12 @@ async function collectAppFiles( const files: Record = {}; async function readDirRecursive(dir: string, basePath: string = "/") { - for await (const entry of Deno.readDir(dir)) { + const dirEntries = await readdir(dir, { withFileTypes: true }); + for (const entry of dirEntries) { const fullPath = dir + entry.name; const relativePath = basePath + entry.name; - if (entry.isDirectory) { + if (entry.isDirectory()) { // Skip the runnables, node_modules, and sql_to_apply subfolders if ( entry.name === APP_BACKEND_FOLDER || @@ -307,7 +308,7 @@ async function collectAppFiles( continue; } await readDirRecursive(fullPath + SEP, relativePath + "/"); - } else if (entry.isFile) { + } else if (entry.isFile()) { // Skip generated/metadata files that shouldn't be part of the app if ( entry.name === "raw_app.yaml" || @@ -318,7 +319,7 @@ async function collectAppFiles( ) { continue; } - const content = await Deno.readTextFile(fullPath); + const content = await readFile(fullPath, "utf-8"); files[relativePath] = content; } } diff --git a/cli/src/commands/dependencies/dependencies.ts b/cli/src/commands/dependencies/dependencies.ts index 9ca4a11e0d..9cbcdf86df 100644 --- a/cli/src/commands/dependencies/dependencies.ts +++ b/cli/src/commands/dependencies/dependencies.ts @@ -1,8 +1,9 @@ -// deno-lint-ignore-file no-explicit-any import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; import { GlobalOptions } from "../../types.ts"; -import { colors, Command, log } from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import * as log from "../../core/log.ts"; import * as wmill from "../../../gen/services.gen.ts"; import fs from "node:fs"; import { workspaceDependenciesPathToLanguageAndFilename } from "../../utils/metadata.ts"; diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index c9c3d79083..b2d86cc6ed 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -1,15 +1,14 @@ -import { - Command, - SEP, - WebSocketServer, - express, - getPort, - http, - log, - open, - WebSocket, - yamlParseFile, -} from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; +import { yamlParseFile } from "../../utils/yaml.ts"; +import { WebSocket, WebSocketServer } from "ws"; + +import * as getPort from "get-port"; +import * as http from "node:http"; +import * as open from "open"; +import { readFile, realpath } from "node:fs/promises"; +import { watch } from "node:fs"; import { getTypeStrFromPath, GlobalOptions } from "../../types.ts"; import { ignoreF } from "../sync/sync.ts"; import { requireLogin } from "../../core/auth.ts"; @@ -40,25 +39,30 @@ async function dev(opts: GlobalOptions & SyncOptions) { const conf = await readConfigFile(); let currentLastEdit: LastEditScript | LastEditFlow | undefined = undefined; - const watcher = Deno.watchFs("."); - const base = await Deno.realPath("."); + const fsWatcher = watch(".", { recursive: true }); + const base = await realpath("."); opts = await mergeConfigWithConfigFile(opts); const ignore = await ignoreF(opts); - const changesTimeouts: Record = {}; - async function watchChanges() { - for await (const event of watcher) { - // console.log(">>>> event", event); - const key = event.paths.join(","); - if (changesTimeouts[key]) { - clearTimeout(changesTimeouts[key]); - } - // @ts-ignore - changesTimeouts[key] = setTimeout(async () => { - delete changesTimeouts[key]; - await loadPaths(event.paths); - }, 100); - } + const changesTimeouts: Record> = {}; + function watchChanges() { + return new Promise((_resolve, _reject) => { + fsWatcher.on("change", (_eventType, filename) => { + if (!filename) return; + const filePath = typeof filename === "string" ? filename : filename.toString(); + const key = filePath; + if (changesTimeouts[key]) { + clearTimeout(changesTimeouts[key]); + } + changesTimeouts[key] = setTimeout(async () => { + delete changesTimeouts[key]; + await loadPaths([filePath]); + }, 100); + }); + fsWatcher.on("error", (err) => { + _reject(err); + }); + }); } const flowFolderSuffix = getFolderSuffixWithSep("flow"); @@ -72,8 +76,9 @@ async function dev(opts: GlobalOptions & SyncOptions) { if (paths.length == 0) { return; } - const cpath = (await Deno.realPath(paths[0])).replace(base + SEP, ""); - if (!ignore(cpath, false)) { + const nativePath = (await realpath(paths[0])).replace(base + SEP, ""); + const cpath = nativePath.replaceAll("\\", "/"); + if (!ignore(nativePath, false)) { const typ = getTypeStrFromPath(cpath); log.info("Detected change in " + cpath + " (" + typ + ")"); if (typ == "flow") { @@ -83,13 +88,11 @@ async function dev(opts: GlobalOptions & SyncOptions) { )) as FlowFile; await replaceInlineScripts( localFlow.value.modules, - async (path: string) => await Deno.readTextFile(localPath + path), + async (path: string) => await readFile(localPath + path, "utf-8"), log, localPath, SEP, undefined, - // (path: string, newPath: string) => Deno.renameSync(path, newPath), - // (path: string) => Deno.removeSync(path), ); currentLastEdit = { type: "flow", @@ -99,7 +102,7 @@ async function dev(opts: GlobalOptions & SyncOptions) { log.info("Updated " + localPath); broadcastChanges(currentLastEdit); } else if (typ == "script") { - const content = await Deno.readTextFile(cpath); + const content = await readFile(cpath, "utf-8"); const splitted = cpath.split("."); const wmPath = splitted[0]; const lang = inferContentTypeFromFilePath(cpath, conf.defaultTs); @@ -150,8 +153,10 @@ async function dev(opts: GlobalOptions & SyncOptions) { } async function startApp() { - const app = express.default(); - const server = http.createServer(app); + const server = http.createServer((_req, res) => { + res.writeHead(200); + res.end(); + }); const wss = new WebSocketServer({ server }); // WebSocket server event listeners @@ -224,7 +229,6 @@ const command = new Command() "--includes ", "Filter paths givena glob pattern or path" ) - // deno-lint-ignore no-explicit-any .action(dev as any); export default command; diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index d53166dce3..eff742ee68 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -1,8 +1,15 @@ -// deno-lint-ignore-file no-explicit-any import { GlobalOptions, isSuperset } from "../../types.ts"; -import { Confirm, SEP, log, yamlStringify } from "../../../deps.ts"; -import { colors, Command, Table, yamlParseFile } from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { Table } from "@cliffy/table"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; +import { stringify as yamlStringify } from "yaml"; +import { yamlParseFile } from "../../utils/yaml.ts"; import * as wmill from "../../../gen/services.gen.ts"; +import { readFile } from "node:fs/promises"; +import { mkdirSync, writeFileSync } from "node:fs"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; @@ -51,7 +58,7 @@ export async function pushFlow( await replaceInlineScripts( localFlow.value.modules, - async (path: string) => await Deno.readTextFile(localPath + path), + async (path: string) => await readFile(localPath + path, "utf-8"), log, localPath, SEP @@ -106,7 +113,7 @@ async function push(opts: Options, filePath: string, remotePath: string) { } async function list( - opts: GlobalOptions & { showArchived?: boolean; includeDraftOnly?: boolean } + opts: GlobalOptions & { showArchived?: boolean; includeDraftOnly?: boolean; json?: boolean } ) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -129,13 +136,35 @@ async function list( } } - new Table() - .header(["path", "summary", "edited by"]) - .padding(2) - .border(true) - .body(total.map((x) => [x.path, x.summary, x.edited_by])) - .render(); + if (opts.json) { + console.log(JSON.stringify(total)); + } else { + new Table() + .header(["path", "summary", "edited by"]) + .padding(2) + .border(true) + .body(total.map((x) => [x.path, x.summary, x.edited_by])) + .render(); + } } +async function get(opts: GlobalOptions & { json?: boolean }, path: string) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + const f = await wmill.getFlowByPath({ + workspace: workspace.workspaceId, + path, + }); + if (opts.json) { + console.log(JSON.stringify(f)); + } else { + console.log(colors.bold("Path:") + " " + f.path); + console.log(colors.bold("Summary:") + " " + (f.summary ?? "")); + console.log(colors.bold("Description:") + " " + (f.description ?? "")); + console.log(colors.bold("Edited by:") + " " + (f.edited_by ?? "")); + console.log(colors.bold("Edited at:") + " " + (f.edited_at ?? "")); + } +} + async function run( opts: GlobalOptions & { data?: string; @@ -225,7 +254,7 @@ async function preview( // Replace inline scripts with their actual content await replaceInlineScripts( localFlow.value.modules, - async (path: string) => await Deno.readTextFile(flowPath + path), + async (path: string) => await readFile(flowPath + path, "utf-8"), log, flowPath, SEP @@ -286,7 +315,7 @@ async function generateLocks( const ignore = await ignoreF(opts); const elems = Object.keys( await elementsToMap( - await FSFSElement(Deno.cwd(), [], true), + await FSFSElement(process.cwd(), [], true), (p, isD) => { return ( ignore(p, isD) || @@ -348,7 +377,7 @@ export function bootstrap( } const flowDirFullPath = `${flowPath}.flow`; - Deno.mkdirSync(flowDirFullPath, { recursive: false }); + mkdirSync(flowDirFullPath, { recursive: false }); const newFlowDefinition = defaultFlowDefinition(); if (opts.summary !== undefined) { @@ -363,13 +392,22 @@ export function bootstrap( ); const flowYamlPath = `${flowDirFullPath}/flow.yaml`; - Deno.writeTextFile(flowYamlPath, newFlowDefinitionYaml, { createNew: true }); + writeFileSync(flowYamlPath, newFlowDefinitionYaml, { flag: "wx", encoding: "utf-8" }); } const command = new Command() .description("flow related commands") - .option("--show-archived", "Enable archived scripts in output") + .option("--show-archived", "Enable archived flows in output") + .option("--json", "Output as JSON (for piping to jq)") .action(list as any) + .command("list", "list all flows") + .option("--show-archived", "Enable archived flows in output") + .option("--json", "Output as JSON (for piping to jq)") + .action(list as any) + .command("get", "get a flow's details") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any) .command( "push", "push a local flow spec. This overrides any remote versions." @@ -416,10 +454,15 @@ const command = new Command() "Comma separated patterns to specify which file to NOT take into account." ) .action(generateLocks as any) - .command("bootstrap", "create a new empty flow") + .command("new", "create a new empty flow") .arguments("") - .option("--summary ", "script summary") - .option("--description ", "script description") + .option("--summary ", "flow summary") + .option("--description ", "flow description") + .action(bootstrap as any) + .command("bootstrap", "create a new empty flow (alias for new)") + .arguments("") + .option("--summary ", "flow summary") + .option("--description ", "flow description") .action(bootstrap as any); export default command; diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index 26883f7512..cb2e5336e6 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -1,11 +1,10 @@ -import { - SEP, - colors, - log, - path, - yamlParseFile, - yamlStringify, -} from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import * as path from "node:path"; +import { sep as SEP } from "node:path"; +import { stringify as yamlStringify } from "yaml"; +import { yamlParseFile } from "../../utils/yaml.ts"; +import { readFile } from "node:fs/promises"; import { GlobalOptions } from "../../types.ts"; import { readLockfile, @@ -37,7 +36,7 @@ async function generateFlowHash( folder: string, defaultTs: "bun" | "deno" | undefined ) { - const elems = await FSFSElement(path.join(Deno.cwd(), folder), [], true); + const elems = await FSFSElement(path.join(process.cwd(), folder), [], true); const hashes: Record = {}; for await (const f of elems.getChildren()) { if (exts.some((e) => f.path.endsWith(e))) { @@ -124,13 +123,11 @@ export async function generateFlowLockInternal( log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); await replaceInlineScripts( flowValue.value.modules, - async (path: string) => await Deno.readTextFile(folder + SEP + path), + async (path: string) => await readFile(folder + SEP + path, "utf-8"), log, folder + SEP!, SEP, changedScripts - // (path: string, newPath: string) => Deno.renameSync(path, newPath), - // (path: string) => Deno.removeSync(path) ); //removeChangedLocks @@ -148,12 +145,12 @@ export async function generateFlowLockInternal( opts.defaultTs ); inlineScripts.forEach((s) => { - writeIfChanged(Deno.cwd() + SEP + folder + SEP + s.path, s.content); + writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); }); // Overwrite `flow.yaml` with the new lockfile references writeIfChanged( - Deno.cwd() + SEP + folder + SEP + "flow.yaml", + process.cwd() + SEP + folder + SEP + "flow.yaml", yamlStringify(flowValue as Record) ); } diff --git a/cli/src/commands/folder/folder.ts b/cli/src/commands/folder/folder.ts index 421d142326..e3967d8ce3 100644 --- a/cli/src/commands/folder/folder.ts +++ b/cli/src/commands/folder/folder.ts @@ -1,5 +1,11 @@ -// deno-lint-ignore-file no-explicit-any -import { colors, Command, log, SEP, Table } from "../../../deps.ts"; +import { stat, writeFile, mkdir } from "node:fs/promises"; +import { stringify as yamlStringify } from "yaml"; + +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; import * as wmill from "../../../gen/services.gen.ts"; import { requireLogin } from "../../core/auth.ts"; @@ -13,7 +19,7 @@ export interface FolderFile { display_name: string | undefined; } -async function list(opts: GlobalOptions) { +async function list(opts: GlobalOptions & { json?: boolean }) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -21,18 +27,60 @@ async function list(opts: GlobalOptions) { workspace: workspace.workspaceId, }); - new Table() - .header(["Name", "Owners", "Extra Perms"]) - .padding(2) - .border(true) - .body( - folders.map((x) => [ - x.name, - x.owners?.join(",") ?? "-", - JSON.stringify(x.extra_perms ?? {}), - ]) - ) - .render(); + if (opts.json) { + console.log(JSON.stringify(folders)); + } else { + new Table() + .header(["Name", "Owners", "Extra Perms"]) + .padding(2) + .border(true) + .body( + folders.map((x) => [ + x.name, + x.owners?.join(",") ?? "-", + JSON.stringify(x.extra_perms ?? {}), + ]) + ) + .render(); + } +} + +async function newFolder(opts: GlobalOptions, name: string) { + const dirPath = `f${SEP}${name}`; + const filePath = `${dirPath}${SEP}folder.meta.yaml`; + try { + await stat(filePath); + throw new Error("File already exists: " + filePath); + } catch (e: any) { + if (e.message?.startsWith("File already exists")) throw e; + } + const template: Omit = { + owners: [], + extra_perms: {}, + }; + await mkdir(dirPath, { recursive: true }); + await writeFile(filePath, yamlStringify(template as Record), { + flag: "wx", + encoding: "utf-8", + }); + log.info(colors.green(`Created ${filePath}`)); +} + +async function get(opts: GlobalOptions & { json?: boolean }, name: string) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + const f = await wmill.getFolder({ + workspace: workspace.workspaceId, + name, + }); + if (opts.json) { + console.log(JSON.stringify(f)); + } else { + console.log(colors.bold("Name:") + " " + f.name); + console.log(colors.bold("Summary:") + " " + (f.summary ?? "")); + console.log(colors.bold("Owners:") + " " + (f.owners?.join(", ") ?? "-")); + console.log(colors.bold("Extra Perms:") + " " + JSON.stringify(f.extra_perms ?? {})); + } } export async function pushFolder( @@ -103,8 +151,8 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) { return; } - const fstat = await Deno.stat(filePath); - if (!fstat.isFile) { + const fstat = await stat(filePath); + if (!fstat.isFile()) { throw new Error("file path must refer to a file."); } @@ -121,7 +169,18 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) { const command = new Command() .description("folder related commands") + .option("--json", "Output as JSON (for piping to jq)") .action(list as any) + .command("list", "list all folders") + .option("--json", "Output as JSON (for piping to jq)") + .action(list as any) + .command("get", "get a folder's details") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any) + .command("new", "create a new folder locally") + .arguments("") + .action(newFolder as any) .command( "push", "push a local folder spec. This overrides any remote versions." diff --git a/cli/src/commands/gitsync-settings/gitsync-settings.ts b/cli/src/commands/gitsync-settings/gitsync-settings.ts index f943bb40b7..5c27e032b5 100644 --- a/cli/src/commands/gitsync-settings/gitsync-settings.ts +++ b/cli/src/commands/gitsync-settings/gitsync-settings.ts @@ -1,4 +1,4 @@ -import { Command } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; import { pullGitSyncSettings } from "./pull.ts"; import { pushGitSyncSettings } from "./push.ts"; diff --git a/cli/src/commands/gitsync-settings/legacySettings.ts b/cli/src/commands/gitsync-settings/legacySettings.ts index fe6f29faa5..929d0e473e 100644 --- a/cli/src/commands/gitsync-settings/legacySettings.ts +++ b/cli/src/commands/gitsync-settings/legacySettings.ts @@ -1,4 +1,7 @@ -import { colors, Confirm } from "../../../deps.ts"; +import process from "node:process"; + +import { colors } from "@cliffy/ansi/colors"; +import { Confirm } from "@cliffy/prompt/confirm"; import * as wmill from "../../../gen/services.gen.ts"; import { GitSyncRepository } from "./types.ts"; @@ -24,7 +27,7 @@ export async function handleLegacyRepositoryMigration( const workspaceIncludePath = gitSyncSettings.include_path; const workspaceIncludeType = gitSyncSettings.include_type; - if (Deno.stdout.isTerminal() && !opts.yes) { + if (!!process.stdout.isTTY && !opts.yes) { // Interactive mode - show migration prompt console.log(colors.yellow('\n⚠️ Legacy git-sync settings detected!')); console.log(`\nRepository "${selectedRepo.git_repo_resource_path}" has legacy settings format.`); @@ -139,6 +142,6 @@ export async function handleLegacyRepositoryMigration( console.error('3. Push local settings to override backend settings:'); console.error(' wmill gitsync-settings push\n'); } - Deno.exit(1); + process.exit(1); } } \ No newline at end of file diff --git a/cli/src/commands/gitsync-settings/pull.ts b/cli/src/commands/gitsync-settings/pull.ts index 4bcd8900d0..bea37743a4 100644 --- a/cli/src/commands/gitsync-settings/pull.ts +++ b/cli/src/commands/gitsync-settings/pull.ts @@ -1,9 +1,13 @@ -import { colors, log, yamlStringify } from "../../../deps.ts"; +import { writeFile } from "node:fs/promises"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { stringify as yamlStringify } from "yaml"; import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { SyncOptions, readConfigFile, getEffectiveSettings, DEFAULT_SYNC_OPTIONS, getWmillYamlPath } from "../../core/conf.ts"; +import { yamlOptions } from "../sync/sync.ts"; import { deepEqual } from "../../utils/utils.ts"; import { getCurrentGitBranch, isGitRepository } from "../../utils/git.ts"; @@ -173,7 +177,7 @@ export async function pullGitSyncSettings( } // Write the new configuration - await Deno.writeTextFile("wmill.yaml", yamlStringify(updatedConfig)); + await writeFile("wmill.yaml", yamlStringify(updatedConfig, yamlOptions), "utf-8"); if (opts.jsonOutput) { console.log( @@ -286,7 +290,7 @@ export async function pullGitSyncSettings( ); const hasConflict = !deepEqual(gitSyncBackend, gitSyncCurrent); - if (hasConflict && !opts.yes && Deno.stdin.isTerminal()) { + if (hasConflict && !opts.yes && !!process.stdin.isTTY) { // Show the diff first log.info("Changes that would be applied locally:"); const changes = generateChanges(effectiveCurrentSettings, backendSyncOptions); @@ -295,7 +299,7 @@ export async function pullGitSyncSettings( } // Interactive mode - ask user - const { Select } = await import("../../../deps.ts"); + const { Select } = await import("@cliffy/prompt/select"); const choice = await Select.prompt({ message: "Settings conflict detected. How would you like to proceed?", options: [ @@ -369,7 +373,7 @@ export async function pullGitSyncSettings( } // Write updated configuration - await Deno.writeTextFile("wmill.yaml", yamlStringify(updatedConfig)); + await writeFile("wmill.yaml", yamlStringify(updatedConfig, yamlOptions), "utf-8"); if (opts.jsonOutput) { console.log( @@ -446,7 +450,7 @@ export async function pullGitSyncSettings( } // Write updated configuration - await Deno.writeTextFile("wmill.yaml", yamlStringify(updatedConfig)); + await writeFile("wmill.yaml", yamlStringify(updatedConfig, yamlOptions), "utf-8"); if (opts.jsonOutput) { console.log( diff --git a/cli/src/commands/gitsync-settings/push.ts b/cli/src/commands/gitsync-settings/push.ts index 2e7f050207..0392aa8722 100644 --- a/cli/src/commands/gitsync-settings/push.ts +++ b/cli/src/commands/gitsync-settings/push.ts @@ -1,4 +1,8 @@ -import { colors, log, Confirm } from "../../../deps.ts"; +import process from "node:process"; + +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { Confirm } from "@cliffy/prompt/confirm"; import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; @@ -34,7 +38,7 @@ export async function pushGitSyncSettings( } catch (error) { if (error instanceof Error && error.message.includes("overrides")) { log.error(error.message); - Deno.exit(1); + process.exit(1); } throw error; } @@ -51,7 +55,7 @@ export async function pushGitSyncSettings( "No wmill.yaml file found. Please run 'wmill init' first to create the configuration file.", ), ); - Deno.exit(1); + process.exit(1); } // Read local configuration @@ -247,7 +251,7 @@ export async function pushGitSyncSettings( } // Ask for confirmation unless --yes is passed or not in TTY - if (!opts.yes && Deno.stdin.isTerminal()) { + if (!opts.yes && !!process.stdin.isTTY) { const confirmed = await Confirm.prompt({ message: `Do you want to apply these changes to the remote?`, default: true, diff --git a/cli/src/commands/gitsync-settings/utils.ts b/cli/src/commands/gitsync-settings/utils.ts index 10b9cf351b..8d255d9f92 100644 --- a/cli/src/commands/gitsync-settings/utils.ts +++ b/cli/src/commands/gitsync-settings/utils.ts @@ -1,4 +1,5 @@ -import { colors, log } from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; import { deepEqual, selectRepository } from "../../utils/utils.ts"; import { SyncOptions, getEffectiveSettings, DEFAULT_SYNC_OPTIONS } from "../../core/conf.ts"; import { GitSyncRepository, GIT_SYNC_FIELDS } from "./types.ts"; diff --git a/cli/src/commands/hub/hub.ts b/cli/src/commands/hub/hub.ts index 314d781bba..83ec15a5e3 100644 --- a/cli/src/commands/hub/hub.ts +++ b/cli/src/commands/hub/hub.ts @@ -1,5 +1,5 @@ -// deno-lint-ignore-file no-explicit-any -import { Command, log } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import * as log from "../../core/log.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { requireLogin } from "../../core/auth.ts"; diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index 0bd2443716..807f2fb31b 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -1,4 +1,9 @@ -import { colors, Command, log, yamlStringify, Confirm } from "../../../deps.ts"; +import { stat, writeFile, rm, mkdir } from "node:fs/promises"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import * as log from "../../core/log.ts"; +import { stringify as yamlStringify } from "yaml"; import { GlobalOptions } from "../../types.ts"; import { readLockfile } from "../../utils/metadata.ts"; import { getActiveWorkspaceOrFallback } from "../workspace/workspace.ts"; @@ -36,7 +41,7 @@ export interface InitOptions { * Bootstrap a windmill project with a wmill.yaml file */ async function initAction(opts: InitOptions) { - if (await Deno.stat("wmill.yaml").catch(() => null)) { + if (await stat("wmill.yaml").catch(() => null)) { log.error(colors.red("wmill.yaml already exists")); } else { // Import DEFAULT_SYNC_OPTIONS from conf.ts @@ -63,7 +68,7 @@ async function initAction(opts: InitOptions) { } initialConfig.nonDottedPaths = true; - await Deno.writeTextFile("wmill.yaml", yamlStringify(initialConfig)); + await writeFile("wmill.yaml", yamlStringify(initialConfig), "utf-8"); log.info(colors.green("wmill.yaml created with default settings")); // Create lock file @@ -80,12 +85,12 @@ async function initAction(opts: InitOptions) { const shouldBind = opts.bindProfile === true; const shouldPrompt = opts.bindProfile === undefined && - Deno.stdin.isTerminal() && + !!process.stdin.isTTY && !opts.useDefault; const shouldSkip = opts.bindProfile != true && - (opts.useDefault || !Deno.stdin.isTerminal()); + (opts.useDefault || !!!process.stdin.isTTY); if (!shouldSkip) { // Show workspace info if we're binding or prompting @@ -132,7 +137,7 @@ async function initAction(opts: InitOptions) { currentConfig.gitBranches[currentBranch].workspaceId = activeWorkspace.workspaceId; - await Deno.writeTextFile("wmill.yaml", yamlStringify(currentConfig)); + await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8"); log.info( colors.green( @@ -183,7 +188,7 @@ async function initAction(opts: InitOptions) { if (useBackendSettings === undefined) { // Interactive prompt - const { Select } = await import("../../../deps.ts"); + const { Select } = await import("@cliffy/prompt/select"); const choice = await Select.prompt({ message: "Git-sync settings found on backend. What would you like to do?", @@ -206,13 +211,13 @@ async function initAction(opts: InitOptions) { if (choice === "cancel") { // Clean up the created files try { - await Deno.remove("wmill.yaml"); - await Deno.remove("wmill-lock.yaml"); + await rm("wmill.yaml"); + await rm("wmill-lock.yaml"); } catch (e) { // Ignore cleanup errors } log.info("Init cancelled"); - Deno.exit(0); + process.exit(0); } useBackendSettings = choice === "backend"; @@ -256,32 +261,32 @@ async function initAction(opts: InitOptions) { ).join("\n"); // Create AGENTS.md file with minimal instructions - if (!(await Deno.stat("AGENTS.md").catch(() => null))) { - await Deno.writeTextFile( + if (!(await stat("AGENTS.md").catch(() => null))) { + await writeFile( "AGENTS.md", - generateAgentsMdContent(skillsReference) + generateAgentsMdContent(skillsReference), "utf-8" ); log.info(colors.green("Created AGENTS.md")); } // Create CLAUDE.md file, referencing AGENTS.md - if (!(await Deno.stat("CLAUDE.md").catch(() => null))) { - await Deno.writeTextFile( + if (!(await stat("CLAUDE.md").catch(() => null))) { + await writeFile( "CLAUDE.md", `Instructions are in @AGENTS.md -` +`, "utf-8" ); log.info(colors.green("Created CLAUDE.md")); } // Create .claude/skills/ directory and skill files try { - await Deno.mkdir(".claude/skills", { recursive: true }); + await mkdir(".claude/skills", { recursive: true }); await Promise.all( SKILLS.map(async (skill) => { const skillDir = `.claude/skills/${skill.name}`; - await Deno.mkdir(skillDir, { recursive: true }); + await mkdir(skillDir, { recursive: true }); let skillContent = SKILL_CONTENT[skill.name]; if (skillContent) { @@ -304,7 +309,7 @@ async function initAction(opts: InitOptions) { } } - await Deno.writeTextFile(`${skillDir}/SKILL.md`, skillContent); + await writeFile(`${skillDir}/SKILL.md`, skillContent, "utf-8"); } }) ); diff --git a/cli/src/commands/instance/instance.ts b/cli/src/commands/instance/instance.ts index 696f1b1c85..6b22d49b27 100644 --- a/cli/src/commands/instance/instance.ts +++ b/cli/src/commands/instance/instance.ts @@ -1,16 +1,17 @@ -import { - Command, - Confirm, - path, - Select, - setClient, - Table, - yamlParseFile, - yamlStringify, -} from "../../../deps.ts"; +import { readFile, writeFile, readdir, mkdir, rm, stat } from "node:fs/promises"; +import { appendFile } from "node:fs/promises"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { Input } from "@cliffy/prompt/input"; +import { Select } from "@cliffy/prompt/select"; +import { Table } from "@cliffy/table"; +import * as log from "../../core/log.ts"; +import * as path from "node:path"; +import { stringify as yamlStringify } from "yaml"; +import { setClient } from "../../core/client.ts"; +import { yamlParseFile } from "../../utils/yaml.ts"; import * as wmill from "../../../gen/services.gen.ts"; - -import { colors, Input, log } from "../../../deps.ts"; import { loginInteractive } from "../../core/login.ts"; import { getActiveInstanceFilePath, @@ -51,7 +52,7 @@ export interface Instance { export async function allInstances(): Promise { try { const file = await getInstancesConfigFilePath(); - const txt = await Deno.readTextFile(file); + const txt = await readFile(file, "utf-8"); return txt .split("\n") .map((line) => { @@ -118,26 +119,19 @@ export async function addInstance( async function appendInstance(instance: Instance) { instance.remote = new URL(instance.remote).toString(); // add trailing slash in all cases! await removeInstance(instance.name); - const file = await Deno.open(await getInstancesConfigFilePath(), { - append: true, - write: true, - read: true, - create: true, - }); - await file.write(new TextEncoder().encode(JSON.stringify(instance) + "\n")); - - file.close(); + const filePath = await getInstancesConfigFilePath(); + await appendFile(filePath, JSON.stringify(instance) + "\n", "utf-8"); } async function removeInstance(name: string) { const orgWorkspaces = await allInstances(); - await Deno.writeTextFile( + await writeFile( await getInstancesConfigFilePath(), orgWorkspaces .filter((x) => x.name !== name) .map((x) => JSON.stringify(x)) - .join("\n") + "\n", + .join("\n") + "\n", "utf-8", ); } @@ -289,7 +283,7 @@ async function instancePull(opts: InstanceSyncOptions) { const totalChanges = uChanges + sChanges + cChanges + gChanges; - const rootDir = Deno.cwd(); + const rootDir = process.cwd(); if (totalChanges > 0) { let confirm = true; @@ -308,7 +302,7 @@ async function instancePull(opts: InstanceSyncOptions) { if (confirm) { if (uChanges > 0) { if (opts.folderPerInstance && opts.prefixSettings) { - await Deno.mkdir(path.join(rootDir, opts.prefix), { + await mkdir(path.join(rootDir, opts.prefix), { recursive: true, }); } @@ -348,10 +342,10 @@ async function instancePull(opts: InstanceSyncOptions) { const workspaceName = opts?.folderPerInstance ? instance.prefix + "/" + remoteWorkspace.id : instance.prefix + "_" + remoteWorkspace.id; - await Deno.mkdir(path.join(rootDir, workspaceName), { + await mkdir(path.join(rootDir, workspaceName), { recursive: true, }); - await Deno.chdir(path.join(rootDir, workspaceName)); + process.chdir(path.join(rootDir, workspaceName)); await addWorkspace( { remote: instance.remote, @@ -397,7 +391,7 @@ async function instancePull(opts: InstanceSyncOptions) { if (confirmDelete) { for (const workspace of localWorkspacesToDelete) { await removeWorkspace(workspace.id, false, {}); - await Deno.remove(path.join(rootDir, workspace.dir), { + await rm(path.join(rootDir, workspace.dir), { recursive: true, }); } @@ -467,7 +461,7 @@ async function instancePush(opts: InstanceSyncOptions) { if (opts.includeWorkspaces) { instances = await allInstances(); - const rootDir = Deno.cwd(); + const rootDir = process.cwd(); let localPrefix; if (opts.prefix) { @@ -506,7 +500,7 @@ async function instancePush(opts: InstanceSyncOptions) { for (const localWorkspace of localWorkspaces) { log.info("\nPushing workspace " + localWorkspace.id); try { - await Deno.chdir(path.join(rootDir, localWorkspace.dir)); + process.chdir(path.join(rootDir, localWorkspace.dir)); } catch (_) { throw new Error( "Workspace folder not found, are you in the right directory?", @@ -515,7 +509,7 @@ async function instancePush(opts: InstanceSyncOptions) { try { const workspaceSettings = (await yamlParseFile( - path.join(Deno.cwd(), "settings.yaml"), + path.join(process.cwd(), "settings.yaml"), )) as SimplifiedSettings; await workspaceSetup( { @@ -586,12 +580,13 @@ async function getLocalWorkspaces( ) { const localWorkspaces: { dir: string; id: string }[] = []; - if (!(await Deno.stat(localPrefix).catch(() => null))) { - await Deno.mkdir(localPrefix); + if (!(await stat(localPrefix).catch(() => null))) { + await mkdir(localPrefix); } if (folderPerInstance) { - for await (const dir of Deno.readDir(rootDir + "/" + localPrefix)) { - if (dir.isDirectory) { + const prefixEntries = await readdir(rootDir + "/" + localPrefix, { withFileTypes: true }); + for (const dir of prefixEntries) { + if (dir.isDirectory()) { const dirName = dir.name; localWorkspaces.push({ dir: localPrefix + "/" + dirName, @@ -600,7 +595,8 @@ async function getLocalWorkspaces( } } } else { - for await (const dir of Deno.readDir(rootDir)) { + const rootEntries = await readdir(rootDir, { withFileTypes: true }); + for (const dir of rootEntries) { const dirName = dir.name; if (dirName.startsWith(localPrefix + "_")) { localWorkspaces.push({ @@ -631,9 +627,9 @@ async function switchI(opts: {}, instanceName: string) { return; } - await Deno.writeTextFile( + await writeFile( await getActiveInstanceFilePath(), - instanceName, + instanceName, "utf-8", ); log.info(colors.green.underline(`Switched to instance ${instanceName}`)); @@ -646,7 +642,7 @@ export async function getActiveInstance(opts: { return opts.instance; } try { - return await Deno.readTextFile(await getActiveInstanceFilePath()); + return await readFile(await getActiveInstanceFilePath(), "utf-8"); } catch { return undefined; } @@ -657,7 +653,7 @@ async function getConfig(opts: InstanceSyncOptions & { outputFile?: string }) { const config = await wmill.getInstanceConfig(); const yaml = yamlStringify(config as Record); if (opts.outputFile) { - await Deno.writeTextFile(opts.outputFile, yaml); + await writeFile(opts.outputFile, yaml, "utf-8"); log.info(colors.green(`Instance config written to ${opts.outputFile}`)); } else { console.log(yaml); diff --git a/cli/src/commands/jobs/jobs.ts b/cli/src/commands/jobs/jobs.ts index 495e513c87..17a58d11f2 100644 --- a/cli/src/commands/jobs/jobs.ts +++ b/cli/src/commands/jobs/jobs.ts @@ -1,8 +1,10 @@ -// deno-lint-ignore-file no-explicit-any import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; -import { colors, Command, Confirm, log } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { colors } from "@cliffy/ansi/colors"; +import { Confirm } from "@cliffy/prompt/confirm"; +import * as log from "../../core/log.ts"; import { mergeConfigWithConfigFile } from "../../core/conf.ts"; import * as fs from "node:fs/promises"; import * as wmill from "../../../gen/services.gen.ts"; diff --git a/cli/src/commands/lint/lint.ts b/cli/src/commands/lint/lint.ts index 58d456bed0..62a491eee1 100644 --- a/cli/src/commands/lint/lint.ts +++ b/cli/src/commands/lint/lint.ts @@ -1,4 +1,12 @@ -import { colors, Command, log, path, SEP } from "../../../deps.ts"; +import { stat, readdir } from "node:fs/promises"; +import process from "node:process"; + +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import * as log from "../../core/log.ts"; +import * as path from "node:path"; +import { sep as SEP } from "node:path"; +import { yamlParseFile } from "../../utils/yaml.ts"; import { GlobalOptions } from "../../types.ts"; import { mergeConfigWithConfigFile } from "../../core/conf.ts"; import { @@ -10,11 +18,24 @@ import { getValidationTargetFromFilename, type ValidationTarget, WindmillYamlValidator, -} from "npm:windmill-yaml-validator@1.1.1"; +} from "windmill-yaml-validator"; +import { + inferContentTypeFromFilePath, + languageNeedsLock, + ScriptLanguage, +} from "../../utils/script_common.ts"; +import { + isFlowInlineScriptPath, + isAppInlineScriptPath, + isRawAppPath, + getFolderSuffix, +} from "../../utils/resource_folders.ts"; +import { exts } from "../script/script.ts"; interface LintOptions extends GlobalOptions { json?: boolean; failOnWarn?: boolean; + locksRequired?: boolean; } interface FileIssue { @@ -101,24 +122,506 @@ function formatYamlDiagnostics(parsed: { diagnostics?: Array<{ message?: string return diagnostics.map((d) => d?.message || "Invalid YAML document"); } +/** + * Check if a lock value represents an actually resolved lock. + * Returns true if the lock is present and valid, false if missing. + * For `!inline` references, checks that the referenced file exists and is non-empty. + */ +async function isLockResolved( + lockValue: string | string[] | undefined, + baseDir: string, +): Promise { + if (lockValue === undefined) return false; + + // Array lock (v2 format) - if non-empty, locks are present + if (Array.isArray(lockValue)) { + const joined = lockValue.join("\n"); + if (joined === "") return false; + if (joined.startsWith("!inline ")) { + return await checkInlineFile(joined.substring("!inline ".length), baseDir); + } + return true; + } + + if (lockValue === "") return false; + + // Inline file reference + if (lockValue.startsWith("!inline ")) { + return await checkInlineFile(lockValue.substring("!inline ".length), baseDir); + } + + // Embedded lock content + return true; +} + +async function checkInlineFile( + relativePath: string, + baseDir: string, +): Promise { + const fullPath = path.join(baseDir, relativePath.trim()); + try { + const s = await stat(fullPath); + return s.size > 0; + } catch { + return false; + } +} + +/** + * Recursively find rawscript modules in a flow's module tree. + */ +function findRawScriptsInModules( + modules: any[], +): { language: string; lock: any; id: string }[] { + const results: { language: string; lock: any; id: string }[] = []; + if (!modules || !Array.isArray(modules)) return results; + + for (const m of modules) { + if (!m?.value?.type) continue; + + if (m.value.type === "rawscript") { + results.push({ + language: m.value.language, + lock: m.value.lock, + id: m.id ?? "unknown", + }); + } else if ( + m.value.type === "forloopflow" || + m.value.type === "whileloopflow" + ) { + results.push(...findRawScriptsInModules(m.value.modules)); + } else if (m.value.type === "branchall") { + for (const b of m.value.branches ?? []) { + results.push(...findRawScriptsInModules(b.modules)); + } + } else if (m.value.type === "branchone") { + for (const b of m.value.branches ?? []) { + results.push(...findRawScriptsInModules(b.modules)); + } + if (m.value.default) { + results.push(...findRawScriptsInModules(m.value.default)); + } + } else if (m.value.type === "aiagent") { + for (const tool of m.value.tools ?? []) { + const toolValue = tool.value; + if ( + toolValue?.tool_type === "flowmodule" && + toolValue?.type === "rawscript" + ) { + results.push({ + language: toolValue.language, + lock: toolValue.lock, + id: tool.id ?? "unknown", + }); + } + } + } + } + + return results; +} + +/** + * Recursively find inlineScript objects in a normal app's value structure. + * Follows the same traversal as traverseAndProcessInlineScripts in app_metadata.ts. + */ +function findInlineScriptsInApp( + obj: any, + currentPath: string[] = [], +): { language: string; lock: any; path: string }[] { + const results: { language: string; lock: any; path: string }[] = []; + if (!obj || typeof obj !== "object") return results; + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + results.push( + ...findInlineScriptsInApp(obj[i], [...currentPath, `[${i}]`]), + ); + } + return results; + } + + for (const [key, value] of Object.entries(obj)) { + if (key === "inlineScript" && typeof value === "object" && value !== null) { + const script = value as Record; + if (script.language) { + results.push({ + language: script.language, + lock: script.lock, + path: [...currentPath, key].join("."), + }); + } + } else { + results.push( + ...findInlineScriptsInApp(value, [...currentPath, key]), + ); + } + } + + return results; +} + +/** + * Check raw app backend runnables for missing locks. + * Reads YAML config files and code files from the backend/ folder. + */ +async function checkRawAppRunnables( + backendDir: string, + rawAppYamlPath: string, + defaultTs: "bun" | "deno" | undefined, +): Promise { + const issues: FileIssue[] = []; + + const allFiles: string[] = []; + const entries = await readdir(backendDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + allFiles.push(entry.name); + } + } + + // Track processed IDs to avoid duplicates + const processedIds = new Set(); + + // Process YAML files (explicit config) + for (const fileName of allFiles) { + if (!fileName.endsWith(".yaml")) continue; + + const runnableId = fileName.replace(".yaml", ""); + processedIds.add(runnableId); + + const filePath = path.join(backendDir, fileName); + let runnable: Record; + try { + runnable = (await yamlParseFile(filePath)) as Record; + } catch { + continue; + } + + // Only inline runnables need lock checking + if (runnable?.type !== "inline") continue; + + // Find the content file to determine language + let language: string | null = null; + for (const codeFile of allFiles) { + if ( + codeFile.endsWith(".yaml") || codeFile.endsWith(".lock") || + !codeFile.startsWith(runnableId + ".") + ) continue; + language = inferContentTypeFromFilePath(codeFile, defaultTs); + break; + } + + if (!language || !languageNeedsLock(language)) continue; + + // Check for lock file + const lockFile = path.join(backendDir, `${runnableId}.lock`); + let hasLock = false; + try { + const s = await stat(lockFile); + hasLock = s.size > 0; + } catch { + // No lock file + } + + // Also check if the runnable YAML has inlineScript.lock + if (!hasLock && runnable.inlineScript?.lock) { + hasLock = await isLockResolved(runnable.inlineScript.lock, backendDir); + } + + if (!hasLock) { + issues.push({ + path: rawAppYamlPath, + target: "raw_app_inline_script", + errors: [ + `Missing lock for ${language} runnable '${runnableId}'. Run 'wmill app generate-locks' to generate locks.`, + ], + }); + } + } + + // Auto-detect code files without YAML config + for (const fileName of allFiles) { + if (fileName.endsWith(".yaml") || fileName.endsWith(".lock")) continue; + + // Extract runnableId from code file + let runnableId: string | null = null; + try { + const lang = inferContentTypeFromFilePath(fileName, defaultTs); + if (lang) { + // The runnableId is the filename without the extension portion + // We need to find which extension matches + for (const ext of exts) { + if (fileName.endsWith(ext)) { + runnableId = fileName.slice(0, -ext.length); + break; + } + } + } + } catch { + continue; + } + + if (!runnableId || processedIds.has(runnableId)) continue; + processedIds.add(runnableId); + + let language: string; + try { + language = inferContentTypeFromFilePath(fileName, defaultTs); + } catch { + continue; + } + + if (!languageNeedsLock(language)) continue; + + const lockFile = path.join(backendDir, `${runnableId}.lock`); + let hasLock = false; + try { + const s = await stat(lockFile); + hasLock = s.size > 0; + } catch { + // No lock file + } + + if (!hasLock) { + issues.push({ + path: rawAppYamlPath, + target: "raw_app_inline_script", + errors: [ + `Missing lock for ${language} runnable '${runnableId}'. Run 'wmill app generate-locks' to generate locks.`, + ], + }); + } + } + + return issues; +} + +/** + * Check for missing lock files across scripts, flow inline scripts, + * app inline scripts, and raw app backend scripts. + * Returns a list of issues for scripts/inline scripts that should have locks but don't. + */ +export async function checkMissingLocks( + opts: GlobalOptions & { defaultTs?: "bun" | "deno" }, + directory?: string, +): Promise { + const initialCwd = process.cwd(); + const targetDirectory = directory + ? path.resolve(initialCwd, directory) + : process.cwd(); + + const { ...syncOpts } = opts; + const mergedOpts = await mergeConfigWithConfigFile(syncOpts); + + const ignore = await ignoreF(mergedOpts); + const root = await FSFSElement(targetDirectory, [], false); + + const issues: FileIssue[] = []; + const defaultTs = mergedOpts.defaultTs; + const flowSuffix = getFolderSuffix("flow"); + const appSuffix = getFolderSuffix("app"); + const rawAppSuffix = getFolderSuffix("raw_app"); + + // Collect all file paths and categorize them + const scriptYamls: string[] = []; + const flowYamls: { normalizedPath: string; fullPath: string }[] = []; + const appYamls: { normalizedPath: string; fullPath: string }[] = []; + const rawAppYamls: { normalizedPath: string; fullPath: string }[] = []; + + for await (const entry of readDirRecursiveWithIgnore(ignore, root)) { + if (entry.isDirectory || entry.ignored) continue; + + const normalizedPath = normalizePath(entry.path); + + // Standalone script metadata files (not inside flow/app folders) + if ( + normalizedPath.endsWith(".script.yaml") && + !isFlowInlineScriptPath(normalizedPath) && + !isAppInlineScriptPath(normalizedPath) + ) { + scriptYamls.push(normalizedPath); + } + + // Flow definition files + if ( + normalizedPath.endsWith("/flow.yaml") && + normalizedPath.includes(flowSuffix + "/") + ) { + flowYamls.push({ + normalizedPath, + fullPath: path.join(targetDirectory, entry.path), + }); + } + + // Normal app definition files + if ( + normalizedPath.endsWith("/app.yaml") && + normalizedPath.includes(appSuffix + "/") + ) { + appYamls.push({ + normalizedPath, + fullPath: path.join(targetDirectory, entry.path), + }); + } + + // Raw app definition files + if ( + normalizedPath.endsWith("/raw_app.yaml") && + normalizedPath.includes(rawAppSuffix + "/") + ) { + rawAppYamls.push({ + normalizedPath, + fullPath: path.join(targetDirectory, entry.path), + }); + } + } + + // Check standalone scripts + for (const yamlPath of scriptYamls) { + const basePath = yamlPath.replace(/\.script\.yaml$/, ""); + + // Find the content file to determine language + let language: ScriptLanguage | null = null; + for (const ext of exts) { + try { + await stat(path.join(targetDirectory, basePath + ext)); + language = inferContentTypeFromFilePath(basePath + ext, defaultTs); + break; + } catch { + // Content file with this extension doesn't exist, try next + } + } + + if (language && languageNeedsLock(language)) { + // Read the metadata to check the lock field + try { + const metadata = (await yamlParseFile( + path.join(targetDirectory, yamlPath), + )) as { lock?: string | string[] }; + + const lockResolved = await isLockResolved( + metadata?.lock, + targetDirectory, + ); + if (!lockResolved) { + issues.push({ + path: yamlPath, + target: "script", + errors: [ + `Missing lock for ${language} script. Run 'wmill script generate-metadata' to generate locks.`, + ], + }); + } + } catch (e) { + log.debug(`Failed to parse ${yamlPath}: ${e}`); + } + } + } + + // Check flow inline scripts + for (const { normalizedPath: flowYamlPath, fullPath } of flowYamls) { + const flowDir = path.dirname(fullPath); + + try { + const flowFile = (await yamlParseFile(fullPath)) as { + value?: { modules?: any[] }; + }; + if (!flowFile?.value?.modules) continue; + + const rawScripts = findRawScriptsInModules(flowFile.value.modules); + + for (const script of rawScripts) { + if (!languageNeedsLock(script.language as ScriptLanguage)) continue; + + const lockResolved = await isLockResolved(script.lock, flowDir); + if (!lockResolved) { + issues.push({ + path: flowYamlPath, + target: "flow_inline_script", + errors: [ + `Missing lock for ${script.language} inline script '${script.id}'. Run 'wmill flow generate-locks' to generate locks.`, + ], + }); + } + } + } catch (e) { + log.debug(`Failed to parse flow ${flowYamlPath}: ${e}`); + } + } + + // Check normal app inline scripts + for (const { normalizedPath: appYamlPath, fullPath } of appYamls) { + const appDir = path.dirname(fullPath); + + try { + const appFile = (await yamlParseFile(fullPath)) as { value?: any }; + if (!appFile?.value) continue; + + const inlineScripts = findInlineScriptsInApp(appFile.value); + for (const script of inlineScripts) { + if (!languageNeedsLock(script.language)) continue; + + const lockResolved = await isLockResolved(script.lock, appDir); + if (!lockResolved) { + issues.push({ + path: appYamlPath, + target: "app_inline_script", + errors: [ + `Missing lock for ${script.language} inline script at '${script.path}'. Run 'wmill app generate-locks' to generate locks.`, + ], + }); + } + } + } catch (e) { + log.debug(`Failed to parse app ${appYamlPath}: ${e}`); + } + } + + // Check raw app backend scripts + for (const { normalizedPath: rawAppYamlPath, fullPath } of rawAppYamls) { + const rawAppDir = path.dirname(fullPath); + const backendDir = path.join(rawAppDir, "backend"); + + try { + await stat(backendDir); + } catch { + continue; // No backend folder + } + + try { + const runnableIssues = await checkRawAppRunnables( + backendDir, + rawAppYamlPath, + defaultTs, + ); + issues.push(...runnableIssues); + } catch (e) { + log.debug(`Failed to check raw app runnables ${rawAppYamlPath}: ${e}`); + } + } + + return issues; +} + export async function runLint( opts: LintOptions, directory?: string, ): Promise { - const initialCwd = Deno.cwd(); + const initialCwd = process.cwd(); const explicitTargetDirectory = directory ? path.resolve(initialCwd, directory) : undefined; const { json: _json, ...syncOpts } = opts; const mergedOpts = await mergeConfigWithConfigFile(syncOpts); - const targetDirectory = explicitTargetDirectory ?? Deno.cwd(); + const targetDirectory = explicitTargetDirectory ?? process.cwd(); - const stats = await Deno.stat(targetDirectory).catch(() => null); + const stats = await stat(targetDirectory).catch(() => null); if (!stats) { throw new Error(`Directory not found: ${targetDirectory}`); } - if (!stats.isDirectory) { + if (!stats.isDirectory()) { throw new Error(`Path is not a directory: ${targetDirectory}`); } @@ -178,6 +681,12 @@ export async function runLint( } } + // Check for missing locks if --locks-required is set + if (opts.locksRequired) { + const lockIssues = await checkMissingLocks(opts, explicitTargetDirectory); + issues.push(...lockIssues); + } + const invalidFiles = issues.length; const shouldFail = invalidFiles > 0 || (!!opts.failOnWarn && warnings.length > 0); @@ -238,7 +747,7 @@ async function lint(opts: LintOptions, directory?: string) { const report = await runLint(opts, directory); printReport(report, !!opts.json); if (report.exitCode !== 0) { - Deno.exit(report.exitCode); + process.exit(report.exitCode); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -257,7 +766,7 @@ async function lint(opts: LintOptions, directory?: string) { } else { log.error(colors.red(`❌ ${message}`)); } - Deno.exit(1); + process.exit(1); } } @@ -268,6 +777,10 @@ const command = new Command() .arguments("[directory:string]") .option("--json", "Output results in JSON format") .option("--fail-on-warn", "Exit with code 1 when warnings are emitted") + .option( + "--locks-required", + "Fail if scripts or flow inline scripts that need locks have no locks", + ) .action(lint as any); export default command; diff --git a/cli/src/commands/queues/queues.ts b/cli/src/commands/queues/queues.ts index 1afcaea269..3f8a18772c 100644 --- a/cli/src/commands/queues/queues.ts +++ b/cli/src/commands/queues/queues.ts @@ -1,5 +1,6 @@ -import { Command, Table } from "../../../deps.ts"; -import { log } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import * as log from "../../core/log.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { pickInstance } from "../instance/instance.ts"; @@ -123,7 +124,7 @@ async function displayQueues(opts: GlobalOptions, workspace?: string) { table.body(body).render(); } catch (error) { - log.error("Failed to fetch queue metrics:", error); + log.error(`Failed to fetch queue metrics: ${error}`); } } else { log.info("No active instance found"); diff --git a/cli/src/commands/resource-type/resource-type.ts b/cli/src/commands/resource-type/resource-type.ts index daeaa3386d..2058c80923 100644 --- a/cli/src/commands/resource-type/resource-type.ts +++ b/cli/src/commands/resource-type/resource-type.ts @@ -1,8 +1,8 @@ -// deno-lint-ignore-file no-explicit-any - import { writeFileSync } from "node:fs"; +import { stat, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; +import { stringify as yamlStringify } from "yaml"; import { GlobalOptions, @@ -12,7 +12,10 @@ import { } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; -import { colors, Command, log, Table } from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import * as log from "../../core/log.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { ResourceType } from "../../../gen/types.gen.ts"; import { compileResourceTypeToTsType } from "../../utils/resource_types.ts"; @@ -65,8 +68,8 @@ export async function pushResourceType( type PushOptions = GlobalOptions; async function push(opts: PushOptions, filePath: string, name: string) { - const fstat = await Deno.stat(filePath); - if (!fstat.isFile) { + const fstat = await stat(filePath); + if (!fstat.isFile()) { throw new Error("file path must refer to a file."); } const workspace = await resolveWorkspace(opts); @@ -83,14 +86,16 @@ async function push(opts: PushOptions, filePath: string, name: string) { log.info(colors.bold.underline.green("Resource pushed")); } -async function list(opts: GlobalOptions & { schema?: boolean }) { +async function list(opts: GlobalOptions & { schema?: boolean; json?: boolean }) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); const res = await wmill.listResourceType({ workspace: workspace.workspaceId, }); - if (opts.schema) { + if (opts.json) { + console.log(JSON.stringify(res)); + } else if (opts.schema) { new Table() .header(["Workspace", "Name", "Schema"]) .padding(2) @@ -113,6 +118,44 @@ async function list(opts: GlobalOptions & { schema?: boolean }) { } } +async function newResourceType(opts: GlobalOptions, name: string) { + const filePath = name + ".resource-type.yaml"; + try { + await stat(filePath); + throw new Error("File already exists: " + filePath); + } catch (e: any) { + if (e.message?.startsWith("File already exists")) throw e; + } + const template: ResourceTypeFile = { + schema: {}, + description: "", + }; + await writeFile(filePath, yamlStringify(template as Record), { + flag: "wx", + encoding: "utf-8", + }); + log.info(colors.green(`Created ${filePath}`)); +} + +async function get(opts: GlobalOptions & { json?: boolean }, path: string) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + const rt = await wmill.getResourceType({ + workspace: workspace.workspaceId, + path, + }); + if (opts.json) { + console.log(JSON.stringify(rt)); + } else { + console.log(colors.bold("Name:") + " " + rt.name); + console.log(colors.bold("Description:") + " " + (rt.description ?? "")); + console.log(colors.bold("Workspace:") + " " + (rt.workspace_id ?? "Global")); + if (rt.schema) { + console.log(colors.bold("Schema:") + " " + JSON.stringify(rt.schema, null, 2)); + } + } +} + export async function generateRTNamespace(opts: GlobalOptions) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -144,10 +187,19 @@ export async function generateRTNamespace(opts: GlobalOptions) { const command = new Command() .description("resource type related commands") - .action(() => log.info("2 actions available, list and push.")) + .option("--json", "Output as JSON (for piping to jq)") + .action(list as any) .command("list", "list all resource types") .option("--schema", "Show schema in the output") + .option("--json", "Output as JSON (for piping to jq)") .action(list as any) + .command("get", "get a resource type's details") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any) + .command("new", "create a new resource type locally") + .arguments("") + .action(newResourceType as any) .command( "push", "push a local resource spec. This overrides any remote versions." diff --git a/cli/src/commands/resource/resource.ts b/cli/src/commands/resource/resource.ts index 4a62a28b70..400130f9b8 100644 --- a/cli/src/commands/resource/resource.ts +++ b/cli/src/commands/resource/resource.ts @@ -1,4 +1,6 @@ -// deno-lint-ignore-file no-explicit-any +import { stat, writeFile } from "node:fs/promises"; +import { stringify as yamlStringify } from "yaml"; + import { GlobalOptions, isSuperset, @@ -7,7 +9,11 @@ import { } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; -import { colors, Command, log, SEP, Table } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; import * as wmill from "../../../gen/services.gen.ts"; import { Resource } from "../../../gen/types.gen.ts"; import { readInlinePathSync } from "../../utils/utils.ts"; @@ -109,8 +115,8 @@ async function push(opts: PushOptions, filePath: string, remotePath: string) { return; } - const fstat = await Deno.stat(filePath); - if (!fstat.isFile) { + const fstat = await stat(filePath); + if (!fstat.isFile()) { throw new Error("file path must refer to a file."); } @@ -126,7 +132,7 @@ async function push(opts: PushOptions, filePath: string, remotePath: string) { log.info(colors.bold.underline.green(`Resource ${remotePath} pushed`)); } -async function list(opts: GlobalOptions) { +async function list(opts: GlobalOptions & { json?: boolean }) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); let page = 0; @@ -145,17 +151,73 @@ async function list(opts: GlobalOptions) { } } - new Table() - .header(["Path", "Resource Type"]) - .padding(2) - .border(true) - .body(total.map((x) => [x.path, x.resource_type])) - .render(); + if (opts.json) { + console.log(JSON.stringify(total)); + } else { + new Table() + .header(["Path", "Resource Type"]) + .padding(2) + .border(true) + .body(total.map((x) => [x.path, x.resource_type])) + .render(); + } +} + +async function newResource(opts: GlobalOptions, path: string) { + if (!validatePath(path)) { + return; + } + const filePath = path + ".resource.yaml"; + try { + await stat(filePath); + throw new Error("File already exists: " + filePath); + } catch (e: any) { + if (e.message?.startsWith("File already exists")) throw e; + // file doesn't exist, proceed + } + const template: ResourceFile = { + value: {}, + resource_type: "", + description: "", + }; + await writeFile(filePath, yamlStringify(template as Record), { + flag: "wx", + encoding: "utf-8", + }); + log.info(colors.green(`Created ${filePath}`)); +} + +async function get(opts: GlobalOptions & { json?: boolean }, path: string) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + const r = await wmill.getResource({ + workspace: workspace.workspaceId, + path, + }); + if (opts.json) { + console.log(JSON.stringify(r)); + } else { + console.log(colors.bold("Path:") + " " + r.path); + console.log(colors.bold("Resource Type:") + " " + (r.resource_type ?? "")); + console.log(colors.bold("Description:") + " " + (r.description ?? "")); + console.log(colors.bold("Value:") + " " + JSON.stringify(r.value, null, 2)); + } } const command = new Command() .description("resource related commands") + .option("--json", "Output as JSON (for piping to jq)") .action(list as any) + .command("list", "list all resources") + .option("--json", "Output as JSON (for piping to jq)") + .action(list as any) + .command("get", "get a resource's details") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any) + .command("new", "create a new resource locally") + .arguments("") + .action(newResource as any) .command( "push", "push a local resource spec. This overrides any remote versions." diff --git a/cli/src/commands/schedule/schedule.ts b/cli/src/commands/schedule/schedule.ts index bebf37aa9e..c8582c5315 100644 --- a/cli/src/commands/schedule/schedule.ts +++ b/cli/src/commands/schedule/schedule.ts @@ -1,5 +1,11 @@ -// deno-lint-ignore-file no-explicit-any -import { colors, Command, log, SEP, Table } from "../../../deps.ts"; +import { stat, writeFile } from "node:fs/promises"; +import { stringify as yamlStringify } from "yaml"; + +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; import * as wmill from "../../../gen/services.gen.ts"; @@ -22,7 +28,7 @@ export interface ScheduleFile { enabled: boolean; } -async function list(opts: GlobalOptions) { +async function list(opts: GlobalOptions & { json?: boolean }) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -30,12 +36,62 @@ async function list(opts: GlobalOptions) { workspace: workspace.workspaceId, }); - new Table() - .header(["Path", "Schedule"]) - .padding(2) - .border(true) - .body(schedules.map((x) => [x.path, x.schedule])) - .render(); + if (opts.json) { + console.log(JSON.stringify(schedules)); + } else { + new Table() + .header(["Path", "Schedule"]) + .padding(2) + .border(true) + .body(schedules.map((x) => [x.path, x.schedule])) + .render(); + } +} + +async function newSchedule(opts: GlobalOptions, path: string) { + if (!validatePath(path)) { + return; + } + const filePath = path + ".schedule.yaml"; + try { + await stat(filePath); + throw new Error("File already exists: " + filePath); + } catch (e: any) { + if (e.message?.startsWith("File already exists")) throw e; + } + const template: ScheduleFile = { + schedule: "0 */6 * * *", + on_failure: "", + script_path: "", + args: {}, + timezone: "Etc/UTC", + is_flow: false, + enabled: false, + }; + await writeFile(filePath, yamlStringify(template as Record), { + flag: "wx", + encoding: "utf-8", + }); + log.info(colors.green(`Created ${filePath}`)); +} + +async function get(opts: GlobalOptions & { json?: boolean }, path: string) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + const s = await wmill.getSchedule({ + workspace: workspace.workspaceId, + path, + }); + if (opts.json) { + console.log(JSON.stringify(s)); + } else { + console.log(colors.bold("Path:") + " " + s.path); + console.log(colors.bold("Schedule:") + " " + s.schedule); + console.log(colors.bold("Timezone:") + " " + (s.timezone ?? "")); + console.log(colors.bold("Script Path:") + " " + (s.script_path ?? "")); + console.log(colors.bold("Is Flow:") + " " + (s.is_flow ? "true" : "false")); + console.log(colors.bold("Enabled:") + " " + (s.enabled ? "true" : "false")); + } } export async function pushSchedule( @@ -114,8 +170,8 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) { return; } - const fstat = await Deno.stat(filePath); - if (!fstat.isFile) { + const fstat = await stat(filePath); + if (!fstat.isFile()) { throw new Error("file path must refer to a file."); } @@ -132,7 +188,18 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) { const command = new Command() .description("schedule related commands") + .option("--json", "Output as JSON (for piping to jq)") .action(list as any) + .command("list", "list all schedules") + .option("--json", "Output as JSON (for piping to jq)") + .action(list as any) + .command("get", "get a schedule's details") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any) + .command("new", "create a new schedule locally") + .arguments("") + .action(newSchedule as any) .command( "push", "push a local schedule spec. This overrides any remote versions." diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index ff962a3eeb..9c6f094b41 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -1,18 +1,15 @@ -// deno-lint-ignore-file no-explicit-any import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; -import { - colors, - Command, - Confirm, - log, - readAll, - SEP, - Table, - writeAllSync, - yamlStringify, -} from "../../../deps.ts"; +import { readFile, writeFile, stat } from "node:fs/promises"; +import { Buffer } from "node:buffer"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { Table } from "@cliffy/table"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; +import { stringify as yamlStringify } from "yaml"; import { deepEqual } from "../../utils/utils.ts"; import * as wmill from "../../../gen/services.gen.ts"; import * as specificItems from "../../core/specific_items.ts"; @@ -51,7 +48,7 @@ import { } from "../../core/conf.ts"; import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts"; import fs from "node:fs"; -import { type Tarball } from "npm:@ayonli/jsext/archive"; +import { createTarBlob, type TarEntry } from "../../utils/tar.ts"; import { execSync } from "node:child_process"; import { NewScript, Script } from "../../../gen/types.gen.ts"; @@ -106,8 +103,8 @@ async function push(opts: PushOptions, filePath: string) { return; } - const fstat = await Deno.stat(filePath); - if (!fstat.isFile) { + const fstat = await stat(filePath); + if (!fstat.isFile()) { throw new Error("file path must refer to a file."); } @@ -159,9 +156,9 @@ export async function findResourceFile(path: string) { const validCandidates = ( await Promise.all( candidates.map((x) => { - return Deno.stat(x) + return stat(x) .catch(() => undefined) - .then((x) => x?.isFile) + .then((x) => x?.isFile()) .then((e) => { return { path: x, file: e }; }); @@ -249,7 +246,7 @@ export async function handleFile( const codebase = language == "bun" ? findCodebase(path, codebases) : undefined; - let bundleContent: string | Tarball | undefined = undefined; + let bundleContent: string | Blob | undefined = undefined; let forceTar = false; if (codebase) { @@ -261,7 +258,7 @@ export async function handleFile( }).toString(); log.info("Custom bundler executed for " + path); } else { - const esbuild = await import("npm:esbuild@0.24.2"); + const esbuild = await import("esbuild"); log.info(`Started bundling ${path} ...`); const startTime = performance.now(); @@ -295,7 +292,6 @@ export async function handleFile( ); } if (outputFiles.length > 1) { - const archiveNpm = await import("npm:@ayonli/jsext/archive"); log.info( `Found multiple output files for ${path}, creating a tarball... ${outputFiles .map((file) => file.path) @@ -303,54 +299,49 @@ export async function handleFile( ); forceTar = true; const startTime = performance.now(); - const tarball = new archiveNpm.Tarball(); const mainPath = path.split(SEP).pop()?.split(".")[0] + ".js"; - const content = + const mainContent = outputFiles.find((file) => file.path == "/" + mainPath)?.text ?? ""; - log.info(`Main content: ${content.length}chars`); - tarball.append(new File([content], "main.js", { type: "text/plain" })); + log.info(`Main content: ${mainContent.length}chars`); + const entries: TarEntry[] = [ + { name: "main.js", content: mainContent }, + ]; for (const file of outputFiles) { if (file.path == "/" + mainPath) { continue; } log.info(`Adding file: ${file.path.substring(1)}`); - // deno-lint-ignore no-explicit-any - const fil = new File([file.contents as any], file.path.substring(1)); - tarball.append(fil); + entries.push({ name: file.path.substring(1), content: file.contents }); } + bundleContent = await createTarBlob(entries); const endTime = performance.now(); log.info( `Finished creating tarball for ${path}: ${( - tarball.size / 1024 + bundleContent.size / 1024 ).toFixed(0)}kB (${(endTime - startTime).toFixed(0)}ms)` ); - bundleContent = tarball; } else { if (Array.isArray(codebase.assets) && codebase.assets.length > 0) { - const archiveNpm = await import("npm:@ayonli/jsext/archive"); log.info( `Using the following asset configuration for ${path}: ${JSON.stringify( codebase.assets )}` ); const startTime = performance.now(); - const tarball = new archiveNpm.Tarball(); - tarball.append( - new File([bundleContent], "main.js", { type: "text/plain" }) - ); + const entries: TarEntry[] = [ + { name: "main.js", content: bundleContent }, + ]; for (const asset of codebase.assets) { const data = fs.readFileSync(asset.from); - const blob = new Blob([data], { type: "text/plain" }); - const file = new File([blob], asset.to); - tarball.append(file); + entries.push({ name: asset.to, content: data }); } + bundleContent = await createTarBlob(entries); const endTime = performance.now(); log.info( `Finished creating tarball for ${path}: ${( - tarball.size / 1024 + bundleContent.size / 1024 ).toFixed(0)}kB (${(endTime - startTime).toFixed(0)}ms)` ); - bundleContent = tarball; } } } @@ -384,7 +375,7 @@ export async function handleFile( } catch { log.debug(`Script ${remotePath} does not exist on remote`); } - const content = await Deno.readTextFile(path); + const content = await readFile(path, "utf-8"); if (opts?.skipScriptsMetadata) { // if (codebase) { @@ -392,17 +383,6 @@ export async function handleFile( // await updateScriptSchema(content, language, typed, path); // if (typedBefore != typed.schema) { // log.info(`Updated metadata for bundle ${path}`); - // showDiff( - // yamlStringify(typedBefore, yamlOptions), - // yamlStringify(typed.schema, yamlOptions) - // ); - // await Deno.writeTextFile( - // remotePath + ".script.yaml", - // yamlStringify(typed as Record, yamlOptions) - // ); - // } - // } - // else { typed = structuredClone(remote); // } } @@ -526,31 +506,8 @@ export async function handleFile( return false; } -async function streamToBlob(stream: ReadableStream): Promise { - // Create a reader from the stream - const reader = stream.getReader(); - const chunks = []; - - // Read the data from the stream - while (true) { - const { done, value } = await reader.read(); - - if (done) { - // If stream is finished, break the loop - break; - } - - // Push the chunk to the array - chunks.push(value); - } - - // deno-lint-ignore no-explicit-any - const blob = new Blob(chunks as any); - return blob; -} - async function createScript( - bundleContent: string | Tarball | undefined, + bundleContent: string | Blob | undefined, workspaceId: string, body: NewScript, workspace: Workspace @@ -577,7 +534,7 @@ async function createScript( "file", typeof bundleContent == "string" ? bundleContent - : await streamToBlob(bundleContent.stream()) + : bundleContent ); const url = @@ -611,9 +568,9 @@ export async function findContentFile(filePath: string) { const validCandidates = ( await Promise.all( candidates.map((x) => { - return Deno.stat(x) + return stat(x) .catch(() => undefined) - .then((x) => x?.isFile) + .then((x) => x?.isFile()) .then((e) => { return { path: x, file: e }; }); @@ -740,6 +697,7 @@ async function list( showArchived?: boolean; includeWithoutMain?: boolean; includeDraftOnly?: boolean; + json?: boolean; } ) { const workspace = await resolveWorkspace(opts); @@ -764,12 +722,16 @@ async function list( } } - new Table() - .header(["path", "summary", "language", "created by"]) - .padding(2) - .border(true) - .body(total.map((x) => [x.path, x.summary, x.language, x.created_by])) - .render(); + if (opts.json) { + console.log(JSON.stringify(total)); + } else { + new Table() + .header(["path", "summary", "language", "created by"]) + .padding(2) + .border(true) + .body(total.map((x) => [x.path, x.summary, x.language, x.created_by])) + .render(); + } } export async function resolve(input: string): Promise> { @@ -778,10 +740,12 @@ export async function resolve(input: string): Promise> { } if (input == "@-") { - input = new TextDecoder().decode(await readAll(Deno.stdin)); + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk); + input = new TextDecoder().decode(Buffer.concat(chunks)); } if (input[0] == "@") { - input = await Deno.readTextFile(input.substring(1)); + input = await readFile(input.substring(1), "utf-8"); } try { return JSON.parse(input); @@ -830,7 +794,7 @@ async function run( break; } catch { - new Promise((resolve, _) => setTimeout(() => resolve(undefined), 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); } } } @@ -872,6 +836,7 @@ export async function track_job(workspace: string, id: string) { log.info("failed to get job updated. skipping log streaming."); break; } + await new Promise((resolve) => setTimeout(resolve, 500)); continue; } @@ -881,7 +846,7 @@ export async function track_job(workspace: string, id: string) { } if (updates.new_logs) { - writeAllSync(Deno.stdout, new TextEncoder().encode(updates.new_logs)); + process.stdout.write(updates.new_logs); logOffset += updates.new_logs.length; } @@ -927,6 +892,26 @@ async function show(opts: GlobalOptions, path: string) { log.info(s.content); } +async function get(opts: GlobalOptions & { json?: boolean }, path: string) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + const s = await wmill.getScriptByPath({ + workspace: workspace.workspaceId, + path, + }); + if (opts.json) { + console.log(JSON.stringify(s)); + } else { + console.log(colors.bold("Path:") + " " + s.path); + console.log(colors.bold("Summary:") + " " + (s.summary ?? "")); + console.log(colors.bold("Description:") + " " + (s.description ?? "")); + console.log(colors.bold("Language:") + " " + s.language); + console.log(colors.bold("Kind:") + " " + (s.kind ?? "script")); + console.log(colors.bold("Created by:") + " " + (s.created_by ?? "")); + console.log(colors.bold("Created at:") + " " + (s.created_at ?? "")); + } +} + async function bootstrap( opts: GlobalOptions & { summary: string; description: string }, scriptPath: string, @@ -951,11 +936,16 @@ async function bootstrap( const scriptMetadataFileFullPath = scriptPath + ".script.yaml"; try { - await Deno.stat(scriptCodeFileFullPath); - await Deno.stat(scriptMetadataFileFullPath); - throw new Error("File already exists in repository"); - } catch { - // file does not exist, we can continue + await stat(scriptCodeFileFullPath); + throw new Error("File already exists: " + scriptCodeFileFullPath); + } catch (e: any) { + if (e.message?.startsWith("File already exists")) throw e; + } + try { + await stat(scriptMetadataFileFullPath); + throw new Error("File already exists: " + scriptMetadataFileFullPath); + } catch (e: any) { + if (e.message?.startsWith("File already exists")) throw e; } const scriptMetadata = defaultScriptMetadata(); @@ -971,14 +961,14 @@ async function bootstrap( yamlOptions ); - await Deno.writeTextFile(scriptCodeFileFullPath, scriptInitialCode, { - createNew: true, + await writeFile(scriptCodeFileFullPath, scriptInitialCode, { + flag: 'wx', encoding: 'utf-8', }); - await Deno.writeTextFile( + await writeFile( scriptMetadataFileFullPath, scriptInitialMetadataYaml, { - createNew: true, + flag: 'wx', encoding: 'utf-8', } ); } @@ -1028,7 +1018,7 @@ async function generateMetadata( // TODO: test this as well. const ignore = await ignoreF(opts); const elems = await elementsToMap( - await FSFSElement(Deno.cwd(), codebases, false), + await FSFSElement(process.cwd(), codebases, false), (p, isD) => { return ( (!isD && !exts.some((ext) => p.endsWith(ext))) || @@ -1107,8 +1097,8 @@ async function preview( return; } - const fstat = await Deno.stat(filePath); - if (!fstat.isFile) { + const fstat = await stat(filePath); + if (!fstat.isFile()) { throw new Error("file path must refer to a file."); } @@ -1120,7 +1110,7 @@ async function preview( const codebases = await listSyncCodebases(opts); const language = inferContentTypeFromFilePath(filePath, opts?.defaultTs); - const content = await Deno.readTextFile(filePath); + const content = await readFile(filePath, "utf-8"); const input = opts.data ? await resolve(opts.data) : {}; // Check if this is a codebase script @@ -1139,7 +1129,7 @@ async function preview( maxBuffer: 1024 * 1024 * 50, }).toString(); } else { - const esbuild = await import("npm:esbuild@0.24.2"); + const esbuild = await import("esbuild"); if (!opts.silent) { log.info(`Bundling ${filePath} for preview...`); @@ -1166,38 +1156,34 @@ async function preview( // Handle multiple output files (create tarball) if (out.outputFiles.length > 1) { - const archiveNpm = await import("npm:@ayonli/jsext/archive"); if (!opts.silent) { log.info(`Creating tarball for multiple output files...`); } - const tarball = new archiveNpm.Tarball(); const mainPath = filePath.split(SEP).pop()?.split(".")[0] + ".js"; const mainContent = out.outputFiles.find((file: OutputFile) => file.path == "/" + mainPath)?.text ?? ""; - tarball.append(new File([mainContent], "main.js", { type: "text/plain" })); + const entries: TarEntry[] = [ + { name: "main.js", content: mainContent }, + ]; for (const file of out.outputFiles) { if (file.path == "/" + mainPath) continue; - // deno-lint-ignore no-explicit-any - const fil = new File([file.contents as any], file.path.substring(1)); - tarball.append(fil); + entries.push({ name: file.path.substring(1), content: file.contents }); } - bundledContent = await streamToBlob(tarball.stream()); + bundledContent = await createTarBlob(entries); isTar = true; } else if (Array.isArray(codebase.assets) && codebase.assets.length > 0) { // Handle assets - const archiveNpm = await import("npm:@ayonli/jsext/archive"); if (!opts.silent) { log.info(`Adding assets to tarball...`); } - const tarball = new archiveNpm.Tarball(); - tarball.append(new File([bundledContent], "main.js", { type: "text/plain" })); + const entries: TarEntry[] = [ + { name: "main.js", content: bundledContent }, + ]; for (const asset of codebase.assets) { const data = fs.readFileSync(asset.from); - const blob = new Blob([data], { type: "text/plain" }); - const file = new File([blob], asset.to); - tarball.append(file); + entries.push({ name: asset.to, content: data }); } - bundledContent = await streamToBlob(tarball.stream()); + bundledContent = await createTarBlob(entries); isTar = true; } @@ -1301,6 +1287,11 @@ async function preview( const command = new Command() .description("script related commands") .option("--show-archived", "Enable archived scripts in output") + .option("--json", "Output as JSON (for piping to jq)") + .action(list as any) + .command("list", "list all scripts") + .option("--show-archived", "Enable archived scripts in output") + .option("--json", "Output as JSON (for piping to jq)") .action(list as any) .command( "push", @@ -1308,7 +1299,11 @@ const command = new Command() ) .arguments("") .action(push as any) - .command("show", "show a scripts content") + .command("get", "get a script's details") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any) + .command("show", "show a script's content (alias for get)") .arguments("") .action(show as any) .command("run", "run a script by path") @@ -1336,7 +1331,12 @@ const command = new Command() "Do not output anything other than the final output. Useful for scripting." ) .action(preview as any) - .command("bootstrap", "create a new script") + .command("new", "create a new script") + .arguments(" ") + .option("--summary ", "script summary") + .option("--description ", "script description") + .action(bootstrap as any) + .command("bootstrap", "create a new script (alias for new)") .arguments(" ") .option("--summary ", "script summary") .option("--description ", "script description") diff --git a/cli/src/commands/sync/global.ts b/cli/src/commands/sync/global.ts index 1b5e975aa6..b30848942a 100644 --- a/cli/src/commands/sync/global.ts +++ b/cli/src/commands/sync/global.ts @@ -1,4 +1,5 @@ -import { colors, log } from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; let GLOBAL_VERSIONS: { remoteMajor: number | undefined; diff --git a/cli/src/commands/sync/pull.ts b/cli/src/commands/sync/pull.ts index 607d789a6b..2d72bc35cf 100644 --- a/cli/src/commands/sync/pull.ts +++ b/cli/src/commands/sync/pull.ts @@ -1,6 +1,8 @@ -// deno-lint-ignore-file no-explicit-any import { GlobalOptions } from "../../types.ts"; -import { colors, Command, JSZip, log } from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import * as log from "../../core/log.ts"; +import JSZip from "jszip"; import { Workspace } from "../workspace/workspace.ts"; import { getHeaders } from "../../utils/utils.ts"; diff --git a/cli/src/commands/sync/push.ts b/cli/src/commands/sync/push.ts index 01f6bea2da..e95fa048be 100644 --- a/cli/src/commands/sync/push.ts +++ b/cli/src/commands/sync/push.ts @@ -1,5 +1,6 @@ -// deno-lint-ignore-file no-explicit-any -import { colors, Command, log } from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import * as log from "../../core/log.ts"; import { GlobalOptions } from "../../types.ts"; function stub(_opts: GlobalOptions, _dir?: string) { diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index b1b6142ce5..bf2ecf7826 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1,18 +1,16 @@ import { requireLogin } from "../../core/auth.ts"; import { fetchVersion, resolveWorkspace } from "../../core/context.ts"; -import { - colors, - Command, - Confirm, - ensureDir, - JSZip, - log, - minimatch, - path, - SEP, - yamlParseContent, - yamlStringify, -} from "../../../deps.ts"; +import { readFile, writeFile, readdir, stat, rm, copyFile, mkdir } from "node:fs/promises"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import * as log from "../../core/log.ts"; +import * as path from "node:path"; +import { sep as SEP } from "node:path"; +import { stringify as yamlStringify, type DocumentOptions, type SchemaOptions, type CreateNodeOptions, type ToStringOptions } from "yaml"; +import JSZip from "jszip"; +import { minimatch } from "minimatch"; +import { yamlParseContent } from "../../utils/yaml.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { @@ -25,7 +23,7 @@ import { extractNativeTriggerInfo, } from "../../types.ts"; import { downloadZip } from "./pull.ts"; -import { runLint, printReport } from "../lint/lint.ts"; +import { runLint, printReport, checkMissingLocks } from "../lint/lint.ts"; import { exts, @@ -178,7 +176,7 @@ async function addCodebaseDigestIfRelevant( let isTs = true; const replacedPath = path.replace(".script.yaml", ".ts"); try { - await Deno.stat(replacedPath); + await stat(replacedPath); } catch { isTs = false; } @@ -231,10 +229,11 @@ export async function FSFSElement( async *getChildren(): AsyncIterable { if (!isDir) return []; try { - for await (const e of Deno.readDir(localP)) { + const entries = await readdir(localP, { withFileTypes: true }); + for (const e of entries) { yield _internal_element( path.join(localP, e.name), - e.isDirectory, + e.isDirectory(), codebases, ); } @@ -242,11 +241,8 @@ export async function FSFSElement( log.warn(`Error reading dir: ${localP}, ${e}`); } }, - // async getContentBytes(): Promise { - // return await Deno.readFile(localP); - // }, async getContentText(): Promise { - const content = await Deno.readTextFile(localP); + const content = await readFile(localP, "utf-8"); const itemPath = localP.substring(p.length + 1); const r = await addCodebaseDigestIfRelevant( itemPath, @@ -258,7 +254,7 @@ export async function FSFSElement( }, }; } - return _internal_element(p, (await Deno.stat(p)).isDirectory, codebases); + return _internal_element(p, (await stat(p)).isDirectory(), codebases); } function prioritizeName(name: string): string { @@ -280,13 +276,12 @@ function prioritizeName(name: string): string { return name; } -export const yamlOptions = { - sortKeys: (a: any, b: any) => { - return prioritizeName(a).localeCompare(prioritizeName(b)); +export const yamlOptions: DocumentOptions & SchemaOptions & CreateNodeOptions & ToStringOptions = { + sortMapEntries: (a, b) => { + return prioritizeName(String(a.key)).localeCompare(prioritizeName(String(b.key))); }, - noCompatMode: true, - noRefs: true, - skipInvalid: true, + aliasDuplicateObjects: false, + singleQuote: true, }; export interface InlineScript { @@ -573,7 +568,6 @@ function ZipFSElement( isDirectory: false, path: path.join(finalPath, s.path), async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return s.content; }, @@ -584,7 +578,6 @@ function ZipFSElement( isDirectory: false, path: path.join(finalPath, "flow.yaml"), async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return yamlStringify(flow, yamlOptions); }, @@ -618,7 +611,6 @@ function ZipFSElement( isDirectory: false, path: path.join(finalPath, s.path), async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return s.content; }, @@ -633,7 +625,6 @@ function ZipFSElement( isDirectory: false, path: path.join(finalPath, "app.yaml"), async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return yamlStringify(app, yamlOptions); }, @@ -690,8 +681,7 @@ function ZipFSElement( isDirectory: false, path: path.join(finalPath, filePath.substring(1)), async *getChildren() {}, - // deno-lint-ignore require-await - async getContentText() { + async getContentText() { if (typeof content !== "string") { throw new Error( `Content of raw app file ${filePath} is not a string`, @@ -712,7 +702,6 @@ function ZipFSElement( isDirectory: false, path: path.join(finalPath, APP_BACKEND_FOLDER, s.path), async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return s.content; }, @@ -792,7 +781,6 @@ function ZipFSElement( `${runnableId}.yaml`, ), async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return yamlStringify(simplifiedRunnable, yamlOptions); }, @@ -813,7 +801,6 @@ function ZipFSElement( isDirectory: false, path: path.join(finalPath, "raw_app.yaml"), async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return yamlStringify(rawApp, yamlOptions); }, @@ -824,7 +811,6 @@ function ZipFSElement( isDirectory: false, path: path.join(finalPath, "DATATABLES.md"), async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return generateDatatablesDocumentation(data); }, @@ -917,7 +903,6 @@ function ZipFSElement( isDirectory: false, path: removeSuffix(finalPath, ".json") + ".lock", async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return lock; }, @@ -946,7 +931,6 @@ function ZipFSElement( ".resource.file." + formatExtension, async *getChildren() {}, - // deno-lint-ignore require-await async getContentText() { return fileContent; }, @@ -975,11 +959,6 @@ function ZipFSElement( } } }, - // // deno-lint-ignore require-await - // async getContentBytes(): Promise { - // throw new Error("Cannot get content of folder"); - // }, - // deno-lint-ignore require-await async getContentText(): Promise { throw new Error("Cannot get content of folder"); }, @@ -1358,17 +1337,19 @@ async function compareDynFSElement( continue; } if (!ignoreCodebaseChanges) { + const beforeCodebase = before?.codebase; + const afterCodebase = after?.codebase; if (before?.codebase != undefined) { delete before.codebase; m2[k] = yamlStringify(before, yamlOptions); } if (after?.codebase != undefined) { - if (before.codebase != after.codebase) { - codebaseChanges[k] = after.codebase; - } delete after.codebase; v = yamlStringify(after, yamlOptions); } + if (beforeCodebase != afterCodebase) { + codebaseChanges[k] = afterCodebase ?? beforeCodebase ?? ""; + } } if (skipMetadata) { continue; @@ -1580,7 +1561,7 @@ export async function ignoreF(wmillconf: { } try { - await Deno.stat(".wmillignore"); + await stat(".wmillignore"); throw Error(".wmillignore is not supported anymore, switch to wmill.yaml"); } catch { //expected @@ -1636,7 +1617,6 @@ interface ChangeTracker { rawApps: string[]; } -// deno-lint-ignore no-inner-declarations async function addToChangedIfNotExists(p: string, tracker: ChangeTracker) { const isScript = exts.some((e) => p.endsWith(e)); if (isScript) { @@ -1700,13 +1680,13 @@ export async function pull( } catch (error) { if (error instanceof Error && error.message.includes("overrides")) { log.error(error.message); - Deno.exit(1); + process.exit(1); } throw error; } if (opts.stateful) { - await ensureDir(path.join(Deno.cwd(), ".wmill")); + await mkdir(path.join(process.cwd(), ".wmill"), { recursive: true }); } const workspace = await resolveWorkspace(opts, opts.branch); @@ -1769,8 +1749,8 @@ export async function pull( ); const local = !opts.stateful - ? await FSFSElement(Deno.cwd(), codebases, true) - : await FSFSElement(path.join(Deno.cwd(), ".wmill"), [], true); + ? await FSFSElement(process.cwd(), codebases, true) + : await FSFSElement(path.join(process.cwd(), ".wmill"), [], true); const changes = await compareDynFSElement( remote, @@ -1852,12 +1832,12 @@ export async function pull( } } - const target = path.join(Deno.cwd(), targetPath); - const stateTarget = path.join(Deno.cwd(), ".wmill", targetPath); + const target = path.join(process.cwd(), targetPath); + const stateTarget = path.join(process.cwd(), ".wmill", targetPath); if (change.name === "edited") { if (opts.stateful) { try { - const currentLocal = await Deno.readTextFile(target); + const currentLocal = await readFile(target, "utf-8"); if ( currentLocal !== change.before && currentLocal !== change.after @@ -1915,16 +1895,16 @@ export async function pull( }`, ); } - await Deno.writeTextFile(target, change.after); + await writeFile(target, change.after, "utf-8"); if (opts.stateful) { - await ensureDir(path.dirname(stateTarget)); - await Deno.copyFile(target, stateTarget); + await mkdir(path.dirname(stateTarget), { recursive: true }); + await copyFile(target, stateTarget); } } else if (change.name === "added") { - await ensureDir(path.dirname(target)); + await mkdir(path.dirname(target), { recursive: true }); if (opts.stateful) { - await ensureDir(path.dirname(stateTarget)); + await mkdir(path.dirname(stateTarget), { recursive: true }); log.info( `Adding ${getTypeStrFromPath(change.path)} ${targetPath}${ targetPath !== change.path @@ -1933,7 +1913,7 @@ export async function pull( }`, ); } - await Deno.writeTextFile(target, change.content); + await writeFile(target, change.content, "utf-8"); log.info( `Writing ${getTypeStrFromPath(change.path)} ${targetPath}${ targetPath !== change.path @@ -1942,20 +1922,20 @@ export async function pull( }`, ); if (opts.stateful) { - await Deno.copyFile(target, stateTarget); + await copyFile(target, stateTarget); } } else if (change.name === "deleted") { try { log.info( `Deleting ${getTypeStrFromPath(change.path)} ${change.path}`, ); - await Deno.remove(target); + await rm(target); if (opts.stateful) { - await Deno.remove(stateTarget); + await rm(stateTarget); } } catch { if (opts.stateful) { - await Deno.remove(stateTarget); + await rm(stateTarget); } } } @@ -1973,7 +1953,7 @@ export async function pull( - pushing the changes with \`wmill push --skip-pull\` to override wmill with all your local changes `), ); - Deno.exit(1); + process.exit(1); } } log.info("All local changes pulled, now updating wmill-lock.yaml"); @@ -2189,7 +2169,7 @@ export async function push( } catch (error) { if (error instanceof Error && error.message.includes("overrides")) { log.error(error.message); - Deno.exit(1); + process.exit(1); } throw error; } @@ -2217,10 +2197,29 @@ export async function push( printReport(lintReport, !!opts.jsonOutput); if (!lintReport.success) { log.error(colors.red("Push aborted due to lint failures.")); - Deno.exit(1); + process.exit(1); } } + if (opts.locksRequired) { + log.info("Checking for missing locks..."); + const lockIssues = await checkMissingLocks(opts); + if (lockIssues.length > 0) { + for (const issue of lockIssues) { + for (const error of issue.errors) { + log.error(colors.red(` ${issue.path}: ${error}`)); + } + } + log.error( + colors.red( + `\nPush aborted: ${lockIssues.length} script(s) missing locks.`, + ), + ); + process.exit(1); + } + log.info(colors.green("All scripts have valid locks.")); + } + const codebases = await listSyncCodebases(opts); if (opts.raw) { log.info("--raw is now the default, you can remove it as a flag"); @@ -2273,7 +2272,7 @@ export async function push( false, ); - const local = await FSFSElement(path.join(Deno.cwd(), ""), codebases, false); + const local = await FSFSElement(path.join(process.cwd(), ""), codebases, false); const changes = await compareDynFSElement( local, remote, @@ -2444,7 +2443,7 @@ export async function push( let stateful = opts.stateful; if (stateful) { try { - await Deno.stat(path.join(Deno.cwd(), ".wmill")); + await stat(path.join(process.cwd(), ".wmill")); } catch { stateful = false; } @@ -2507,8 +2506,8 @@ export async function push( let stateTarget = undefined; if (stateful) { try { - stateTarget = path.join(Deno.cwd(), ".wmill", change.path); - await Deno.stat(stateTarget); + stateTarget = path.join(process.cwd(), ".wmill", change.path); + await stat(stateTarget); } catch { stateTarget = undefined; } @@ -2527,7 +2526,7 @@ export async function push( ) ) { if (stateTarget) { - await Deno.writeTextFile(stateTarget, change.after); + await writeFile(stateTarget, change.after, "utf-8"); } continue; } else if ( @@ -2542,12 +2541,12 @@ export async function push( ) ) { if (stateTarget) { - await Deno.writeTextFile(stateTarget, change.after); + await writeFile(stateTarget, change.after, "utf-8"); } continue; } if (stateTarget) { - await ensureDir(path.dirname(stateTarget)); + await mkdir(path.dirname(stateTarget), { recursive: true }); log.info( `Editing ${getTypeStrFromPath(change.path)} ${change.path}`, ); @@ -2560,7 +2559,7 @@ export async function push( const newObj = parseFromPath( resourceFilePath, - await Deno.readTextFile(resourceFilePath), + await readFile(resourceFilePath, "utf-8"), ); // For branch-specific resources, push to the base path on the workspace server @@ -2583,7 +2582,7 @@ export async function push( resourceFilePath, ); if (stateTarget) { - await Deno.writeTextFile(stateTarget, change.after); + await writeFile(stateTarget, change.after, "utf-8"); } continue; } @@ -2613,7 +2612,7 @@ export async function push( ); if (stateTarget) { - await Deno.writeTextFile(stateTarget, change.after); + await writeFile(stateTarget, change.after, "utf-8"); } } else if (change.name === "added") { if ( @@ -2637,7 +2636,7 @@ export async function push( continue; } if (stateTarget) { - await ensureDir(path.dirname(stateTarget)); + await mkdir(path.dirname(stateTarget), { recursive: true }); log.info( `Adding ${getTypeStrFromPath(change.path)} ${change.path}`, ); @@ -2670,7 +2669,7 @@ export async function push( ); if (stateTarget) { - await Deno.writeTextFile(stateTarget, change.content); + await writeFile(stateTarget, change.content, "utf-8"); } } else if (change.name === "deleted") { if (change.path.endsWith(".lock")) { @@ -2735,7 +2734,7 @@ export async function push( let folderExists = false; if (rawAppFolder) { try { - await Deno.stat(rawAppFolder); + await stat(rawAppFolder); folderExists = true; } catch { // folder doesn't exist @@ -2903,7 +2902,7 @@ export async function push( } if (stateTarget) { try { - await Deno.remove(stateTarget); + await rm(stateTarget); } catch { // state target may not exist already } @@ -3032,7 +3031,6 @@ const command = new Command() "--branch ", "Override the current git branch (works even outside a git repository)", ) - // deno-lint-ignore no-explicit-any .action(pull as any) .command("push") .description("Push any local changes and apply them remotely.") @@ -3090,7 +3088,10 @@ const command = new Command() "Override the current git branch (works even outside a git repository)", ) .option("--lint", "Run lint validation before pushing") - // deno-lint-ignore no-explicit-any + .option( + "--locks-required", + "Fail if scripts or flow inline scripts that need locks have no locks", + ) .action(push as any); export default command; diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index 58bf626523..be645a83fa 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -1,3 +1,6 @@ +import { stat, writeFile } from "node:fs/promises"; +import { stringify as yamlStringify } from "yaml"; + import * as wmill from "../../../gen/services.gen.ts"; import { GcpTrigger, @@ -13,7 +16,11 @@ import { NativeTriggerData, NativeServiceName, } from "../../../gen/types.gen.ts"; -import { colors, Command, log, SEP, Table } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; import { GlobalOptions, isSuperset, @@ -289,37 +296,192 @@ export async function pushNativeTrigger( } } -async function list(opts: GlobalOptions) { +const triggerTemplates: Record> = { + http: { + script_path: "", + is_flow: false, + route_path: "", + http_method: "get", + is_async: false, + requires_auth: true, + }, + websocket: { + script_path: "", + is_flow: false, + url: "", + enabled: false, + }, + kafka: { + script_path: "", + is_flow: false, + kafka_resource_path: "", + group_id: "", + topics: [], + enabled: false, + }, + nats: { + script_path: "", + is_flow: false, + nats_resource_path: "", + subjects: [], + enabled: false, + }, + postgres: { + script_path: "", + is_flow: false, + postgres_resource_path: "", + publication_name: "", + replication_slot_name: "", + enabled: false, + }, + mqtt: { + script_path: "", + is_flow: false, + mqtt_resource_path: "", + topics: [], + subscribe_qos: 0, + enabled: false, + }, + sqs: { + script_path: "", + is_flow: false, + sqs_resource_path: "", + queue_url: "", + enabled: false, + }, + gcp: { + script_path: "", + is_flow: false, + gcp_resource_path: "", + subscription_id: "", + topic_id: "", + enabled: false, + }, + email: { + script_path: "", + is_flow: false, + enabled: false, + }, +}; + +async function newTrigger(opts: GlobalOptions & { kind: string }, path: string) { + if (!validatePath(path)) { + return; + } + if (!opts.kind) { + throw new Error("--kind is required. Valid kinds: " + TRIGGER_TYPES.join(", ")); + } + if (!checkIfValidTrigger(opts.kind)) { + throw new Error("Invalid trigger kind: " + opts.kind + ". Valid kinds: " + TRIGGER_TYPES.join(", ")); + } + const kind: TriggerType = opts.kind; + const filePath = `${path}.${kind}_trigger.yaml`; + try { + await stat(filePath); + throw new Error("File already exists: " + filePath); + } catch (e: any) { + if (e.message?.startsWith("File already exists")) throw e; + } + const template = triggerTemplates[kind]; + await writeFile(filePath, yamlStringify(template), { + flag: "wx", + encoding: "utf-8", + }); + log.info(colors.green(`Created ${filePath}`)); +} + +async function get(opts: GlobalOptions & { json?: boolean; kind?: string }, path: string) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); - const httpTriggers = await wmill.listHttpTriggers({ - workspace: workspace.workspaceId, - }); - const websocketTriggers = await wmill.listWebsocketTriggers({ - workspace: workspace.workspaceId, - }); - const kafkaTriggers = await wmill.listKafkaTriggers({ - workspace: workspace.workspaceId, - }); - const natsTriggers = await wmill.listNatsTriggers({ - workspace: workspace.workspaceId, - }); - const postgresTriggers = await wmill.listPostgresTriggers({ - workspace: workspace.workspaceId, - }); - const mqttTriggers = await wmill.listMqttTriggers({ - workspace: workspace.workspaceId, - }); - const sqsTriggers = await wmill.listSqsTriggers({ - workspace: workspace.workspaceId, - }); - const gcpTriggers = await wmill.listGcpTriggers({ - workspace: workspace.workspaceId, - }); - const emailTriggers = await wmill.listEmailTriggers({ - workspace: workspace.workspaceId, - }); + if (opts.kind) { + if (!checkIfValidTrigger(opts.kind)) { + throw new Error("Invalid trigger kind: " + opts.kind + ". Valid kinds: " + TRIGGER_TYPES.join(", ")); + } + const trigger = await getTrigger(opts.kind, workspace.workspaceId, path); + if (opts.json) { + console.log(JSON.stringify(trigger)); + } else { + console.log(colors.bold("Path:") + " " + (trigger as any).path); + console.log(colors.bold("Kind:") + " " + opts.kind); + console.log(colors.bold("Enabled:") + " " + ((trigger as any).enabled ?? "-")); + console.log(colors.bold("Script Path:") + " " + ((trigger as any).script_path ?? "")); + console.log(colors.bold("Is Flow:") + " " + ((trigger as any).is_flow ? "true" : "false")); + } + return; + } + + // Try all trigger types and collect matches + const matches: { kind: string; trigger: any }[] = []; + for (const kind of TRIGGER_TYPES) { + try { + const trigger = await getTrigger(kind, workspace.workspaceId, path); + matches.push({ kind, trigger }); + } catch { + // not found for this kind + } + } + + if (matches.length === 0) { + throw new Error("No trigger found at path: " + path); + } + + if (matches.length === 1) { + const { kind, trigger } = matches[0]; + if (opts.json) { + console.log(JSON.stringify(trigger)); + } else { + console.log(colors.bold("Path:") + " " + trigger.path); + console.log(colors.bold("Kind:") + " " + kind); + console.log(colors.bold("Enabled:") + " " + (trigger.enabled ?? "-")); + console.log(colors.bold("Script Path:") + " " + (trigger.script_path ?? "")); + console.log(colors.bold("Is Flow:") + " " + (trigger.is_flow ? "true" : "false")); + } + return; + } + + // Multiple matches — ask user to specify --kind + console.log("Multiple triggers found at path " + path + ":"); + for (const m of matches) { + console.log(" - " + m.kind); + } + console.log("Please specify --kind to select one."); +} + +async function listOrEmpty(fn: () => Promise): Promise { + try { + return await fn(); + } catch { + return []; + } +} + +async function list(opts: GlobalOptions & { json?: boolean }) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const ws = workspace.workspaceId; + const [ + httpTriggers, + websocketTriggers, + kafkaTriggers, + natsTriggers, + postgresTriggers, + mqttTriggers, + sqsTriggers, + gcpTriggers, + emailTriggers, + ] = await Promise.all([ + listOrEmpty(() => wmill.listHttpTriggers({ workspace: ws })), + listOrEmpty(() => wmill.listWebsocketTriggers({ workspace: ws })), + listOrEmpty(() => wmill.listKafkaTriggers({ workspace: ws })), + listOrEmpty(() => wmill.listNatsTriggers({ workspace: ws })), + listOrEmpty(() => wmill.listPostgresTriggers({ workspace: ws })), + listOrEmpty(() => wmill.listMqttTriggers({ workspace: ws })), + listOrEmpty(() => wmill.listSqsTriggers({ workspace: ws })), + listOrEmpty(() => wmill.listGcpTriggers({ workspace: ws })), + listOrEmpty(() => wmill.listEmailTriggers({ workspace: ws })), + ]); const triggers = [ ...httpTriggers.map((x) => ({ path: x.path, kind: "http" })), ...websocketTriggers.map((x) => ({ path: x.path, kind: "websocket" })), @@ -332,12 +494,16 @@ async function list(opts: GlobalOptions) { ...emailTriggers.map((x) => ({ path: x.path, kind: "email" })), ]; - new Table() - .header(["Path", "Kind"]) - .padding(2) - .border(true) - .body(triggers.map((x) => [x.path, x.kind])) - .render(); + if (opts.json) { + console.log(JSON.stringify(triggers)); + } else { + new Table() + .header(["Path", "Kind"]) + .padding(2) + .border(true) + .body(triggers.map((x) => [x.path, x.kind])) + .render(); + } } function checkIfValidTrigger(kind: string | undefined): kind is TriggerType { @@ -372,8 +538,8 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) { return; } - const fstat = await Deno.stat(filePath); - if (!fstat.isFile) { + const fstat = await stat(filePath); + if (!fstat.isFile()) { throw new Error("file path must refer to a file."); } @@ -395,7 +561,20 @@ async function push(opts: GlobalOptions, filePath: string, remotePath: string) { const command = new Command() .description("trigger related commands") + .option("--json", "Output as JSON (for piping to jq)") .action(list as any) + .command("list", "list all triggers") + .option("--json", "Output as JSON (for piping to jq)") + .action(list as any) + .command("get", "get a trigger's details") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .option("--kind ", "Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email). Recommended for faster lookup") + .action(get as any) + .command("new", "create a new trigger locally") + .arguments("") + .option("--kind ", "Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)") + .action(newTrigger as any) .command( "push", "push a local trigger spec. This overrides any remote versions." diff --git a/cli/src/commands/user/user.ts b/cli/src/commands/user/user.ts index 93ab9236c5..207958ecce 100644 --- a/cli/src/commands/user/user.ts +++ b/cli/src/commands/user/user.ts @@ -1,4 +1,5 @@ -// deno-lint-ignore-file no-explicit-any +import { writeFile } from "node:fs/promises"; + import { requireLogin } from "../../core/auth.ts"; import { GlobalOptions, @@ -7,14 +8,12 @@ import { removePathPrefix, } from "../../types.ts"; import { compareInstanceObjects, InstanceSyncOptions } from "../instance/instance.ts"; -import { - colors, - Command, - log, - Table, - yamlStringify, - yamlParseFile, -} from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import * as log from "../../core/log.ts"; +import { stringify as yamlStringify } from "yaml"; +import { yamlParseFile } from "../../utils/yaml.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { ExportedInstanceGroup, @@ -417,9 +416,10 @@ export async function pullInstanceUsers( return compareInstanceObjects(remoteUsers, localUsers, "email", "user"); } else { log.info("Pulling users from instance..."); - await Deno.writeTextFile( + await writeFile( instanceUsersPath, - yamlStringify(remoteUsers as any) + yamlStringify(remoteUsers as any), + "utf-8" ); log.info(colors.green(`Users written to ${instanceUsersPath}`)); } @@ -486,9 +486,10 @@ export async function pullInstanceGroups( } else { log.info("Pulling groups from instance..."); - await Deno.writeTextFile( + await writeFile( instanceGroupsPath, - yamlStringify(remoteGroups as any) + yamlStringify(remoteGroups as any), + "utf-8" ); log.info(colors.green(`Groups written to ${instanceGroupsPath}`)); diff --git a/cli/src/commands/variable/variable.ts b/cli/src/commands/variable/variable.ts index 60a4f7320d..21b7a69eba 100644 --- a/cli/src/commands/variable/variable.ts +++ b/cli/src/commands/variable/variable.ts @@ -1,4 +1,6 @@ -// deno-lint-ignore-file no-explicit-any +import { stat, writeFile } from "node:fs/promises"; +import { stringify as yamlStringify } from "yaml"; + import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; import { @@ -7,12 +9,17 @@ import { parseFromFile, removeType, } from "../../types.ts"; -import { colors, Command, Confirm, log, SEP, Table } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import { colors } from "@cliffy/ansi/colors"; +import { Confirm } from "@cliffy/prompt/confirm"; +import * as log from "../../core/log.ts"; +import { sep as SEP } from "node:path"; import * as wmill from "../../../gen/services.gen.ts"; import { ListableVariable } from "../../../gen/types.gen.ts"; -async function list(opts: GlobalOptions) { +async function list(opts: GlobalOptions & { json?: boolean }) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -20,19 +27,64 @@ async function list(opts: GlobalOptions) { workspace: workspace.workspaceId, }); - new Table() - .header(["Path", "Is Secret", "Account", "Value"]) - .padding(2) - .border(true) - .body( - variables.map((x) => [ - x.path, - x.is_secret ? "true" : "false", - x.account ?? "-", - x.value ?? "-", - ]) - ) - .render(); + if (opts.json) { + console.log(JSON.stringify(variables)); + } else { + new Table() + .header(["Path", "Is Secret", "Account", "Value"]) + .padding(2) + .border(true) + .body( + variables.map((x) => [ + x.path, + x.is_secret ? "true" : "false", + x.account ?? "-", + x.value ?? "-", + ]) + ) + .render(); + } +} + +async function newVariable(opts: GlobalOptions, path: string) { + if (!validatePath(path)) { + return; + } + const filePath = path + ".variable.yaml"; + try { + await stat(filePath); + throw new Error("File already exists: " + filePath); + } catch (e: any) { + if (e.message?.startsWith("File already exists")) throw e; + } + const template: VariableFile = { + value: "", + is_secret: false, + description: "", + }; + await writeFile(filePath, yamlStringify(template as Record), { + flag: "wx", + encoding: "utf-8", + }); + log.info(colors.green(`Created ${filePath}`)); +} + +async function get(opts: GlobalOptions & { json?: boolean }, path: string) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + const v = await wmill.getVariable({ + workspace: workspace.workspaceId, + path, + }); + if (opts.json) { + console.log(JSON.stringify(v)); + } else { + console.log(colors.bold("Path:") + " " + v.path); + console.log(colors.bold("Value:") + " " + (v.value ?? "-")); + console.log(colors.bold("Is Secret:") + " " + (v.is_secret ? "true" : "false")); + console.log(colors.bold("Description:") + " " + (v.description ?? "")); + console.log(colors.bold("Account:") + " " + (v.account ?? "-")); + } } export interface VariableFile { @@ -108,8 +160,8 @@ async function push( return; } - const fstat = await Deno.stat(filePath); - if (!fstat.isFile) { + const fstat = await stat(filePath); + if (!fstat.isFile()) { throw new Error("file path must refer to a file."); } @@ -172,7 +224,18 @@ async function add( const command = new Command() .description("variable related commands") + .option("--json", "Output as JSON (for piping to jq)") .action(list as any) + .command("list", "list all variables") + .option("--json", "Output as JSON (for piping to jq)") + .action(list as any) + .command("get", "get a variable's details") + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .action(get as any) + .command("new", "create a new variable locally") + .arguments("") + .action(newVariable as any) .command( "push", "Push a local variable spec. This overrides any remote versions." diff --git a/cli/src/commands/worker-groups/worker-groups.ts b/cli/src/commands/worker-groups/worker-groups.ts index a683021bc0..30c14249ad 100644 --- a/cli/src/commands/worker-groups/worker-groups.ts +++ b/cli/src/commands/worker-groups/worker-groups.ts @@ -1,6 +1,8 @@ -import { Command, Confirm, setClient, Table } from "../../../deps.ts"; - -import { log } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import { Confirm } from "@cliffy/prompt/confirm"; +import * as log from "../../core/log.ts"; +import { setClient } from "../../core/client.ts"; import { allInstances, getActiveInstance, InstanceSyncOptions, pickInstance } from "../instance/instance.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { pullInstanceConfigs, pushInstanceConfigs } from "../../core/settings.ts"; diff --git a/cli/src/commands/workers/workers.ts b/cli/src/commands/workers/workers.ts index f3d00ff63f..7ea5d074ab 100644 --- a/cli/src/commands/workers/workers.ts +++ b/cli/src/commands/workers/workers.ts @@ -1,5 +1,6 @@ -import { Command, Table } from "../../../deps.ts"; -import { log } from "../../../deps.ts"; +import { Command } from "@cliffy/command"; +import { Table } from "@cliffy/table"; +import * as log from "../../core/log.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { pickInstance } from "../instance/instance.ts"; diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 0bd3b9eec6..619f29fa2c 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -1,6 +1,8 @@ -// deno-lint-ignore-file no-explicit-any import { GlobalOptions } from "../../types.ts"; -import { colors, Input, log, setClient } from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { Input } from "@cliffy/prompt/input"; +import * as log from "../../core/log.ts"; +import { setClient } from "../../core/client.ts"; import { allWorkspaces, list, removeWorkspace } from "./workspace.ts"; import * as wmill from "../../../gen/services.gen.ts"; import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, isGitRepository } from "../../utils/git.ts"; @@ -159,7 +161,7 @@ async function deleteWorkspaceFork( } if (!opts.yes) { - const { Select } = await import("../../../deps.ts"); + const { Select } = await import("@cliffy/prompt/select"); const choice = await Select.prompt({ message: `Are you sure you want to delete the forked workspace with id: \`${workspace.workspaceId}\`? This action will delete the workspace `, options: [ diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 4625e589ca..b70469073c 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -1,19 +1,18 @@ -// deno-lint-ignore-file no-explicit-any +import { readFile, writeFile, open as fsOpen } from "node:fs/promises"; +import process from "node:process"; import { GlobalOptions } from "../../types.ts"; import { getActiveWorkspaceConfigFilePath, getWorkspaceConfigFilePath, } from "../../../windmill-utils-internal/src/config/config.ts"; import { loginInteractive, tryGetLoginInfo } from "../../core/login.ts"; -import { - colors, - Command, - Confirm, - Input, - log, - setClient, - Table, -} from "../../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { Input } from "@cliffy/prompt/input"; +import { Table } from "@cliffy/table"; +import * as log from "../../core/log.ts"; +import { setClient } from "../../core/client.ts"; import { requireLogin } from "../../core/auth.ts"; import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts"; @@ -31,7 +30,7 @@ export async function allWorkspaces( ): Promise { try { const file = await getWorkspaceConfigFilePath(configDirOverride); - const txt = await Deno.readTextFile(file); + const txt = await readFile(file, "utf-8"); return txt .split("\n") .map((line) => { @@ -55,7 +54,7 @@ async function getActiveWorkspaceName( } try { const file = await getActiveWorkspaceConfigFilePath(opts?.configDir); - return await Deno.readTextFile(file); + return await readFile(file, "utf-8"); } catch { return undefined; } @@ -146,7 +145,7 @@ export async function setActiveWorkspace( configDirOverride?: string ) { const file = await getActiveWorkspaceConfigFilePath(configDirOverride); - await Deno.writeTextFile(file, workspaceName); + await writeFile(file, workspaceName, "utf-8"); } export async function add( @@ -202,7 +201,7 @@ export async function add( remote = new URL(remote).toString(); // add trailing slash in all cases! let token = await tryGetLoginInfo(opts); - if (!token && Deno.stdin.isTerminal && !Deno.stdin.isTerminal()) { + if (!token && !(process.stdin.isTTY ?? false)) { log.info("Not a TTY, can't login interactively. Pass the token in --token"); return; } @@ -257,10 +256,10 @@ export async function add( for (const workspace of workspaces) { log.info(`- ${workspace.id} (name: ${workspace.name})`); } - Deno.exit(1); + process.exit(1); } - await addWorkspace( + const added = await addWorkspace( { name: workspaceName, remote: remote, @@ -269,6 +268,9 @@ export async function add( }, opts ); + if (!added) { + return; + } await setActiveWorkspace(workspaceName, opts.configDir); log.info( @@ -278,13 +280,13 @@ export async function add( ); } -export async function addWorkspace(workspace: Workspace, opts: any) { +export async function addWorkspace(workspace: Workspace, opts: any): Promise { workspace.remote = new URL(workspace.remote).toString(); // add trailing slash in all cases! // Check for conflicts before adding const existingWorkspaces = await allWorkspaces(opts.configDir); const isInteractive = - Deno.stdin.isTerminal() && Deno.stdout.isTerminal() && !opts.force; + (process.stdin.isTTY ?? false) && (process.stdout.isTTY ?? false) && !opts.force; // Check 1: Workspace name already exists const nameConflict = existingWorkspaces.find( @@ -330,26 +332,39 @@ export async function addWorkspace(workspace: Workspace, opts: any) { if (!overwrite) { log.info(colors.yellow("Operation cancelled.")); - return; + return false; } } } } + // Check 2: Same (remote, workspaceId) already exists with a different name + const backendConflict = existingWorkspaces.find( + (w) => + w.remote === workspace.remote && + w.workspaceId === workspace.workspaceId && + w.name !== workspace.name + ); + if (backendConflict) { + if (opts.force) { + // Remove the conflicting workspace before adding the new one + await removeWorkspace(backendConflict.name, true, opts); + } else { + throw new Error( + `Backend constraint violation: (${workspace.remote}, ${workspace.workspaceId}) already exists as "${backendConflict.name}". Use --force to overwrite.` + ); + } + } + // Remove existing workspace with same name (if updating) await removeWorkspace(workspace.name, true, opts); // Add the new workspace const filePath = await getWorkspaceConfigFilePath(opts.configDir); - const file = await Deno.open(filePath, { - append: true, - write: true, - read: true, - create: true, - }); - await file.write(new TextEncoder().encode(JSON.stringify(workspace) + "\n")); - - file.close(); + const fh = await fsOpen(filePath, "a"); + await fh.write(JSON.stringify(workspace) + "\n"); + await fh.close(); + return true; } export async function removeWorkspace( @@ -373,12 +388,13 @@ export async function removeWorkspace( } const filePath = await getWorkspaceConfigFilePath(opts.configDir); - await Deno.writeTextFile( + await writeFile( filePath, orgWorkspaces .filter((x) => x.name !== name) .map((x) => JSON.stringify(x)) - .join("\n") + "\n" + .join("\n") + "\n", + "utf-8" ); if (!silent) { @@ -502,9 +518,9 @@ async function bind( } // Write back the updated config - const { yamlStringify } = await import("../../../deps.ts"); + const { stringify: yamlStringify } = await import("yaml"); try { - await Deno.writeTextFile("wmill.yaml", yamlStringify(config)); + await writeFile("wmill.yaml", yamlStringify(config), "utf-8"); } catch (error) { log.error(colors.red(`Failed to save configuration: ${(error as Error).message}`)); return; diff --git a/cli/src/core/auth.ts b/cli/src/core/auth.ts index 2831761141..0be7571320 100644 --- a/cli/src/core/auth.ts +++ b/cli/src/core/auth.ts @@ -1,5 +1,6 @@ -// deno-lint-ignore-file no-explicit-any -import { colors, log, setClient } from "../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "./log.ts"; +import { setClient } from "./client.ts"; import * as wmill from "../../gen/services.gen.ts"; import { GlobalUserInfo } from "../../gen/types.gen.ts"; diff --git a/cli/src/core/branch-profiles.ts b/cli/src/core/branch-profiles.ts index 006b7fed18..80c72b0705 100644 --- a/cli/src/core/branch-profiles.ts +++ b/cli/src/core/branch-profiles.ts @@ -1,4 +1,5 @@ -import { log } from "../../deps.ts"; +import * as log from "./log.ts"; +import { readFile, writeFile } from "node:fs/promises"; import { getStore } from "./store.ts"; export interface BranchProfileMapping { @@ -16,7 +17,7 @@ export async function getBranchProfilesPath(configDirOverride?: string): Promise export async function loadBranchProfiles(configDirOverride?: string): Promise { try { const path = await getBranchProfilesPath(configDirOverride); - const content = await Deno.readTextFile(path); + const content = await readFile(path, "utf-8"); return JSON.parse(content); } catch { // File doesn't exist or invalid JSON - return empty mapping @@ -29,7 +30,7 @@ export async function saveBranchProfiles( configDirOverride?: string ): Promise { const path = await getBranchProfilesPath(configDirOverride); - await Deno.writeTextFile(path, JSON.stringify(mapping, null, 2)); + await writeFile(path, JSON.stringify(mapping, null, 2), "utf-8"); } export function getBranchProfileKey( diff --git a/cli/src/core/client.ts b/cli/src/core/client.ts new file mode 100644 index 0000000000..4b0b98e30f --- /dev/null +++ b/cli/src/core/client.ts @@ -0,0 +1,15 @@ +import { OpenAPI } from "../../gen/index.ts"; + +export function setClient(token?: string, baseUrl?: string) { + if (baseUrl === undefined) { + baseUrl = process.env["BASE_INTERNAL_URL"] ?? + process.env["BASE_URL"] ?? + "http://localhost:8000"; + } + if (token === undefined) { + token = process.env["WM_TOKEN"] ?? "no_token"; + } + OpenAPI.WITH_CREDENTIALS = true; + OpenAPI.TOKEN = token; + OpenAPI.BASE = baseUrl + "/api"; +} diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index dd38adbd0d..22acd536f0 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -1,4 +1,7 @@ -import { log, yamlParseFile, Confirm, yamlStringify } from "../../deps.ts"; +import * as log from "./log.ts"; +import { yamlParseFile } from "../utils/yaml.ts"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { stringify as yamlStringify } from "yaml"; import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, @@ -6,6 +9,7 @@ import { } from "../utils/git.ts"; import { join, dirname, resolve, relative } from "node:path"; import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; import { execSync } from "node:child_process"; import { setNonDottedPaths } from "../utils/resource_folders.ts"; @@ -97,6 +101,7 @@ export interface SyncOptions { }; promotion?: string; lint?: boolean; + locksRequired?: boolean; } export interface Codebase { @@ -132,7 +137,7 @@ function getGitRepoRoot(): string | null { export const GLOBAL_CONFIG_OPT = { noCdToRoot: false }; function findWmillYaml(): string | null { - const startDir = resolve(Deno.cwd()); + const startDir = resolve(process.cwd()); const isInGitRepo = isGitRepository(); const gitRoot = isInGitRepo ? getGitRepoRoot() : null; @@ -173,7 +178,7 @@ function findWmillYaml(): string | null { log.warn(`⚠️ wmill.yaml found in parent directory: ${relativePath}`); // Change working directory to where wmill.yaml was found - Deno.chdir(configDir); + process.chdir(configDir); log.info(`📁 Changed working directory to: ${configDir}`); } @@ -191,7 +196,7 @@ export async function readConfigFile(): Promise { if (!wmillYamlPath) { log.warn( - "No wmill.yaml found. Use 'wmill init' to bootstrap it. Using 'bun' as default typescript runtime." + "No wmill.yaml found. Use 'wmill init' to bootstrap it." ); return {}; } @@ -250,7 +255,7 @@ export async function readConfigFile(): Promise { // Perform single atomic write if any migrations are needed if (needsConfigWrite) { try { - await Deno.writeTextFile(wmillYamlPath, yamlStringify(conf)); + await writeFile(wmillYamlPath, yamlStringify(conf), "utf-8"); // Log all migration messages after successful write migrationMessages.forEach((msg) => { if (msg.startsWith("⚠️")) { @@ -417,7 +422,7 @@ export async function validateBranchConfiguration( // Current branch must be defined in gitBranches config if (currentBranch && !gitBranches[currentBranch]) { // In interactive mode, offer to create the branch - if (Deno.stdin.isTerminal()) { + if (!!process.stdin.isTTY) { const availableBranches = Object.keys(gitBranches).join(", "); log.info( `Current Git branch '${currentBranch}' is not defined in the gitBranches configuration.\n` + @@ -457,7 +462,7 @@ export async function validateBranchConfiguration( } currentConfig.gitBranches[currentBranch] = { overrides: {} }; - await Deno.writeTextFile("wmill.yaml", yamlStringify(currentConfig)); + await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8"); log.info( `✅ Created empty branch configuration for '${currentBranch}'` diff --git a/cli/src/core/context.ts b/cli/src/core/context.ts index 714b613cdd..86307646bf 100644 --- a/cli/src/core/context.ts +++ b/cli/src/core/context.ts @@ -1,5 +1,8 @@ -// deno-lint-ignore-file no-explicit-any -import { colors, log, Select, Confirm, Input } from "../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "./log.ts"; +import { Select } from "@cliffy/prompt/select"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { Input } from "@cliffy/prompt/input"; import { loginInteractive } from "./login.ts"; import { GlobalOptions } from "../types.ts"; @@ -56,7 +59,7 @@ async function selectFromMultipleProfiles( } // No last used or it no longer exists - prompt for selection - if (!Deno.stdin.isTerminal() || !Deno.stdout.isTerminal()) { + if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) { const selectedProfile = profiles[0]; log.info( colors.yellow( @@ -129,7 +132,7 @@ async function createWorkspaceProfileInteractively( ); } - if (!Deno.stdin.isTerminal() || !Deno.stdout.isTerminal()) { + if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) { log.info( "Not a TTY, cannot create profile interactively. Use 'wmill workspace add' first." ); @@ -382,7 +385,7 @@ export async function resolveWorkspace( normalizedBaseUrl = new URL(opts.baseUrl).toString(); // add trailing slash if not present } catch (error) { log.info(colors.red(`Invalid base URL: ${opts.baseUrl}`)); - return Deno.exit(-1); + return process.exit(-1); } // Try to find existing workspace profile by name, then by workspaceId + remote @@ -423,7 +426,7 @@ export async function resolveWorkspace( `Base URL mismatch: --base-url is ${normalizedBaseUrl} but workspace profile "${opts.workspace}" uses ${existingWorkspace.remote}` ) ); - return Deno.exit(-1); + return process.exit(-1); } // Use the existing workspace profile (preserves workspace name) return { @@ -446,7 +449,7 @@ export async function resolveWorkspace( "If you specify a base URL with --base-url, you must also specify a workspace (--workspace) and token (--token)." ) ); - return Deno.exit(-1); + return process.exit(-1); } } @@ -456,11 +459,12 @@ export async function resolveWorkspace( // forked workspace, that we detect through the branch name (only when not using branchOverride) const res = await tryResolveWorkspace(opts); if (!res.isError) { + const workspace = (res as { isError: false; value: Workspace }).value; if (branchOverride || !branch || !branch.startsWith(WM_FORK_PREFIX)) { - return res.value; + return workspace; } else { log.info( - `Found an active workspace \`${res.value.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`` + `Found an active workspace \`${workspace.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`` ); } } @@ -479,20 +483,48 @@ export async function resolveWorkspace( `Failed to resolve workspace profile for workspace fork. This most likely means that the original branch \`${originalBranch}\` where \`${branch}\` is originally forked from, is not setup in the wmill.yaml. You need to update the \`gitBranches\` section for \`${originalBranch}\` to include workspaceId and baseUrl.` ) ); - return Deno.exit(-1); + return process.exit(-1); } } - // Fall back to active workspace (lowest priority) + // Fall back to active workspace const activeWorkspace = await getActiveWorkspace(opts); if (activeWorkspace) { (opts as any).__secret_workspace = activeWorkspace; return activeWorkspace; } + // Last resort: auto-configure from Windmill environment variables + // (set by the worker for bash/script execution) + const envWorkspace = process.env["WM_WORKSPACE"]; + const envToken = process.env["WM_TOKEN"]; + const envBaseUrl = + process.env["BASE_INTERNAL_URL"] ?? process.env["BASE_URL"]; + + if (envWorkspace && envToken && envBaseUrl) { + let normalizedBaseUrl: string; + try { + normalizedBaseUrl = new URL(envBaseUrl).toString(); + } catch { + log.info(colors.red(`Invalid BASE_INTERNAL_URL: ${envBaseUrl}`)); + return process.exit(-1); + } + log.debug( + `Using workspace from environment variables: ${envWorkspace} on ${normalizedBaseUrl}` + ); + const ws: Workspace = { + name: envWorkspace, + workspaceId: envWorkspace, + remote: normalizedBaseUrl, + token: envToken, + }; + (opts as any).__secret_workspace = ws; + return ws; + } + // If everything failed, show error log.info(colors.red.bold("No workspace given and no default set.")); - return Deno.exit(-1); + return process.exit(-1); } export async function fetchVersion(baseUrl: string): Promise { @@ -529,7 +561,8 @@ export async function tryResolveVersion( const workspaceRes = await tryResolveWorkspace(opts); if (workspaceRes.isError) return undefined; - const version = await fetchVersion(workspaceRes.value.remote); + const workspace = (workspaceRes as { isError: false; value: Workspace }).value; + const version = await fetchVersion(workspace.remote); try { return Number.parseInt( diff --git a/cli/src/core/log.ts b/cli/src/core/log.ts new file mode 100644 index 0000000000..d7bed9a0d4 --- /dev/null +++ b/cli/src/core/log.ts @@ -0,0 +1,24 @@ +let logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR" = "INFO"; + +const levels = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }; + +export function setup(level: "DEBUG" | "INFO" | "WARN" | "ERROR") { + logLevel = level; +} + +export function debug(msg: unknown) { + if (levels[logLevel] <= levels.DEBUG) + console.log(`\x1b[90m${String(msg)}\x1b[39m`); +} + +export function info(msg: unknown) { + console.log(`\x1b[34m${String(msg)}\x1b[39m`); +} + +export function warn(msg: unknown) { + console.log(`\x1b[33m${String(msg)}\x1b[39m`); +} + +export function error(msg: unknown) { + console.log(`\x1b[31m${String(msg)}\x1b[39m`); +} diff --git a/cli/src/core/login.ts b/cli/src/core/login.ts index 6da90b8042..516b3fb4c8 100644 --- a/cli/src/core/login.ts +++ b/cli/src/core/login.ts @@ -1,10 +1,15 @@ import { GlobalOptions } from "../types.ts"; -import { colors, getPort, log, open, Secret, Select } from "../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as getPort from "get-port"; +import * as log from "./log.ts"; +import * as open from "open"; +import { Secret } from "@cliffy/prompt/secret"; +import { Select } from "@cliffy/prompt/select"; import * as http from "node:http"; export async function loginInteractive(remote: string) { let token: string | undefined; - if (Deno.stdin.isTerminal && !Deno.stdin.isTerminal()) { + if (!process.stdin.isTTY) { log.info("Not a TTY, can't login interactively."); return undefined; } @@ -30,7 +35,6 @@ export async function loginInteractive(remote: string) { return token; } -// deno-lint-ignore require-await export async function tryGetLoginInfo( opts: GlobalOptions ): Promise { @@ -45,8 +49,8 @@ export async function browserLogin( baseUrl: string ): Promise { const env = - Deno.env.get("TOKEN_PORT") != undefined - ? parseInt(Deno.env.get("TOKEN_PORT")!) + process.env["TOKEN_PORT"] != undefined + ? parseInt(process.env["TOKEN_PORT"]!) : undefined; const port = await getPort.default({ port: env }); @@ -55,32 +59,6 @@ export async function browserLogin( return undefined; } - // const server = Deno.listen({ transport: "tcp", port }); - // const url = `${baseUrl}user/cli?port=${port}`; - // log.info(`Login by going to ${url}`); - // try { - // await open.openApp(open.apps.browser, { arguments: [url] }); - - // log.info("Opened browser for you"); - // } catch { - // console.error(`Failed to open browser, please navigate to ${url}`); - // } - // const firstConnection = await server.accept(); - // const httpFirstConnection = Deno.serveHttp(firstConnection); - // const firstRequest = (await httpFirstConnection.nextRequest())!; - // const params = new URL(firstRequest.request.url!).searchParams; - // const token = params.get("token"); - // // const _workspace = params.get("workspace"); - // await firstRequest?.respondWith( - // Response.redirect(baseUrl + "user/cli-success", 302) - // ); - - // setTimeout(() => { - // httpFirstConnection.close(); - // server.close(); - // }, 10); - // return token ?? undefined; - return new Promise((resolve) => { const server = http.createServer((req, res) => { const params = new URL(req.url!, `http://${req.headers.host}`) diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index ed83f60658..3ffddc4837 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -1,5 +1,10 @@ import process from "node:process"; -import { colors, Confirm, log, yamlParseFile, yamlStringify } from "../../deps.ts"; +import { writeFile } from "node:fs/promises"; +import { colors } from "@cliffy/ansi/colors"; +import { Confirm } from "@cliffy/prompt/confirm"; +import * as log from "./log.ts"; +import { yamlParseFile } from "../utils/yaml.ts"; +import { stringify as yamlStringify } from "yaml"; import * as wmill from "../../gen/services.gen.ts"; import { AIConfig, Config, GlobalSetting } from "../../gen/types.gen.ts"; import { compareInstanceObjects, InstanceSyncOptions } from "../commands/instance/instance.ts"; @@ -493,9 +498,10 @@ export async function pullInstanceSettings( remoteSettings, "encode" ); - await Deno.writeTextFile( + await writeFile( instanceSettingsPath, - yamlStringify(processedSettings) + yamlStringify(processedSettings), + "utf-8" ); log.info(colors.green(`Settings written to ${instanceSettingsPath}`)); @@ -602,9 +608,10 @@ export async function pullInstanceConfigs( } else { log.info("Pulling configs from instance"); - await Deno.writeTextFile( + await writeFile( instanceConfigsPath, - yamlStringify(remoteConfigs as any) + yamlStringify(remoteConfigs as any), + "utf-8" ); log.info(colors.green(`Configs written to ${instanceConfigsPath}`)); diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index e0484fb53a..aa3f6652fc 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -1,4 +1,4 @@ -import { minimatch } from "../../deps.ts"; +import { minimatch } from "minimatch"; import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts"; import { isFileResource } from "../utils/utils.ts"; import { SyncOptions } from "./conf.ts"; diff --git a/cli/src/core/store.ts b/cli/src/core/store.ts index 5843b6ad02..cc58ca023f 100644 --- a/cli/src/core/store.ts +++ b/cli/src/core/store.ts @@ -1,4 +1,4 @@ -import { ensureDir } from "../../deps.ts"; +import { mkdir } from "node:fs/promises"; import { getConfigDirPath } from "../../windmill-utils-internal/src/config/config.ts"; function hash_string(str: string): number { @@ -17,6 +17,6 @@ function hash_string(str: string): number { export async function getStore(baseUrl: string, configDirOverride?: string): Promise { const baseHash = Math.abs(hash_string(baseUrl)).toString(16); const baseStore = (await getConfigDirPath(configDirOverride)) + baseHash + "/"; - await ensureDir(baseStore); + await mkdir(baseStore, { recursive: true }); return baseStore; } \ No newline at end of file diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 19f455fadb..88f8cbb842 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -4557,9 +4557,16 @@ Current version: 1.624.0 app related commands +**Options:** +- \`--json\` - Output as JSON (for piping to jq) + **Subcommands:** -- \`app push \` - push a local app +- \`app list\` - list all apps + - \`--json\` - Output as JSON (for piping to jq) +- \`app get \` - get an app's details + - \`--json\` - Output as JSON (for piping to jq) +- \`app push \` - push a local app - \`app dev [app_folder:string]\` - Start a development server for building apps with live reload and hot module replacement - \`--port \` - Port to run the dev server on (will find next available port if occupied) - \`--host \` - Host to bind the dev server to @@ -4596,10 +4603,16 @@ Launch a dev server that will spawn a webserver with HMR flow related commands **Options:** -- \`--show-archived\` - Enable archived scripts in output +- \`--show-archived\` - Enable archived flows in output +- \`--json\` - Output as JSON (for piping to jq) **Subcommands:** +- \`flow list\` - list all flows + - \`--show-archived\` - Enable archived flows in output + - \`--json\` - Output as JSON (for piping to jq) +- \`flow get \` - get a flow's details + - \`--json\` - Output as JSON (for piping to jq) - \`flow push \` - push a local flow spec. This overrides any remote versions. - \`flow run \` - run a flow by path. - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. @@ -4611,16 +4624,27 @@ flow related commands - \`--yes\` - Skip confirmation prompt - \`-i --includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string) - \`-e --excludes \` - Comma separated patterns to specify which file to NOT take into account. -- \`flow bootstrap \` - create a new empty flow - - \`--summary \` - script summary - - \`--description \` - script description +- \`flow new \` - create a new empty flow + - \`--summary \` - flow summary + - \`--description \` - flow description +- \`flow bootstrap \` - create a new empty flow (alias for new) + - \`--summary \` - flow summary + - \`--description \` - flow description ### folder folder related commands +**Options:** +- \`--json\` - Output as JSON (for piping to jq) + **Subcommands:** +- \`folder list\` - list all folders + - \`--json\` - Output as JSON (for piping to jq) +- \`folder get \` - get a folder's details + - \`--json\` - Output as JSON (for piping to jq) +- \`folder new \` - create a new folder locally - \`folder push \` - push a local folder spec. This overrides any remote versions. ### gitsync-settings @@ -4731,18 +4755,33 @@ List all queues with their metrics resource related commands +**Options:** +- \`--json\` - Output as JSON (for piping to jq) + **Subcommands:** +- \`resource list\` - list all resources + - \`--json\` - Output as JSON (for piping to jq) +- \`resource get \` - get a resource's details + - \`--json\` - Output as JSON (for piping to jq) +- \`resource new \` - create a new resource locally - \`resource push \` - push a local resource spec. This overrides any remote versions. ### resource-type resource type related commands +**Options:** +- \`--json\` - Output as JSON (for piping to jq) + **Subcommands:** - \`resource-type list\` - list all resource types - \`--schema\` - Show schema in the output + - \`--json\` - Output as JSON (for piping to jq) +- \`resource-type get \` - get a resource type's details + - \`--json\` - Output as JSON (for piping to jq) +- \`resource-type new \` - create a new resource type locally - \`resource-type push \` - push a local resource spec. This overrides any remote versions. - \`resource-type generate-namespace\` - Create a TypeScript definition file with the RT namespace generated from the resource types @@ -4750,8 +4789,16 @@ resource type related commands schedule related commands +**Options:** +- \`--json\` - Output as JSON (for piping to jq) + **Subcommands:** +- \`schedule list\` - list all schedules + - \`--json\` - Output as JSON (for piping to jq) +- \`schedule get \` - get a schedule's details + - \`--json\` - Output as JSON (for piping to jq) +- \`schedule new \` - create a new schedule locally - \`schedule push \` - push a local schedule spec. This overrides any remote versions. ### script @@ -4760,21 +4807,30 @@ script related commands **Options:** - \`--show-archived\` - Enable archived scripts in output +- \`--json\` - Output as JSON (for piping to jq) **Subcommands:** -- \`script push \` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh -- \`script show \` - show a scripts content +- \`script list\` - list all scripts + - \`--show-archived\` - Enable archived scripts in output + - \`--json\` - Output as JSON (for piping to jq) +- \`script get \` - get a script's details + - \`--json\` - Output as JSON (for piping to jq) +- \`script show \` - show a script's content (alias for get) +- \`script push \` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh) - \`script run \` - run a script by path - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. - \`script preview \` - preview a local script without deploying it. Supports both regular and codebase scripts. - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other than the final output. Useful for scripting. -- \`script bootstrap \` - create a new script +- \`script new \` - create a new script - \`--summary \` - script summary - \`--description \` - script description -- \`script generate-metadata [script:file]\` - re-generate the metadata file updating the lock and the script schema (for flows, use \`wmill flow generate-locks\` +- \`script bootstrap \` - create a new script (alias for new) + - \`--summary \` - script summary + - \`--description \` - script description +- \`script generate-metadata [script:file]\` - re-generate the metadata file updating the lock and the script schema (for flows, use \`wmill flow generate-locks\`) - \`--yes\` - Skip confirmation prompt - \`--dry-run\` - Perform a dry run without making changes - \`--lock-only\` - re-generate only the lock @@ -4852,8 +4908,18 @@ sync local with a remote workspaces or the opposite (push or pull) trigger related commands +**Options:** +- \`--json\` - Output as JSON (for piping to jq) + **Subcommands:** +- \`trigger list\` - list all triggers + - \`--json\` - Output as JSON (for piping to jq) +- \`trigger get \` - get a trigger's details + - \`--json\` - Output as JSON (for piping to jq) + - \`--kind \` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email) +- \`trigger new \` - create a new trigger locally + - \`--kind \` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email) - \`trigger push \` - push a local trigger spec. This overrides any remote versions. ### user @@ -4875,8 +4941,16 @@ user related commands variable related commands +**Options:** +- \`--json\` - Output as JSON (for piping to jq) + **Subcommands:** +- \`variable list\` - list all variables + - \`--json\` - Output as JSON (for piping to jq) +- \`variable get \` - get a variable's details + - \`--json\` - Output as JSON (for piping to jq) +- \`variable new \` - create a new variable locally - \`variable push \` - Push a local variable spec. This overrides any remote versions. - \`--plain-secrets\` - Push secrets as plain text - \`variable add \` - Create a new variable on the remote. This will update the variable if it already exists. diff --git a/cli/src/main.ts b/cli/src/main.ts index 8ed52add53..64ceb67b2d 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -1,16 +1,9 @@ -import { - Command, - CompletionsCommand, - UpgradeCommand, - esMain, - log, -} from "../deps.ts"; +import { Command } from "@cliffy/command"; +import { generateShellCompletions } from "@cliffy/command/completions"; +import { UpgradeCommand } from "@cliffy/command/upgrade"; +import * as log from "./core/log.ts"; -// Node.js-specific imports for symlink resolution in isMain() -// These are only used in Node.js, not Deno -// dnt-shim-ignore import { realpathSync } from "node:fs"; -// dnt-shim-ignore import { fileURLToPath } from "node:url"; import flow from "./commands/flow/flow.ts"; import app from "./commands/app/app.ts"; @@ -35,7 +28,7 @@ import lint from "./commands/lint/lint.ts"; import dev from "./commands/dev/dev.ts"; import { GlobalOptions } from "./types.ts"; import { OpenAPI } from "../gen/index.ts"; -import { getHeaders, getIsWin } from "./utils/utils.ts"; +import { getHeaders } from "./utils/utils.ts"; import { setShowDiffs } from "./core/conf.ts"; import { NpmProvider } from "./utils/upgrade.ts"; import { pull as hubPull } from "./commands/hub/hub.ts"; @@ -72,14 +65,7 @@ export { workspaceAdd, }; -// addEventListener("error", (event) => { -// if (event.error) { -// console.error("Error details of: " + event.error.message); -// console.error(JSON.stringify(event.error, null, 4)); -// } -// }); - -export const VERSION = "1.638.4"; +export const VERSION = "1.642.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; @@ -165,7 +151,9 @@ const command = new Command() const backendVersion = await fetchVersion(workspace.remote); console.log("Backend Version: " + backendVersion); } catch (e) { - console.warn("Cannot fetch backend version: " + e); + console.warn( + `Cannot fetch backend version from ${workspace.remote} (workspace: ${workspace.name}): ${e}` + ); } } else { console.warn( @@ -184,42 +172,42 @@ const command = new Command() ); }) ) - .command("completions", new CompletionsCommand()); + .command( + "completions", + new Command() + .description("Generate shell completions.") + .command("bash", new Command().description("Generate bash completions.").action(() => { + process.stdout.write(generateShellCompletions(command, "bash") + "\n"); + })) + .command("zsh", new Command().description("Generate zsh completions.").action(() => { + process.stdout.write(generateShellCompletions(command, "zsh") + "\n"); + })) + .command("fish", new Command().description("Generate fish completions.").action(() => { + process.stdout.write(generateShellCompletions(command, "fish") + "\n"); + })) + ); async function main() { try { - if (Deno.args.length === 0) { + const args = process.argv.slice(2); + if (args.length === 0) { command.showHelp(); } const LOG_LEVEL = - Deno.args.includes("--verbose") || Deno.args.includes("--debug") + args.includes("--verbose") || args.includes("--debug") ? "DEBUG" : "INFO"; - // const NO_COLORS = Deno.args.includes("--no-colors"); - setShowDiffs(Deno.args.includes("--show-diffs")); + // const NO_COLORS = args.includes("--no-colors"); + setShowDiffs(args.includes("--show-diffs")); - const isWin = await getIsWin(); - log.setup({ - handlers: { - console: new log.ConsoleHandler(LOG_LEVEL, { - formatter: ({ msg }) => msg, - useColors: isWin ? false : true, - }), - }, - loggers: { - default: { - level: LOG_LEVEL, - handlers: ["console"], - }, - }, - }); + log.setup(LOG_LEVEL); log.debug("Debug logging enabled. CLI build against " + VERSION); const extraHeaders = getHeaders(); if (extraHeaders) { OpenAPI.HEADERS = extraHeaders; } - await command.parse(Deno.args); + await command.parse(args); } catch (e) { if (e && typeof e === "object" && "name" in e && e.name === "ApiError") { console.log( @@ -231,45 +219,25 @@ async function main() { } function isMain() { - // dnt-shim-ignore - const { Deno } = globalThis as any; + // Handle symlinks properly: resolve symlinks when comparing process.argv[1] + // with import.meta.url, so `wmill` symlink matches the real file path. + try { + const scriptPath = process.argv[1]; + if (!scriptPath) return false; - const isDeno = Deno != undefined; + const realScriptPath = realpathSync(scriptPath); + const modulePath = fileURLToPath(import.meta.url); - if (isDeno) { - const isMain = import.meta.main; - if (isMain) { - if (!Deno.args.includes("completions")) { - if (Deno.env.get("SKIP_DENO_DEPRECATION_WARNING") !== "true") { - log.warn( - "Using the deno runtime for the Windmill CLI is deprecated, you can now use node: deno uninstall wmill && npm install -g windmill-cli. To skip this warning set SKIP_DENO_DEPRECATION_WARNING=true" - ); - } - } - } - return isMain; - } else { - // For Node.js, we need to handle symlinks properly. - // The dnt polyfill doesn't resolve symlinks when comparing process.argv[1] - // with import.meta.url, so `wmill` symlink doesn't match the real file path. - // We resolve symlinks manually to get accurate comparison. - try { - const scriptPath = process.argv[1]; - if (!scriptPath) return false; - - const realScriptPath = realpathSync(scriptPath); - const modulePath = fileURLToPath(import.meta.url); - - return realScriptPath === modulePath; - } catch { - // Fallback to esMain if something fails - //@ts-ignore - return esMain.default(import.meta); - } + return realScriptPath === modulePath; + } catch { + return false; } } if (isMain()) { - main(); + main().then(() => { + // Destroy stdin so interactive prompts (Cliffy) don't keep the event loop alive + process.stdin.destroy(); + }); } export default command; diff --git a/cli/src/types.ts b/cli/src/types.ts index 22cc1a2b33..382f7f82af 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -1,14 +1,11 @@ -// deno-lint-ignore-file no-explicit-any - -import { - colors, - Diff, - log, - path, - SEP, - yamlParseContent, - yamlStringify, -} from "../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as Diff from "diff"; +import * as log from "./core/log.ts"; +import * as path from "node:path"; +import { sep as SEP } from "node:path"; +import { stringify as yamlStringify } from "yaml"; +import { yamlParseContent } from "./utils/yaml.ts"; +import { readFileSync } from "node:fs"; import { pushApp } from "./commands/app/app.ts"; import { pushFolder } from "./commands/folder/folder.ts"; import { pushFlow } from "./commands/flow/flow.ts"; @@ -228,9 +225,9 @@ export function parseFromPath(p: string, content: string): any { } export function parseFromFile(p: string): any { if (p.endsWith(".json")) { - return JSON.parse(Deno.readTextFileSync(p)); + return JSON.parse(readFileSync(p, "utf-8")); } else if (p.endsWith(".yaml") || p.endsWith(".yml")) { - return yamlParseContent(p, Deno.readTextFileSync(p)); + return yamlParseContent(p, readFileSync(p, "utf-8")); } else { throw new Error("Could not read file " + p); } diff --git a/cli/src/utils/codebase.ts b/cli/src/utils/codebase.ts index 84424f87c3..e665d060b2 100644 --- a/cli/src/utils/codebase.ts +++ b/cli/src/utils/codebase.ts @@ -1,5 +1,5 @@ import { Codebase, SyncOptions } from "../core/conf.ts"; -import { log } from "../../deps.ts"; +import * as log from "../core/log.ts"; import { digestDir } from "./utils.ts"; export type SyncCodebase = Codebase & { diff --git a/cli/src/utils/git.ts b/cli/src/utils/git.ts index a67d343408..05e37240dd 100644 --- a/cli/src/utils/git.ts +++ b/cli/src/utils/git.ts @@ -1,4 +1,4 @@ -import { log } from "../../deps.ts"; +import * as log from "../core/log.ts"; import { execSync } from "node:child_process"; import { WM_FORK_PREFIX } from "../core/constants.ts"; diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 25ce5fdaa5..0e10798725 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -1,6 +1,12 @@ -// deno-lint-ignore-file no-explicit-any import { GlobalOptions } from "../types.ts"; -import { SEP, colors, log, yamlParseFile, yamlStringify } from "../../deps.ts"; +import { sep as SEP } from "node:path"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../core/log.ts"; +import { stringify as yamlStringify } from "yaml"; +import { yamlParseFile } from "./yaml.ts"; +import { readFile, writeFile, stat, rm, readdir } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { ScriptMetadata, defaultScriptMetadata, @@ -18,6 +24,25 @@ import { SyncCodebase } from "./codebase.ts"; import { argSigToJsonSchemaType } from "../../windmill-utils-internal/src/parse/parse-schema.ts"; import { getIsWin } from "./utils.ts"; +const _require = createRequire(import.meta.url); +const _parserCache = new Map>(); + +function loadParser(pkgName: string): Promise { + let p = _parserCache.get(pkgName); + if (!p) { + p = (async () => { + const mod = await import(pkgName); + const wasmPath = _require.resolve( + `${pkgName}/windmill_parser_wasm_bg.wasm` + ); + await mod.default(readFileSync(wasmPath)); + return mod; + })(); + _parserCache.set(pkgName, p); + } + return p; +} + export class LockfileGenerationError extends Error { constructor(message: string) { super(message); @@ -31,11 +56,12 @@ export async function getRawWorkspaceDependencies(): Promise = {}; try { - for await (const entry of Deno.readDir("dependencies")) { - if (entry.isDirectory) continue; + const entries = await readdir("dependencies", { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) continue; const filePath = `dependencies/${entry.name}`; - const content = await Deno.readTextFile(filePath); + const content = await readFile(filePath, "utf-8"); // Find matching language for (const lang of workspaceDependenciesLanguages) { @@ -120,7 +146,7 @@ export async function filterWorkspaceDependenciesForScripts( if (content.startsWith("!inline ")) { const filePath = folder + sep + content.replace("!inline ", ""); try { - content = await Deno.readTextFile(filePath); + content = await readFile(filePath, "utf-8"); } catch { continue; } @@ -173,8 +199,8 @@ export async function generateScriptMetadataInternal( ); // read script content - const scriptContent = await Deno.readTextFile(scriptPath); - const metadataContent = await Deno.readTextFile(metadataWithType.path); + const scriptContent = await readFile(scriptPath, "utf-8"); + const metadataContent = await readFile(metadataWithType.path, "utf-8"); const filteredRawWorkspaceDependencies = filterWorkspaceDependencies( rawWorkspaceDependencies, @@ -250,7 +276,7 @@ export async function generateScriptMetadataInternal( ); await updateMetadataGlobalLock(remotePath, hash); if (!justUpdateMetadataLock) { - await Deno.writeTextFile(metaPath, newMetadataContent); + await writeFile(metaPath, newMetadataContent, "utf-8"); } return `${remotePath} (${language})`; } @@ -490,12 +516,12 @@ async function updateScriptLock( const lockPath = remotePath + ".script.lock"; if (lock != "") { - await Deno.writeTextFile(lockPath, lock); + await writeFile(lockPath, lock, "utf-8"); metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/"); } else { try { - if (await Deno.stat(lockPath)) { - await Deno.remove(lockPath); + if (await stat(lockPath)) { + await rm(lockPath); } } catch (e) { log.info(colors.yellow(`Error removing lock file ${lockPath}: ${e}`)); @@ -519,139 +545,98 @@ export async function inferSchema( }> { let inferedSchema: any; if (language === "python3") { - const { parse_python } = await import( - "../../wasm/py/windmill_parser_wasm.js" - ); + const { parse_python } = await loadParser("windmill-parser-wasm-py"); inferedSchema = JSON.parse(parse_python(content)); } else if (language === "nativets") { - const { parse_deno } = await import( - "../../wasm/ts/windmill_parser_wasm.js" - ); + const { parse_deno } = await loadParser("windmill-parser-wasm-ts"); inferedSchema = JSON.parse(parse_deno(content)); } else if (language === "bun") { - const { parse_deno } = await import( - "../../wasm/ts/windmill_parser_wasm.js" - ); + const { parse_deno } = await loadParser("windmill-parser-wasm-ts"); inferedSchema = JSON.parse(parse_deno(content)); } else if (language === "deno") { - const { parse_deno } = await import( - "../../wasm/ts/windmill_parser_wasm.js" - ); + const { parse_deno } = await loadParser("windmill-parser-wasm-ts"); inferedSchema = JSON.parse(parse_deno(content)); } else if (language === "go") { - const { parse_go } = await import("../../wasm/go/windmill_parser_wasm.js"); + const { parse_go } = await loadParser("windmill-parser-wasm-go"); inferedSchema = JSON.parse(parse_go(content)); } else if (language === "mysql") { - const { parse_mysql } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); - + const { parse_mysql } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_mysql(content)); inferedSchema.args = [ { name: "database", typ: { resource: "mysql" } }, ...inferedSchema.args, ]; } else if (language === "bigquery") { - const { parse_bigquery } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); + const { parse_bigquery } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_bigquery(content)); inferedSchema.args = [ { name: "database", typ: { resource: "bigquery" } }, ...inferedSchema.args, ]; } else if (language === "oracledb") { - const { parse_oracledb } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); + const { parse_oracledb } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_oracledb(content)); inferedSchema.args = [ { name: "database", typ: { resource: "oracledb" } }, ...inferedSchema.args, ]; } else if (language === "snowflake") { - const { parse_snowflake } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); + const { parse_snowflake } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_snowflake(content)); inferedSchema.args = [ { name: "database", typ: { resource: "snowflake" } }, ...inferedSchema.args, ]; } else if (language === "mssql") { - const { parse_mssql } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); + const { parse_mssql } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_mssql(content)); inferedSchema.args = [ { name: "database", typ: { resource: "ms_sql_server" } }, ...inferedSchema.args, ]; } else if (language === "postgresql") { - const { parse_sql } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); + const { parse_sql } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_sql(content)); inferedSchema.args = [ { name: "database", typ: { resource: "postgresql" } }, ...inferedSchema.args, ]; } else if (language === "duckdb") { - const { parse_duckdb } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); + const { parse_duckdb } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_duckdb(content)); } else if (language === "graphql") { - const { parse_graphql } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); + const { parse_graphql } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_graphql(content)); inferedSchema.args = [ { name: "api", typ: { resource: "graphql" } }, ...inferedSchema.args, ]; } else if (language === "bash") { - const { parse_bash } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); + const { parse_bash } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_bash(content)); } else if (language === "powershell") { - const { parse_powershell } = await import( - "../../wasm/regex/windmill_parser_wasm.js" - ); + const { parse_powershell } = await loadParser("windmill-parser-wasm-regex"); inferedSchema = JSON.parse(parse_powershell(content)); } else if (language === "php") { - const { parse_php } = await import( - "../../wasm/php/windmill_parser_wasm.js" - ); + const { parse_php } = await loadParser("windmill-parser-wasm-php"); inferedSchema = JSON.parse(parse_php(content)); } else if (language === "rust") { - const { parse_rust } = await import( - "../../wasm/rust/windmill_parser_wasm.js" - ); + const { parse_rust } = await loadParser("windmill-parser-wasm-rust"); inferedSchema = JSON.parse(parse_rust(content)); } else if (language === "csharp") { - const { parse_csharp } = await import( - "../../wasm/csharp/windmill_parser_wasm.js" - ); + const { parse_csharp } = await loadParser("windmill-parser-wasm-csharp"); inferedSchema = JSON.parse(parse_csharp(content)); } else if (language === "nu") { - const { parse_nu } = await import("../../wasm/nu/windmill_parser_wasm.js"); + const { parse_nu } = await loadParser("windmill-parser-wasm-nu"); inferedSchema = JSON.parse(parse_nu(content)); } else if (language === "ansible") { - const { parse_ansible } = await import( - "../../wasm/yaml/windmill_parser_wasm.js" - ); + const { parse_ansible } = await loadParser("windmill-parser-wasm-yaml"); inferedSchema = JSON.parse(parse_ansible(content)); } else if (language === "java") { - const { parse_java } = await import( - "../../wasm/java/windmill_parser_wasm.js" - ); + const { parse_java } = await loadParser("windmill-parser-wasm-java"); inferedSchema = JSON.parse(parse_java(content)); } else if (language === "ruby") { - const { parse_ruby } = await import( - "../../wasm/ruby/windmill_parser_wasm.js" - ); + const { parse_ruby } = await loadParser("windmill-parser-wasm-ruby"); inferedSchema = JSON.parse(parse_ruby(content)); // for related places search: ADD_NEW_LANG } else { @@ -751,16 +736,16 @@ export async function parseMetadataFile( ): Promise<{ isJson: boolean; payload: any; path: string }> { let metadataFilePath = scriptPath + ".script.json"; try { - await Deno.stat(metadataFilePath); + await stat(metadataFilePath); return { path: metadataFilePath, - payload: JSON.parse(await Deno.readTextFile(metadataFilePath)), + payload: JSON.parse(await readFile(metadataFilePath, "utf-8")), isJson: true, }; } catch { try { metadataFilePath = scriptPath + ".script.yaml"; - await Deno.stat(metadataFilePath); + await stat(metadataFilePath); const payload: any = await yamlParseFile(metadataFilePath); replaceLock(payload); @@ -785,12 +770,8 @@ export async function parseMetadataFile( yamlOptions ); - await Deno.writeTextFile(metadataFilePath, scriptInitialMetadataYaml, { - createNew: true, - }); - await Deno.writeTextFile(lockPath, "", { - createNew: true, - }); + await writeFile(metadataFilePath, scriptInitialMetadataYaml, { flag: "wx", encoding: "utf-8" }); + await writeFile(lockPath, "", { flag: "wx", encoding: "utf-8" }); if (generateMetadataIfMissing) { log.info( @@ -857,7 +838,7 @@ export async function readLockfile(): Promise { } } catch { const lock = { locks: {}, version: "v2" as const }; - await Deno.writeTextFile(WMILL_LOCKFILE, yamlStringify(lock, yamlOptions)); + await writeFile(WMILL_LOCKFILE, yamlStringify(lock, yamlOptions), "utf-8"); log.info(colors.green("wmill-lock.yaml created")); return lock; @@ -925,9 +906,10 @@ export async function clearGlobalLock(path: string): Promise { } }); } - await Deno.writeTextFile( + await writeFile( WMILL_LOCKFILE, - yamlStringify(conf as Record, yamlOptions) + yamlStringify(conf as Record, yamlOptions), + "utf-8" ); } } @@ -957,8 +939,9 @@ export async function updateMetadataGlobalLock( conf.locks[path] = hash; } } - await Deno.writeTextFile( + await writeFile( WMILL_LOCKFILE, - yamlStringify(conf as Record, yamlOptions) + yamlStringify(conf as Record, yamlOptions), + "utf-8" ); } diff --git a/cli/src/utils/resource_folders.ts b/cli/src/utils/resource_folders.ts index 5d7fc7c1ca..e24835ab35 100644 --- a/cli/src/utils/resource_folders.ts +++ b/cli/src/utils/resource_folders.ts @@ -8,7 +8,9 @@ * (.flow, .app, .raw_app) or dunder-prefixed names (__flow, __app, __raw_app). */ -import { log, SEP, yamlParseFile } from "../../deps.ts"; +import * as log from "../core/log.ts"; +import { sep as SEP } from "node:path"; +import { yamlParseFile } from "./yaml.ts"; import * as fs from "node:fs"; import * as path from "node:path"; import process from "node:process"; @@ -154,25 +156,30 @@ export function getMetadataPathSuffix( // Path Detection Functions // ============================================================================ +/** Normalize path separators to forward slash for cross-platform matching */ +function normalizeSep(p: string): string { + return p.replaceAll("\\", "/"); +} + /** * Check if a path is inside a flow folder */ export function isFlowPath(p: string): boolean { - return p.includes(getFolderSuffixes().flow + SEP); + return normalizeSep(p).includes(getFolderSuffixes().flow + "/"); } /** * Check if a path is inside an app folder */ export function isAppPath(p: string): boolean { - return p.includes(getFolderSuffixes().app + SEP); + return normalizeSep(p).includes(getFolderSuffixes().app + "/"); } /** * Check if a path is inside a raw_app folder */ export function isRawAppPath(p: string): boolean { - return p.includes(getFolderSuffixes().raw_app + SEP); + return normalizeSep(p).includes(getFolderSuffixes().raw_app + "/"); } /** @@ -248,10 +255,11 @@ export function extractResourceName( p: string, type: FolderResourceType ): string | null { - const suffix = getFolderSuffixes()[type] + SEP; - const index = p.indexOf(suffix); + const normalized = normalizeSep(p); + const suffix = getFolderSuffixes()[type] + "/"; + const index = normalized.indexOf(suffix); if (index === -1) return null; - return p.substring(0, index); + return normalized.substring(0, index); } /** @@ -262,10 +270,11 @@ export function extractFolderPath( p: string, type: FolderResourceType ): string | null { - const suffix = getFolderSuffixes()[type] + SEP; - const index = p.indexOf(suffix); + const normalized = normalizeSep(p); + const suffix = getFolderSuffixes()[type] + "/"; + const index = normalized.indexOf(suffix); if (index === -1) return null; - return p.substring(0, index) + suffix; + return normalized.substring(0, index) + suffix; } /** @@ -291,7 +300,7 @@ export function buildMetadataPath( return ( resourceName + getFolderSuffixes()[type] + - SEP + + "/" + METADATA_FILES[type][format] ); } diff --git a/cli/src/utils/script_common.ts b/cli/src/utils/script_common.ts index a5aba792c8..558a83bda6 100644 --- a/cli/src/utils/script_common.ts +++ b/cli/src/utils/script_common.ts @@ -39,6 +39,19 @@ export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [ { language: "go", filename: "go.mod" }, ] as const; +/** + * Returns true if a script in the given language requires a lock file. + * Matches the condition in updateScriptLock (metadata.ts). + */ +export function languageNeedsLock(language: ScriptLanguage | string): boolean { + return ( + workspaceDependenciesLanguages.some((l) => l.language === language) || + language === "deno" || + language === "rust" || + language === "ansible" + ); +} + export function inferContentTypeFromFilePath( contentPath: string, defaultTs: "bun" | "deno" | undefined diff --git a/cli/src/utils/tar.ts b/cli/src/utils/tar.ts new file mode 100644 index 0000000000..d5149acb56 --- /dev/null +++ b/cli/src/utils/tar.ts @@ -0,0 +1,22 @@ +import { pack } from "tar-stream"; + +export interface TarEntry { + name: string; + content: Buffer | Uint8Array | string; +} + +export function createTarBlob(entries: TarEntry[]): Promise { + return new Promise((resolve, reject) => { + const p = pack(); + const chunks: Uint8Array[] = []; + + p.on("data", (chunk: Buffer) => chunks.push(new Uint8Array(chunk))); + p.on("end", () => resolve(new Blob(chunks as BlobPart[]))); + p.on("error", reject); + + for (const entry of entries) { + p.entry({ name: entry.name }, Buffer.from(entry.content)); + } + p.finalize(); + }); +} diff --git a/cli/src/utils/upgrade.ts b/cli/src/utils/upgrade.ts index 83bbe6b4ab..44ab0741fd 100644 --- a/cli/src/utils/upgrade.ts +++ b/cli/src/utils/upgrade.ts @@ -1,4 +1,4 @@ -import { Provider } from "../../deps.ts"; +import { Provider } from "@cliffy/command/upgrade"; export type NpmProviderOptions = { main?: string; logger?: any } & ( | { @@ -53,6 +53,10 @@ export class NpmProvider extends Provider { getRegistryUrl(name: string, version: string): string { return `npm:${this.packageName ?? name}@${version}`; } + + async hasRequiredPermissions(): Promise { + return true; + } } type NpmApiPackageMetadata = { diff --git a/cli/src/utils/utils.ts b/cli/src/utils/utils.ts index b4fa85ba64..02a8c1a643 100644 --- a/cli/src/utils/utils.ts +++ b/cli/src/utils/utils.ts @@ -2,8 +2,12 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck This file is copied from a JS project, so it's not type-safe. -import { colors, encodeHex, log, SEP } from "../../deps.ts"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../core/log.ts"; +import { sep as SEP } from "node:path"; import crypto from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; import { fetchVersion } from "../core/context.ts"; import { updateGlobalVersions } from "../commands/sync/global.ts"; import { isRawAppPath } from "./resource_folders.ts"; @@ -86,7 +90,7 @@ export function deepEqual(a: T, b: T): boolean { } export function getHeaders(): Record | undefined { - const headers = Deno.env.get("HEADERS"); + const headers = process.env["HEADERS"]; if (headers) { const parsedHeaders = Object.fromEntries( headers.split(",").map((h) => h.split(":").map((s) => s.trim())) @@ -102,11 +106,12 @@ export function getHeaders(): Record | undefined { export async function digestDir(path: string, conf: string) { const hashes: string = []; - for await (const e of Deno.readDir(path)) { + const entries = await readdir(path, { withFileTypes: true }); + for (const e of entries) { const npath = path + "/" + e.name; - if (e.isFile) { - hashes.push(await generateHashFromBuffer(await Deno.readFile(npath))); - } else if (e.isDirectory && !e.isSymlink) { + if (e.isFile()) { + hashes.push(await generateHashFromBuffer(await readFile(npath))); + } else if (e.isDirectory() && !e.isSymbolicLink()) { hashes.push(await digestDir(npath, "")); } } @@ -122,16 +127,12 @@ export async function generateHashFromBuffer( content: BufferSource ): Promise { const hashBuffer = await crypto.subtle.digest("SHA-256", content); - return encodeHex(hashBuffer); + return Buffer.from(hashBuffer).toString("hex"); } -// export async function readInlinePath(path: string): Promise { -// return await Deno.readTextFile(path.replaceAll("/", SEP)); -// } - export function readInlinePathSync(path: string): string { try { - return Deno.readTextFileSync(path.replaceAll("/", SEP)); + return readFileSync(path.replaceAll("/", SEP), "utf-8"); } catch (error) { log.warn(`Error reading inline path: ${path}, ${error}`); return ""; @@ -161,13 +162,10 @@ export function isWorkspaceDependencies(path: string): boolean { return path.startsWith("dependencies/"); } -export function printSync(input: string | Uint8Array, to = Deno.stdout) { - let bytesWritten = 0; - const bytes = - typeof input === "string" ? new TextEncoder().encode(input) : input; - while (bytesWritten < bytes.length) { - bytesWritten += to.writeSync(bytes.subarray(bytesWritten)); - } +export function printSync(input: string | Uint8Array) { + process.stdout.write( + typeof input === "string" ? input : Buffer.from(input) + ); } // Repository interface for shared selection logic @@ -194,7 +192,7 @@ export async function selectRepository( } // Check if we're in a non-interactive environment - const isInteractive = Deno.stdin.isTerminal() && Deno.stdout.isTerminal(); + const isInteractive = !!process.stdin.isTTY && !!process.stdout.isTTY; if (!isInteractive) { const repoPaths = repositories.map((r) => @@ -208,7 +206,7 @@ export async function selectRepository( } // Import Select dynamically to avoid dependency issues - const { Select } = await import("../../deps.ts"); + const { Select } = await import("@cliffy/prompt/select"); console.log( `\nMultiple repositories found. Please select which repository to ${ @@ -249,21 +247,19 @@ export async function getIsWin(): Promise { */ export function writeIfChanged(path: string, content: string): boolean { try { - const existing = Deno.readTextFileSync(path); + const existing = readFileSync(path, "utf-8"); if (existing === content) { - // console.log(`Content unchanged for ${path}`); return false; // Content unchanged, skip write } - } catch (error) { + } catch (error: any) { // File doesn't exist or can't be read, proceed with write - if (!(error instanceof Deno.errors.NotFound)) { + if (error?.code !== "ENOENT") { // If it's not a "not found" error, we might want to know about it // but still proceed with the write attempt } } - // console.log(`Writing content to ${path}`); - Deno.writeTextFileSync(path, content); + writeFileSync(path, content, "utf-8"); return true; // File was written } diff --git a/cli/src/utils/yaml.ts b/cli/src/utils/yaml.ts new file mode 100644 index 0000000000..9ad247c1fd --- /dev/null +++ b/cli/src/utils/yaml.ts @@ -0,0 +1,22 @@ +import { parse as yamlParse, type ParseOptions } from "yaml"; +import { readFile } from "node:fs/promises"; + +export async function yamlParseFile(path: string, options: ParseOptions = {}) { + try { + return yamlParse(await readFile(path, "utf-8"), options); + } catch (e) { + throw new Error(`Error parsing yaml ${path}`, { cause: e }); + } +} + +export function yamlParseContent( + path: string, + content: string, + options: ParseOptions = {}, +) { + try { + return yamlParse(content, options); + } catch (e) { + throw new Error(`Error parsing yaml ${path}`, { cause: e }); + } +} diff --git a/cli/test/cargo_backend.ts b/cli/test/cargo_backend.ts index 8e45c6bb12..7c85ff5bab 100644 --- a/cli/test/cargo_backend.ts +++ b/cli/test/cargo_backend.ts @@ -8,11 +8,16 @@ * - Backend code compiled or ready to compile * * Usage: - * DATABASE_URL=postgres://postgres:changeme@localhost:5432 deno test --allow-all test/my_test.ts + * DATABASE_URL=postgres://postgres:changeme@localhost:5432 bun test test/my_test.ts */ -import { ensureDir } from "https://deno.land/std@0.224.0/fs/mod.ts"; -import { fromFileUrl, resolve, dirname } from "https://deno.land/std@0.224.0/path/mod.ts"; +import { resolve, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { statSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { Subprocess } from "bun"; export interface CargoBackendConfig { /** PostgreSQL connection string (without database name) */ @@ -43,7 +48,7 @@ export interface CargoBackendConfig { export class CargoBackend { private config: Required; - private process: Deno.ChildProcess | null = null; + private process: Subprocess | null = null; private dbName: string; private isRunning = false; private actualPort: number; @@ -58,19 +63,21 @@ export class CargoBackend { // Determine default features based on environment // CI mode: minimal features (zip only) - // Local mode: full features (zip, private, enterprise) - const isCI = Deno.env.get("CI_MINIMAL_FEATURES") === "true"; - const defaultFeatures = isCI ? ["zip"] : ["zip", "private", "enterprise"]; + // Local mode with license key: full features (zip, private, enterprise, license) + // Local mode without license key: zip only (EE features reject API calls without valid license) + const isCI = process.env["CI_MINIMAL_FEATURES"] === "true"; + const hasLicenseKey = !!process.env["EE_LICENSE_KEY"]; + const defaultFeatures = isCI ? ["zip"] : (hasLicenseKey ? ["zip", "private", "enterprise", "license"] : ["zip"]); // Parse additional features from environment variable - const envFeatures = Deno.env.get("TEST_FEATURES")?.split(",").filter(f => f.trim()) || []; + const envFeatures = process.env["TEST_FEATURES"]?.split(",").filter(f => f.trim()) || []; const allFeatures = [...new Set([...defaultFeatures, ...envFeatures, ...(config.features || [])])]; this.config = { - postgresUrl: config.postgresUrl || Deno.env.get("DATABASE_URL") || "postgres://postgres:changeme@localhost:5432", + postgresUrl: config.postgresUrl || process.env["DATABASE_URL"] || "postgres://postgres:changeme@localhost:5432", port: config.port || 0, backendDir, - binaryPath: config.binaryPath || Deno.env.get("WINDMILL_BINARY") || "", + binaryPath: config.binaryPath || process.env["WINDMILL_BINARY"] || "", features: allFeatures, release: config.release ?? false, workspace: config.workspace || "test", @@ -84,8 +91,7 @@ export class CargoBackend { private findBackendDir(): string { // Try to find backend directory relative to CLI - // Use fromFileUrl to properly handle Windows paths (e.g., file:///D:/...) - const cliTestDir = fromFileUrl(new URL(".", import.meta.url)); + const cliTestDir = dirname(fileURLToPath(import.meta.url)); // Use resolve() for proper cross-platform path resolution const candidates = [ resolve(cliTestDir, "..", "..", "backend"), @@ -97,8 +103,8 @@ export class CargoBackend { for (const candidate of candidates) { try { const cargoPath = resolve(candidate, "Cargo.toml"); - const stat = Deno.statSync(cargoPath); - if (stat.isFile) { + const stat = statSync(cargoPath); + if (stat.isFile()) { return candidate; } } catch { @@ -129,19 +135,19 @@ export class CargoBackend { return; } - console.log("🚀 Starting Cargo-based Windmill backend..."); + console.log("Starting Cargo-based Windmill backend..."); // Create test config directory if (!this.config.testConfigDir) { - this.config.testConfigDir = await Deno.makeTempDir({ prefix: "wmill_test_config_" }); - console.log(`📁 Created test config directory: ${this.config.testConfigDir}`); + this.config.testConfigDir = await mkdtemp(join(tmpdir(), "wmill_test_config_")); + console.log(`Created test config directory: ${this.config.testConfigDir}`); } // Find a free port if not specified if (this.actualPort === 0) { this.actualPort = await this.findFreePort(); } - console.log(`📡 Using port: ${this.actualPort}`); + console.log(`Using port: ${this.actualPort}`); // Create the test database await this.createDatabase(); @@ -156,7 +162,7 @@ export class CargoBackend { await this.initializeAndAuthenticate(); this.isRunning = true; - console.log("✅ Cargo backend is ready!"); + console.log("Cargo backend is ready!"); console.log(` Server: ${this.baseUrl}`); console.log(` Database: ${this.dbName}`); console.log(` Workspace: ${this.config.workspace}`); @@ -170,15 +176,15 @@ export class CargoBackend { return; } - console.log("🛑 Stopping Cargo backend..."); + console.log("Stopping Cargo backend..."); // Kill the backend process if (this.process) { try { - this.process.kill("SIGTERM"); + this.process.kill(); // Wait a bit for graceful shutdown await Promise.race([ - this.process.status, + this.process.exited, new Promise(resolve => setTimeout(resolve, 5000)), ]); } catch { @@ -193,25 +199,29 @@ export class CargoBackend { // Cleanup test config directory if (this.config.testConfigDir?.includes("wmill_test_config_")) { try { - await Deno.remove(this.config.testConfigDir, { recursive: true }); - console.log(`🗑️ Cleaned up test config directory`); + await rm(this.config.testConfigDir, { recursive: true, force: true }); + console.log(`Cleaned up test config directory`); } catch { // Ignore cleanup errors } } this.isRunning = false; - console.log("✅ Backend stopped"); + console.log("Backend stopped"); } /** * Find a free port */ private async findFreePort(): Promise { - const listener = Deno.listen({ port: 0 }); - const port = (listener.addr as Deno.NetAddr).port; - listener.close(); - return port; + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const port = (server.address() as any).port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); } /** @@ -231,66 +241,65 @@ export class CargoBackend { * Create the test database */ private async createDatabase(): Promise { - console.log(`📦 Creating test database: ${this.dbName}`); + console.log(`Creating test database: ${this.dbName}`); const baseUrl = this.getBasePostgresUrl(); - const cmd = new Deno.Command("psql", { - args: [ - `${baseUrl}/postgres`, - "-c", - `CREATE DATABASE "${this.dbName}";`, - ], - stdout: "piped", - stderr: "piped", + const proc = Bun.spawn(["psql", `${baseUrl}/postgres`, "-c", `CREATE DATABASE "${this.dbName}";`], { + stdout: "pipe", + stderr: "pipe", }); - const result = await cmd.output(); - if (result.code !== 0) { - const stderr = new TextDecoder().decode(result.stderr); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + + if (exitCode !== 0) { throw new Error(`Failed to create database: ${stderr}`); } - console.log("✅ Test database created"); + console.log("Test database created"); } /** * Drop the test database */ private async dropDatabase(): Promise { - console.log(`🗑️ Dropping test database: ${this.dbName}`); + console.log(`Dropping test database: ${this.dbName}`); const baseUrl = this.getBasePostgresUrl(); // Terminate existing connections - const terminateCmd = new Deno.Command("psql", { - args: [ - `${baseUrl}/postgres`, - "-c", - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${this.dbName}' AND pid <> pg_backend_pid();`, - ], - stdout: "piped", - stderr: "piped", + const terminateProc = Bun.spawn(["psql", `${baseUrl}/postgres`, "-c", + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${this.dbName}' AND pid <> pg_backend_pid();`], { + stdout: "pipe", + stderr: "pipe", }); - await terminateCmd.output(); + await Promise.all([ + new Response(terminateProc.stdout).text(), + new Response(terminateProc.stderr).text(), + ]); + await terminateProc.exited; // Drop the database - const dropCmd = new Deno.Command("psql", { - args: [ - `${baseUrl}/postgres`, - "-c", - `DROP DATABASE IF EXISTS "${this.dbName}";`, - ], - stdout: "piped", - stderr: "piped", + const dropProc = Bun.spawn(["psql", `${baseUrl}/postgres`, "-c", + `DROP DATABASE IF EXISTS "${this.dbName}";`], { + stdout: "pipe", + stderr: "pipe", }); - const result = await dropCmd.output(); - if (result.code !== 0) { - const stderr = new TextDecoder().decode(result.stderr); + const [, stderr] = await Promise.all([ + new Response(dropProc.stdout).text(), + new Response(dropProc.stderr).text(), + ]); + const exitCode = await dropProc.exited; + + if (exitCode !== 0) { console.warn(`Warning: Failed to drop database: ${stderr}`); } else { - console.log("✅ Test database dropped"); + console.log("Test database dropped"); } } @@ -305,7 +314,7 @@ export class CargoBackend { const databaseUrl = `${baseUrl}/${this.dbName}?sslmode=disable`; const env: Record = { - ...Deno.env.toObject(), + ...process.env as Record, DATABASE_URL: databaseUrl, PORT: String(this.actualPort), MODE: "standalone", // Run server + worker in one process @@ -324,24 +333,28 @@ export class CargoBackend { SUPERADMIN_PASSWORD: this.config.password, }; + // On Windows, ensure BUN_PATH and NODE_BIN_PATH are set for the worker. + // The Rust defaults (/usr/bin/bun, /usr/bin/node) don't exist on Windows. + if (process.platform === "win32") { + env.BUN_PATH = env.BUN_PATH || Bun.which("bun") || process.execPath; + env.NODE_BIN_PATH = env.NODE_BIN_PATH || Bun.which("node") || "node"; + } + // Add license key if available - const licenseKey = Deno.env.get("EE_LICENSE_KEY"); + const licenseKey = process.env["EE_LICENSE_KEY"]; if (licenseKey) { env.LICENSE_KEY = licenseKey; } - let cmd: Deno.Command; - if (this.config.binaryPath) { // Use pre-built binary if explicitly specified - console.log(`🔧 Starting backend using binary: ${this.config.binaryPath}`); + console.log(`Starting backend using binary: ${this.config.binaryPath}`); console.log(` DATABASE_URL: ${databaseUrl}`); - cmd = new Deno.Command(this.config.binaryPath, { - args: [], + this.process = Bun.spawn([this.config.binaryPath], { env, - stdout: "piped", - stderr: "piped", + stdout: "pipe", + stderr: "pipe", }); } else { // Use cargo run with features @@ -353,27 +366,25 @@ export class CargoBackend { cargoArgs.push("--features", this.config.features.join(",")); } - console.log(`🔧 Starting backend via: cargo ${cargoArgs.join(" ")}`); + console.log(`Starting backend via: cargo ${cargoArgs.join(" ")}`); console.log(` DATABASE_URL: ${databaseUrl}`); console.log(` Backend dir: ${this.config.backendDir}`); - cmd = new Deno.Command("cargo", { - args: cargoArgs, + this.process = Bun.spawn(["cargo", ...cargoArgs], { cwd: this.config.backendDir, env, - stdout: "piped", - stderr: "piped", + stdout: "pipe", + stderr: "pipe", }); } - this.process = cmd.spawn(); this.stderrChunks = []; this.stdoutChunks = []; // Capture output in background this.captureProcessOutput(); - console.log(`⏳ Backend process started (PID: ${this.process.pid})`); + console.log(`Backend process started (PID: ${this.process.pid})`); } /** @@ -395,7 +406,7 @@ export class CargoBackend { if (value) { this.stdoutChunks.push(value); if (this.config.verbose) { - Deno.stdout.writeSync(value); + process.stdout.write(value); } } } @@ -415,7 +426,7 @@ export class CargoBackend { if (value) { this.stderrChunks.push(value); if (this.config.verbose) { - Deno.stderr.writeSync(value); + process.stderr.write(value); } } } @@ -458,7 +469,7 @@ export class CargoBackend { * Wait for the API to be responsive */ private async waitForAPI(): Promise { - console.log("⏳ Waiting for API to be responsive (this may take a few minutes if compiling)..."); + console.log("Waiting for API to be responsive (this may take a few minutes if compiling)..."); // Allow up to 10 minutes for cargo build + startup const maxAttempts = 300; // 10 minutes with 2-second intervals @@ -473,7 +484,7 @@ export class CargoBackend { if (response.ok) { const version = await response.text(); - console.log(`📡 API ready (version: ${version.trim()})`); + console.log(`API ready (version: ${version.trim()})`); return; } await response.text(); // Consume response @@ -485,7 +496,7 @@ export class CargoBackend { if (this.process) { try { const status = await Promise.race([ - this.process.status, + this.process.exited, new Promise(resolve => setTimeout(() => resolve(null), 100)), ]); if (status !== null) { @@ -493,14 +504,14 @@ export class CargoBackend { await new Promise(resolve => setTimeout(resolve, 500)); const stderr = this.getStderr(); const stdout = this.getStdout(); - console.error("\n❌ Backend process crashed!"); + console.error("\nBackend process crashed!"); if (stdout) { console.error("=== STDOUT ===\n" + stdout.slice(-2000)); } if (stderr) { console.error("=== STDERR ===\n" + stderr.slice(-2000)); } - throw new Error(`Backend process exited with code ${status.code}`); + throw new Error(`Backend process exited with code ${status}`); } } catch (e) { if (e instanceof Error && e.message.includes("exited")) { @@ -529,7 +540,7 @@ export class CargoBackend { * Initialize test data and authenticate */ private async initializeAndAuthenticate(): Promise { - console.log("🔧 Initializing test workspace..."); + console.log("Initializing test workspace..."); // Create test workspace via API await this.createWorkspace(); @@ -537,7 +548,7 @@ export class CargoBackend { // Login to get token await this.authenticate(); - console.log("✅ Test workspace initialized"); + console.log("Test workspace initialized"); } /** @@ -581,7 +592,7 @@ export class CargoBackend { } } else { await createWsResponse.text(); - console.log(` ✅ Created workspace: ${this.config.workspace}`); + console.log(` Created workspace: ${this.config.workspace}`); } } @@ -589,7 +600,7 @@ export class CargoBackend { * Authenticate and get token */ private async authenticate(): Promise { - console.log("🔑 Authenticating..."); + console.log("Authenticating..."); const loginResponse = await fetch(`${this.baseUrl}/api/auth/login`, { method: "POST", @@ -605,7 +616,7 @@ export class CargoBackend { } this.token = await loginResponse.text(); - console.log("✅ Authentication successful"); + console.log("Authentication successful"); } /** @@ -618,8 +629,9 @@ export class CargoBackend { /** * Create CLI command with proper authentication */ - createCLICommand(args: string[], workingDir: string, workspaceName?: string): Deno.Command { + createCLICommand(args: string[], workingDir: string, workspaceName?: string): { command: string, args: string[], cwd: string, env: Record } { const workspace = workspaceName || this.config.workspace; + const cliDir = join(dirname(fileURLToPath(import.meta.url)), ".."); const fullArgs = [ "--base-url", this.baseUrl, "--workspace", workspace, @@ -628,20 +640,21 @@ export class CargoBackend { ...args, ]; - const denoPath = Deno.execPath(); - const cliMainPath = fromFileUrl(new URL("../src/main.ts", import.meta.url)); + const useNode = process.env["TEST_CLI_RUNTIME"] === "node"; + const runtime = useNode ? "node" : "bun"; + const entrypoint = useNode + ? join(cliDir, "npm", "esm", "main.js") + : join(cliDir, "src", "main.ts"); + const runtimeArgs = useNode ? [entrypoint] : ["run", entrypoint]; - console.log("🔧 CLI Command:", [denoPath, "run", "-A", cliMainPath, ...fullArgs].join(" ")); + console.log("CLI Command:", [runtime, ...runtimeArgs, ...fullArgs].join(" ")); - return new Deno.Command(denoPath, { - args: ["run", "-A", cliMainPath, ...fullArgs], + return { + command: runtime, + args: [...runtimeArgs, ...fullArgs], cwd: workingDir, - stdout: "piped", - stderr: "piped", - env: { - SKIP_DENO_DEPRECATION_WARNING: "true", - }, - }); + env: { ...process.env as Record }, + }; } /** @@ -653,13 +666,20 @@ export class CargoBackend { code: number; }> { const cmd = this.createCLICommand(args, workingDir, workspaceName); - const result = await cmd.output(); + const proc = Bun.spawn([cmd.command, ...cmd.args], { + cwd: cmd.cwd, + env: cmd.env, + stdout: "pipe", + stderr: "pipe", + }); - return { - stdout: new TextDecoder().decode(result.stdout), - stderr: new TextDecoder().decode(result.stderr), - code: result.code, - }; + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + + return { stdout, stderr, code }; } /** @@ -677,7 +697,7 @@ export class CargoBackend { * Reset workspace to clean state */ async reset(): Promise { - console.log("🔄 Resetting workspace..."); + console.log("Resetting workspace..."); // Delete all content via API await Promise.all([ @@ -689,7 +709,7 @@ export class CargoBackend { this.deleteAll("folders"), ]); - console.log("✅ Workspace reset complete"); + console.log("Workspace reset complete"); } private async deleteAll(resourceType: string): Promise { @@ -731,13 +751,13 @@ export async function withCargoBackend( await globalCargoBackend.start(); } - const tempDir = await Deno.makeTempDir({ prefix: "windmill_cli_test_" }); + const tempDir = await mkdtemp(join(tmpdir(), "windmill_cli_test_")); try { await globalCargoBackend.reset(); return await testFn(globalCargoBackend, tempDir); } finally { - await Deno.remove(tempDir, { recursive: true }); + await rm(tempDir, { recursive: true, force: true }); } } @@ -752,15 +772,15 @@ export async function cleanupCargoBackend(): Promise { } /** - * Check if running in CI minimal mode (skip EE-dependent tests) + * Check if EE-dependent tests should be skipped * - * When CI_MINIMAL_FEATURES=true: - * - Backend runs with only "zip" feature (no private/enterprise) - * - Tests requiring EE features should be skipped + * Returns true when: + * - CI_MINIMAL_FEATURES=true (CI mode with zip-only features) + * - EE_LICENSE_KEY is not set (EE features reject API calls without valid license) * * Use this in test definitions: - * ignore: shouldSkipOnCI() + * test.skipIf(shouldSkipOnCI())("my EE test", ...) */ export function shouldSkipOnCI(): boolean { - return Deno.env.get("CI_MINIMAL_FEATURES") === "true"; + return process.env["CI_MINIMAL_FEATURES"] === "true" || !process.env["EE_LICENSE_KEY"]; } diff --git a/cli/test/cargo_backend_example.test.ts b/cli/test/cargo_backend_example.standalone.ts similarity index 55% rename from cli/test/cargo_backend_example.test.ts rename to cli/test/cargo_backend_example.standalone.ts index 49bc2c48b6..945b2b6419 100644 --- a/cli/test/cargo_backend_example.test.ts +++ b/cli/test/cargo_backend_example.standalone.ts @@ -16,76 +16,54 @@ * VERBOSE=1 deno test --allow-all test/cargo_backend_example.test.ts */ -import { - assertEquals, - assertExists, -} from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { CargoBackend } from "./cargo_backend.ts"; // Single backend instance for all tests let backend: CargoBackend; // Setup before all tests -Deno.test({ - name: "setup: start cargo backend", - fn: async () => { +test("setup: start cargo backend", async () => { backend = new CargoBackend({ - verbose: Deno.env.get("VERBOSE") === "1", + verbose: process.env.VERBOSE === "1", }); await backend.start(); - assertExists(backend.baseUrl); - assertExists(backend.authToken); - }, - sanitizeResources: false, - sanitizeOps: false, + expect(backend.baseUrl).toBeDefined(); + expect(backend.authToken).toBeDefined(); }); -Deno.test({ - name: "API: version endpoint responds", - fn: async () => { +test("API: version endpoint responds", async () => { const response = await fetch(`${backend.baseUrl}/api/version`); - assertEquals(response.ok, true); + expect(response.ok).toEqual(true); const version = await response.text(); - assertExists(version); + expect(version).toBeDefined(); console.log(` Backend version: ${version.trim()}`); - }, - sanitizeResources: false, - sanitizeOps: false, }); -Deno.test({ - name: "API: workspace exists", - fn: async () => { +test("API: workspace exists", async () => { const response = await backend.apiRequest( `/api/w/${backend.workspace}/workspaces/get_settings`, ); - assertEquals(response.ok, true); + expect(response.ok).toEqual(true); await response.text(); - }, - sanitizeResources: false, - sanitizeOps: false, }); -Deno.test({ - name: "CLI: wmill --version works", - fn: async () => { - const tempDir = await Deno.makeTempDir({ prefix: "wmill_test_" }); +test("CLI: wmill --version works", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "wmill_test_")); try { const result = await backend.runCLICommand(["--version"], tempDir); - assertEquals(result.code, 0); + expect(result.code).toEqual(0); console.log(` CLI version: ${result.stdout.trim()}`); } finally { - await Deno.remove(tempDir, { recursive: true }); + await rm(tempDir, { recursive: true }); } - }, - sanitizeResources: false, - sanitizeOps: false, }); -Deno.test({ - name: "CLI: wmill sync pull works", - fn: async () => { - const tempDir = await Deno.makeTempDir({ prefix: "wmill_test_" }); +test("CLI: wmill sync pull works", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "wmill_test_")); try { const result = await backend.runCLICommand( ["sync", "pull", "--yes"], @@ -97,19 +75,11 @@ Deno.test({ console.log(` stderr: ${result.stderr.slice(0, 200)}`); } } finally { - await Deno.remove(tempDir, { recursive: true }); + await rm(tempDir, { recursive: true }); } - }, - sanitizeResources: false, - sanitizeOps: false, }); // Cleanup after all tests -Deno.test({ - name: "cleanup: stop cargo backend", - fn: async () => { +test("cleanup: stop cargo backend", async () => { await backend.stop(); - }, - sanitizeResources: false, - sanitizeOps: false, }); diff --git a/cli/test/conf_branch_override.test.ts b/cli/test/conf_branch_override.test.ts index e0976710bb..1bacea0636 100644 --- a/cli/test/conf_branch_override.test.ts +++ b/cli/test/conf_branch_override.test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; import { getEffectiveSettings, type SyncOptions } from "../src/core/conf.ts"; // ============================================================================= @@ -6,7 +6,7 @@ import { getEffectiveSettings, type SyncOptions } from "../src/core/conf.ts"; // Tests for getEffectiveSettings with branchOverride parameter // ============================================================================= -Deno.test("getEffectiveSettings: applies branch overrides when branchOverride is provided", async () => { +test("getEffectiveSettings: applies branch overrides when branchOverride is provided", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], @@ -28,18 +28,18 @@ Deno.test("getEffectiveSettings: applies branch overrides when branchOverride is // Test with staging branch override const stagingSettings = await getEffectiveSettings(config, undefined, true, true, "staging"); - assertEquals(stagingSettings.includes, ["staging/**"]); - assertEquals(stagingSettings.skipVariables, true); - assertEquals(stagingSettings.skipSecrets, undefined); + expect(stagingSettings.includes).toEqual(["staging/**"]); + expect(stagingSettings.skipVariables).toEqual(true); + expect(stagingSettings.skipSecrets).toEqual(undefined); // Test with production branch override const prodSettings = await getEffectiveSettings(config, undefined, true, true, "production"); - assertEquals(prodSettings.includes, ["prod/**"]); - assertEquals(prodSettings.skipSecrets, true); - assertEquals(prodSettings.skipVariables, undefined); + expect(prodSettings.includes).toEqual(["prod/**"]); + expect(prodSettings.skipSecrets).toEqual(true); + expect(prodSettings.skipVariables).toEqual(undefined); }); -Deno.test("getEffectiveSettings: uses top-level settings when branchOverride has no overrides", async () => { +test("getEffectiveSettings: uses top-level settings when branchOverride has no overrides", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], @@ -52,12 +52,12 @@ Deno.test("getEffectiveSettings: uses top-level settings when branchOverride has }; const settings = await getEffectiveSettings(config, undefined, true, true, "staging"); - assertEquals(settings.includes, ["f/**"]); - assertEquals(settings.skipVariables, true); - assertEquals(settings.defaultTs, "bun"); + expect(settings.includes).toEqual(["f/**"]); + expect(settings.skipVariables).toEqual(true); + expect(settings.defaultTs).toEqual("bun"); }); -Deno.test("getEffectiveSettings: uses top-level settings for unknown branch", async () => { +test("getEffectiveSettings: uses top-level settings for unknown branch", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], @@ -71,11 +71,11 @@ Deno.test("getEffectiveSettings: uses top-level settings for unknown branch", as }; const settings = await getEffectiveSettings(config, undefined, true, true, "nonexistent"); - assertEquals(settings.includes, ["f/**"]); - assertEquals(settings.defaultTs, "bun"); + expect(settings.includes).toEqual(["f/**"]); + expect(settings.defaultTs).toEqual("bun"); }); -Deno.test("getEffectiveSettings: promotionOverrides take precedence when promotion specified", async () => { +test("getEffectiveSettings: promotionOverrides take precedence when promotion specified", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], @@ -94,16 +94,16 @@ Deno.test("getEffectiveSettings: promotionOverrides take precedence when promoti // Test without promotion flag - should use regular overrides const normalSettings = await getEffectiveSettings(config, undefined, true, true, "production"); - assertEquals(normalSettings.includes, ["prod/**"]); - assertEquals(normalSettings.skipVariables, undefined); + expect(normalSettings.includes).toEqual(["prod/**"]); + expect(normalSettings.skipVariables).toEqual(undefined); // Test with promotion flag - should use promotionOverrides const promoSettings = await getEffectiveSettings(config, "production", true, true); - assertEquals(promoSettings.includes, ["promoted/**"]); - assertEquals(promoSettings.skipVariables, true); + expect(promoSettings.includes).toEqual(["promoted/**"]); + expect(promoSettings.skipVariables).toEqual(true); }); -Deno.test("getEffectiveSettings: branchOverride works without gitBranches config", async () => { +test("getEffectiveSettings: branchOverride works without gitBranches config", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], @@ -111,11 +111,11 @@ Deno.test("getEffectiveSettings: branchOverride works without gitBranches config // Should not throw even with branchOverride but no gitBranches const settings = await getEffectiveSettings(config, undefined, true, true, "staging"); - assertEquals(settings.includes, ["f/**"]); - assertEquals(settings.defaultTs, "bun"); + expect(settings.includes).toEqual(["f/**"]); + expect(settings.defaultTs).toEqual("bun"); }); -Deno.test("getEffectiveSettings: preserves all top-level settings in merged result", async () => { +test("getEffectiveSettings: preserves all top-level settings in merged result", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], @@ -134,11 +134,11 @@ Deno.test("getEffectiveSettings: preserves all top-level settings in merged resu }; const settings = await getEffectiveSettings(config, undefined, true, true, "staging"); - assertEquals(settings.defaultTs, "bun"); - assertEquals(settings.includes, ["f/**"]); - assertEquals(settings.excludes, ["*.test.ts"]); - assertEquals(settings.skipVariables, true); // Overridden - assertEquals(settings.skipResources, false); - assertEquals(settings.skipFlows, false); - assertEquals(settings.parallel, 4); + expect(settings.defaultTs).toEqual("bun"); + expect(settings.includes).toEqual(["f/**"]); + expect(settings.excludes).toEqual(["*.test.ts"]); + expect(settings.skipVariables).toEqual(true); // Overridden + expect(settings.skipResources).toEqual(false); + expect(settings.skipFlows).toEqual(false); + expect(settings.parallel).toEqual(4); }); diff --git a/cli/test/containerized_backend.ts b/cli/test/containerized_backend.ts index 8ee7e064f6..65c26c8373 100644 --- a/cli/test/containerized_backend.ts +++ b/cli/test/containerized_backend.ts @@ -3,6 +3,25 @@ * Manages real Windmill EE backend containers for CLI testing */ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +async function runCommand(cmd: string, args: string[], opts?: { cwd?: string, env?: Record }): Promise<{ code: number, stdout: string, stderr: string }> { + const proc = Bun.spawn([cmd, ...args], { + stdout: 'pipe', + stderr: 'pipe', + cwd: opts?.cwd, + env: { ...process.env, ...opts?.env }, + }); + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { code, stdout, stderr }; +} + export interface ContainerConfig { composeFile?: string; baseUrl?: string; @@ -71,25 +90,19 @@ export class ContainerizedBackend { // Create isolated test config directory if not provided if (!this.config.testConfigDir) { - this.config.testConfigDir = await Deno.makeTempDir({ prefix: 'wmill_test_config_' }); + this.config.testConfigDir = await mkdtemp(join(tmpdir(), 'wmill_test_config_')); console.log(`📁 Created test config directory: ${this.config.testConfigDir}`); } // Start containers with EE license key - const startCmd = new Deno.Command('docker', { - args: ['compose', '-f', this.config.composeFile, 'up', '-d'], - stdout: 'piped', - stderr: 'piped', + const startResult = await runCommand('docker', ['compose', '-f', this.config.composeFile, 'up', '-d'], { env: { - ...Deno.env.toObject(), - ...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! }) + ...(process.env.EE_LICENSE_KEY && { EE_LICENSE_KEY: process.env.EE_LICENSE_KEY }) } }); - const startResult = await startCmd.output(); if (startResult.code !== 0) { - const stderr = new TextDecoder().decode(startResult.stderr); - throw new Error(`Failed to start containers: ${stderr}`); + throw new Error(`Failed to start containers: ${startResult.stderr}`); } // Wait for services to be healthy @@ -117,22 +130,16 @@ export class ContainerizedBackend { console.log('🛑 Stopping containerized backend...'); - const stopCmd = new Deno.Command('docker', { - args: ['compose', '-f', this.config.composeFile, 'down', '-v'], - stdout: 'piped', - stderr: 'piped', + await runCommand('docker', ['compose', '-f', this.config.composeFile, 'down', '-v'], { env: { - ...Deno.env.toObject(), - ...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! }) + ...(process.env.EE_LICENSE_KEY && { EE_LICENSE_KEY: process.env.EE_LICENSE_KEY }) } }); - - await stopCmd.output(); // Clean up test config directory if we created it if (this.config.testConfigDir && this.config.testConfigDir.includes('wmill_test_config_')) { try { - await Deno.remove(this.config.testConfigDir, { recursive: true }); + await rm(this.config.testConfigDir, { recursive: true }); console.log(`🗑️ Cleaned up test config directory: ${this.config.testConfigDir}`); } catch (error) { console.warn(`⚠️ Failed to clean up test config directory: ${error}`); @@ -1013,7 +1020,7 @@ export async function main( /** * Create CLI command with proper authentication */ - createCLICommand(args: string[], workingDir: string, workspaceName?: string): Deno.Command { + createCLICommand(args: string[], workingDir: string, workspaceName?: string): { cmd: string[], cwd: string } { const workspace = workspaceName || this.config.workspace; const fullArgs = [ '--base-url', this.config.baseUrl, @@ -1022,21 +1029,21 @@ export async function main( '--config-dir', this.config.testConfigDir, ...args ]; - - const denoPath = Deno.execPath(); - const cliMainPath = new URL('../src/main.ts', import.meta.url).pathname; - console.log('🔧 CLI Command:', [denoPath, 'run', '-A', cliMainPath, ...fullArgs].join(' ')); + const useNode = process.env["TEST_CLI_RUNTIME"] === "node"; + const cliDir = new URL('..', import.meta.url).pathname; + const entrypoint = useNode + ? new URL('../npm/esm/main.js', import.meta.url).pathname + : new URL('../src/main.ts', import.meta.url).pathname; + const runtime = useNode ? 'node' : 'bun'; + const runtimeArgs = useNode ? [entrypoint] : ['run', entrypoint]; - return new Deno.Command(denoPath, { - args: ['run', '-A', cliMainPath, ...fullArgs], + console.log('CLI Command:', [runtime, ...runtimeArgs, ...fullArgs].join(' ')); + + return { + cmd: [runtime, ...runtimeArgs, ...fullArgs], cwd: workingDir, - stdout: 'piped', - stderr: 'piped', - env: { - 'SKIP_DENO_DEPRECATION_WARNING': 'true' - } - }); + }; } /** @@ -1047,14 +1054,18 @@ export async function main( stderr: string; code: number; }> { - const cmd = this.createCLICommand(args, workingDir, workspaceName); - const result = await cmd.output(); - - return { - stdout: new TextDecoder().decode(result.stdout), - stderr: new TextDecoder().decode(result.stderr), - code: result.code - }; + const { cmd, cwd } = this.createCLICommand(args, workingDir, workspaceName); + const proc = Bun.spawn(cmd, { + stdout: 'pipe', + stderr: 'pipe', + cwd, + }); + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { stdout, stderr, code }; } /** @@ -1192,42 +1203,30 @@ export async function main( ON CONFLICT (workspace_id, kind) DO UPDATE SET key = EXCLUDED.key; `; - const execCmd = new Deno.Command('docker', { - args: ['compose', '-f', this.config.composeFile, 'exec', '-T', 'test_db', - 'psql', '-U', 'postgres', '-d', 'windmill_test', '-c', initSQL], - stdout: 'piped', - stderr: 'piped', + const result = await runCommand('docker', ['compose', '-f', this.config.composeFile, 'exec', '-T', 'test_db', + 'psql', '-U', 'postgres', '-d', 'windmill_test', '-c', initSQL], { env: { - ...Deno.env.toObject(), - ...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! }) + ...(process.env.EE_LICENSE_KEY && { EE_LICENSE_KEY: process.env.EE_LICENSE_KEY }) } }); - const result = await execCmd.output(); if (result.code !== 0) { - const stderr = new TextDecoder().decode(result.stderr); - throw new Error(`Failed to initialize test data: ${stderr}`); + throw new Error(`Failed to initialize test data: ${result.stderr}`); } console.log('✅ Test workspace initialized'); // Verify license key was stored - const checkLicenseCmd = new Deno.Command('docker', { - args: ['compose', '-f', this.config.composeFile, 'exec', '-T', 'test_db', - 'psql', '-U', 'postgres', '-d', 'windmill_test', '-c', - "SELECT name, value FROM global_settings WHERE name = 'license_key';"], - stdout: 'piped', - stderr: 'piped', + const checkResult = await runCommand('docker', ['compose', '-f', this.config.composeFile, 'exec', '-T', 'test_db', + 'psql', '-U', 'postgres', '-d', 'windmill_test', '-c', + "SELECT name, value FROM global_settings WHERE name = 'license_key';"], { env: { - ...Deno.env.toObject(), - EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY') || 'REMOVED_HARDCODED_LICENSE' + EE_LICENSE_KEY: process.env.EE_LICENSE_KEY || 'REMOVED_HARDCODED_LICENSE' } }); - - const checkResult = await checkLicenseCmd.output(); + if (checkResult.code === 0) { - const output = new TextDecoder().decode(checkResult.stdout); - console.log('🔍 License key in database:', output.trim()); + console.log('License key in database:', checkResult.stdout.trim()); } } @@ -1240,19 +1239,14 @@ export async function main( let attempts = 0; while (attempts < maxAttempts) { - const healthCmd = new Deno.Command('docker', { - args: ['compose', '-f', this.config.composeFile, 'ps', '--format', 'json'], - stdout: 'piped', - stderr: 'piped', + const result = await runCommand('docker', ['compose', '-f', this.config.composeFile, 'ps', '--format', 'json'], { env: { - ...Deno.env.toObject(), - ...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! }) + ...(process.env.EE_LICENSE_KEY && { EE_LICENSE_KEY: process.env.EE_LICENSE_KEY }) } }); - - const result = await healthCmd.output(); + if (result.code === 0) { - const output = new TextDecoder().decode(result.stdout); + const output = result.stdout; if (output.trim()) { const containers = output.trim().split('\n').map(line => JSON.parse(line)); @@ -1345,15 +1339,15 @@ export async function withContainerizedBackend( } } - const tempDir = await Deno.makeTempDir({ prefix: 'windmill_cli_test_' }); - + const tempDir = await mkdtemp(join(tmpdir(), 'windmill_cli_test_')); + try { await globalBackend.reset(); await globalBackend.seedTestData(); - + return await testFn(globalBackend, tempDir); } finally { - await Deno.remove(tempDir, { recursive: true }); + await rm(tempDir, { recursive: true }); } } diff --git a/cli/test/dev_server.test.ts b/cli/test/dev_server.test.ts new file mode 100644 index 0000000000..be243b645d --- /dev/null +++ b/cli/test/dev_server.test.ts @@ -0,0 +1,417 @@ +/** + * Dev Server Smoke Tests + * + * Tests for `wmill dev` and `wmill app dev` commands. + * Verifies server startup, WebSocket connectivity, and file-change broadcasting. + * + * Run with: + * bun test test/dev_server.test.ts + */ + +import { expect, test } from "bun:test"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createServer } from "node:net"; +import { Subprocess } from "bun"; +import WebSocket from "ws"; +import { withTestBackend } from "./test_backend.ts"; + +/** Find a free port by binding to port 0 */ +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const port = (server.address() as any).port; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +/** Wait for a condition with timeout */ +async function waitFor( + fn: () => T | Promise, + timeoutMs: number, + label: string, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const result = await fn(); + if (result) return result; + } catch (e) { + lastError = e; + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error(`Timed out waiting for: ${label} (after ${timeoutMs}ms). Last error: ${lastError}`); +} + +/** Get CLI main.ts path */ +function getCLIMainPath(): string { + return join(dirname(fileURLToPath(import.meta.url)), "..", "src", "main.ts"); +} + +// ============================================================================= +// TEST 1: `wmill dev` smoke test +// ============================================================================= + +test( + "wmill dev: starts server, broadcasts file changes over WebSocket", + async () => { + await withTestBackend(async (backend, tempDir) => { + // Create wmill.yaml config + await writeFile( + join(tempDir, "wmill.yaml"), + "defaultTs: bun\n", + "utf-8", + ); + + // Create a script file + const scriptDir = join(tempDir, "f", "test"); + await mkdir(scriptDir, { recursive: true }); + await writeFile( + join(scriptDir, "hello.ts"), + 'export function main() { return "hello"; }\n', + "utf-8", + ); + await writeFile( + join(scriptDir, "hello.script.yaml"), + `summary: "test"\ndescription: ""\nlock: ""\nschema:\n $schema: "https://json-schema.org/draft/2020-12/schema"\n type: object\n properties: {}\n required: []\n`, + "utf-8", + ); + + // Push the script so the workspace has content + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes"], + tempDir, + ); + if (pushResult.code !== 0) { + console.error("Push stderr:", pushResult.stderr); + console.error("Push stdout:", pushResult.stdout); + } + expect(pushResult.code).toEqual(0); + + // Build the CLI command for `wmill dev` + const cliMainPath = getCLIMainPath(); + const args = [ + "run", + cliMainPath, + "--base-url", + backend.baseUrl, + "--workspace", + backend.workspace, + "--token", + backend.token!, + "--config-dir", + backend.testConfigDir, + "dev", + ]; + + let proc: Subprocess | null = null; + let ws: WebSocket | null = null; + + try { + // Spawn wmill dev as background process + proc = Bun.spawn(["bun", ...args], { + cwd: tempDir, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }); + + // Read stdout to find the port + const stdoutReader = proc.stdout.getReader(); + let stdoutBuffer = ""; + let port: number | null = null; + + // Wait for "Server listening on port XXXX" message + const portMatch = await waitFor( + async () => { + try { + const { done, value } = await Promise.race([ + stdoutReader.read(), + new Promise<{ done: true; value: undefined }>((r) => + setTimeout(() => r({ done: true, value: undefined }), 500), + ), + ]); + if (!done && value) { + stdoutBuffer += new TextDecoder().decode(value); + } + } catch { + // Reader may be exhausted + } + const match = stdoutBuffer.match( + /Server listening on port (\d+)/, + ); + return match; + }, + 30000, + "dev server to start", + ); + + port = parseInt(portMatch[1], 10); + expect(port).toBeGreaterThan(0); + stdoutReader.releaseLock(); + + // Connect WebSocket + ws = new WebSocket(`ws://localhost:${port}`); + + // Wait for connection to open + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("WebSocket connection timeout")), + 5000, + ); + ws!.on("open", () => { + clearTimeout(timeout); + resolve(); + }); + ws!.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + + expect(ws.readyState).toEqual(WebSocket.OPEN); + + // Set up a promise to receive the next WebSocket message + const isWindows = process.platform === "win32"; + const messagePromise = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("WebSocket message timeout")), + isWindows ? 30000 : 10000, + ); + ws!.on("message", (data) => { + clearTimeout(timeout); + try { + resolve(JSON.parse(data.toString())); + } catch (e) { + reject(e); + } + }); + }); + + // Modify the script file on disk + // Windows fs.watch() needs more time to initialize with recursive: true + await new Promise((r) => setTimeout(r, isWindows ? 2000 : 300)); + await writeFile( + join(scriptDir, "hello.ts"), + 'export function main() { return "modified"; }\n', + "utf-8", + ); + + // Wait for WebSocket message + const message = await messagePromise; + + // Verify the message + expect(message.type).toEqual("script"); + expect(message.content).toContain("modified"); + expect(message.path).toContain("f/test/hello"); + expect(message.language).toBeTruthy(); + } finally { + if (ws) { + ws.close(); + } + if (proc) { + proc.kill(); + await proc.exited; + } + } + }); + }, + { timeout: 60000 }, +); + +// ============================================================================= +// TEST 2: `wmill app dev` smoke test +// ============================================================================= + +test( + "wmill app dev: starts HTTP server, serves HTML, provides SSE endpoint", + async () => { + await withTestBackend(async (backend, tempDir) => { + // Create wmill.yaml config + await writeFile( + join(tempDir, "wmill.yaml"), + "defaultTs: bun\n", + "utf-8", + ); + + // Create a raw app directory with the right suffix + const appDir = join(tempDir, "f", "test", "myapp.raw_app"); + await mkdir(appDir, { recursive: true }); + + // Create raw_app.yaml + await writeFile( + join(appDir, "raw_app.yaml"), + `custom_path: f/test/myapp\n`, + "utf-8", + ); + + // Create package.json (minimal, with react dependency) + await writeFile( + join(appDir, "package.json"), + JSON.stringify( + { + name: "test-app", + private: true, + dependencies: { + react: "^18.0.0", + "react-dom": "^18.0.0", + }, + }, + null, + 2, + ), + "utf-8", + ); + + // Create index.tsx entry point + await writeFile( + join(appDir, "index.tsx"), + `import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +const root = createRoot(document.getElementById("root")!); +root.render(); +`, + "utf-8", + ); + + // Create App.tsx + await writeFile( + join(appDir, "App.tsx"), + `import React from "react"; + +export default function App() { + return
Hello from test app
; +} +`, + "utf-8", + ); + + // Run npm install in the app directory + const npmInstall = Bun.spawn(["npm", "install"], { + cwd: appDir, + stdout: "pipe", + stderr: "pipe", + }); + await Promise.all([ + new Response(npmInstall.stdout).text(), + new Response(npmInstall.stderr).text(), + ]); + const npmExitCode = await npmInstall.exited; + expect(npmExitCode).toEqual(0); + + // Find a free port + const port = await findFreePort(); + + // Build the CLI command for `wmill app dev` + const cliMainPath = getCLIMainPath(); + const args = [ + "run", + cliMainPath, + "--base-url", + backend.baseUrl, + "--workspace", + backend.workspace, + "--token", + backend.token!, + "--config-dir", + backend.testConfigDir, + "app", + "dev", + appDir, + "--no-open", + "--port", + String(port), + ]; + + let proc: Subprocess | null = null; + + try { + // Spawn wmill app dev as background process + proc = Bun.spawn(["bun", ...args], { + cwd: tempDir, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env }, + }); + + // Collect stderr in background for debugging + const stderrReader = proc.stderr.getReader(); + let stderrBuffer = ""; + (async () => { + try { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + stderrBuffer += new TextDecoder().decode(value); + } + } catch { + // Process may have exited + } + })(); + + // Wait for server to be ready by polling the HTTP endpoint + await waitFor( + async () => { + try { + const res = await fetch(`http://localhost:${port}/`, { + signal: AbortSignal.timeout(1000), + }); + if (res.ok) { + await res.text(); + return true; + } + await res.text(); + } catch { + // Not ready yet + } + return false; + }, + 60000, + "app dev server to be ready", + ); + + // Verify GET / returns HTML + const htmlRes = await fetch(`http://localhost:${port}/`); + const contentType = htmlRes.headers.get("content-type"); + const htmlBody = await htmlRes.text(); + expect(contentType).toContain("text/html"); + expect(htmlBody).toContain(""); + expect(htmlBody).toContain("
"); + + // Verify GET /__events returns SSE stream + const controller = new AbortController(); + const sseTimeout = setTimeout(() => controller.abort(), 5000); + try { + const sseRes = await fetch(`http://localhost:${port}/__events`, { + signal: controller.signal, + }); + const sseContentType = sseRes.headers.get("content-type"); + expect(sseContentType).toContain("text/event-stream"); + // Read a small chunk to verify SSE sends data + const reader = sseRes.body!.getReader(); + const { value } = await reader.read(); + const chunk = new TextDecoder().decode(value); + expect(chunk).toContain("data: connected"); + reader.cancel(); + } finally { + clearTimeout(sseTimeout); + } + } finally { + if (proc) { + proc.kill(); + await proc.exited; + } + } + }); + }, + { timeout: 120000 }, +); + diff --git a/cli/test/elements_to_map_branch_specific.test.ts b/cli/test/elements_to_map_branch_specific.test.ts index 03af7d52ba..b9abaa8dd1 100644 --- a/cli/test/elements_to_map_branch_specific.test.ts +++ b/cli/test/elements_to_map_branch_specific.test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; // Import the function we need to test import { elementsToMap } from "../src/commands/sync/sync.ts"; @@ -63,7 +63,7 @@ const defaultSkips = {}; // REGRESSION TEST: Remote base files should NOT be skipped // ============================================================================= -Deno.test("elementsToMap: remote base file is NOT skipped when configured as branch-specific (isRemote=true)", async () => { +test("elementsToMap: remote base file is NOT skipped when configured as branch-specific (isRemote=true)", async () => { // This is the key regression test. // When pulling from remote, the workspace only has base paths (e.g., TestVar.variable.yaml) // These should NOT be skipped even if configured as branch-specific, because the remote @@ -94,14 +94,10 @@ Deno.test("elementsToMap: remote base file is NOT skipped when configured as bra ); // The base file should be in the map - assertEquals( - Object.keys(result).includes("f/Shared/Variable/TestVar.variable.yaml"), - true, - "Remote base file should NOT be skipped when isRemote=true" - ); + expect(Object.keys(result).includes("f/Shared/Variable/TestVar.variable.yaml")).toEqual(true); }); -Deno.test("elementsToMap: local base file IS skipped when configured as branch-specific (isRemote=false)", async () => { +test("elementsToMap: local base file IS skipped when configured as branch-specific (isRemote=false)", async () => { // When processing local files, if a base file is configured as branch-specific, // it should be skipped because we expect the branch-specific version to be used instead. @@ -130,14 +126,10 @@ Deno.test("elementsToMap: local base file IS skipped when configured as branch-s ); // The base file should NOT be in the map (skipped because branch-specific expected) - assertEquals( - Object.keys(result).includes("f/Shared/Variable/TestVar.variable.yaml"), - false, - "Local base file SHOULD be skipped when isRemote=false and configured as branch-specific" - ); + expect(Object.keys(result).includes("f/Shared/Variable/TestVar.variable.yaml")).toEqual(false); }); -Deno.test("elementsToMap: local branch-specific file is mapped to base path (isRemote=false)", async () => { +test("elementsToMap: local branch-specific file is mapped to base path (isRemote=false)", async () => { // When processing local files with branch-specific naming, they should be mapped to base paths const config: SpecificItemsConfig = { @@ -164,15 +156,8 @@ Deno.test("elementsToMap: local branch-specific file is mapped to base path (isR ); // The branch-specific file should be mapped to the base path - assertEquals( - Object.keys(result).includes("f/Shared/Variable/TestVar.variable.yaml"), - true, - "Branch-specific file should be mapped to base path" - ); - assertEquals( - result["f/Shared/Variable/TestVar.variable.yaml"], - "value: staging-test\nis_secret: false", - ); + expect(Object.keys(result).includes("f/Shared/Variable/TestVar.variable.yaml")).toEqual(true); + expect(result["f/Shared/Variable/TestVar.variable.yaml"]).toEqual("value: staging-test\nis_secret: false"); }); // ============================================================================= @@ -183,7 +168,7 @@ Deno.test("elementsToMap: local branch-specific file is mapped to base path (isR // - Expected: No deletion, the files should match // ============================================================================= -Deno.test("elementsToMap: pull scenario - remote and local maps should align correctly", async () => { +test("elementsToMap: pull scenario - remote and local maps should align correctly", async () => { const config: SpecificItemsConfig = { variables: ["f/Shared/Variable/**"], }; @@ -233,23 +218,15 @@ Deno.test("elementsToMap: pull scenario - remote and local maps should align cor const remoteKeys = Object.keys(remoteMap); const localKeys = Object.keys(localMap); - assertEquals( - remoteKeys.includes("f/Shared/Variable/TestVar.variable.yaml"), - true, - "Remote map should include base path" - ); - assertEquals( - localKeys.includes("f/Shared/Variable/TestVar.variable.yaml"), - true, - "Local map should include base path (mapped from branch-specific)" - ); + expect(remoteKeys.includes("f/Shared/Variable/TestVar.variable.yaml")).toEqual(true); + expect(localKeys.includes("f/Shared/Variable/TestVar.variable.yaml")).toEqual(true); }); // ============================================================================= // NON-CONFIGURED ITEMS: Should work the same regardless of isRemote // ============================================================================= -Deno.test("elementsToMap: non-configured items included regardless of isRemote", async () => { +test("elementsToMap: non-configured items included regardless of isRemote", async () => { const config: SpecificItemsConfig = { variables: ["f/Other/**"], // Only "Other" folder is branch-specific }; @@ -286,23 +263,15 @@ Deno.test("elementsToMap: non-configured items included regardless of isRemote", ); // Both should include the file since it's not in the branch-specific config - assertEquals( - Object.keys(remoteResult).includes("f/Shared/Variable/TestVar.variable.yaml"), - true, - "Non-configured item should be included when isRemote=true" - ); - assertEquals( - Object.keys(localResult).includes("f/Shared/Variable/TestVar.variable.yaml"), - true, - "Non-configured item should be included when isRemote=false" - ); + expect(Object.keys(remoteResult).includes("f/Shared/Variable/TestVar.variable.yaml")).toEqual(true); + expect(Object.keys(localResult).includes("f/Shared/Variable/TestVar.variable.yaml")).toEqual(true); }); // ============================================================================= // RESOURCE TYPE TESTS // ============================================================================= -Deno.test("elementsToMap: remote resource base file not skipped when configured", async () => { +test("elementsToMap: remote resource base file not skipped when configured", async () => { const config: SpecificItemsConfig = { resources: ["f/db/**"], }; @@ -326,18 +295,14 @@ Deno.test("elementsToMap: remote resource base file not skipped when configured" true, // isRemote ); - assertEquals( - Object.keys(result).includes("f/db/connection.resource.yaml"), - true, - "Remote resource base file should NOT be skipped" - ); + expect(Object.keys(result).includes("f/db/connection.resource.yaml")).toEqual(true); }); // ============================================================================= // TRIGGER TYPE TESTS // ============================================================================= -Deno.test("elementsToMap: remote trigger base file not skipped when configured", async () => { +test("elementsToMap: remote trigger base file not skipped when configured", async () => { const config: SpecificItemsConfig = { triggers: ["f/webhooks/**"], }; @@ -361,18 +326,14 @@ Deno.test("elementsToMap: remote trigger base file not skipped when configured", true, // isRemote ); - assertEquals( - Object.keys(result).includes("f/webhooks/handler.http_trigger.yaml"), - true, - "Remote trigger base file should NOT be skipped" - ); + expect(Object.keys(result).includes("f/webhooks/handler.http_trigger.yaml")).toEqual(true); }); // ============================================================================= // SETTINGS TYPE TESTS // ============================================================================= -Deno.test("elementsToMap: remote settings.yaml not skipped when configured", async () => { +test("elementsToMap: remote settings.yaml not skipped when configured", async () => { const config: SpecificItemsConfig = { settings: true, }; @@ -396,18 +357,14 @@ Deno.test("elementsToMap: remote settings.yaml not skipped when configured", asy true, // isRemote ); - assertEquals( - Object.keys(result).includes("settings.yaml"), - true, - "Remote settings.yaml should NOT be skipped" - ); + expect(Object.keys(result).includes("settings.yaml")).toEqual(true); }); // ============================================================================= // FOLDER TYPE TESTS // ============================================================================= -Deno.test("elementsToMap: remote folder meta not skipped when configured", async () => { +test("elementsToMap: remote folder meta not skipped when configured", async () => { const config: SpecificItemsConfig = { folders: ["f/env_*"], }; @@ -431,18 +388,14 @@ Deno.test("elementsToMap: remote folder meta not skipped when configured", async true, // isRemote ); - assertEquals( - Object.keys(result).includes("f/env_staging/folder.meta.yaml"), - true, - "Remote folder meta should NOT be skipped" - ); + expect(Object.keys(result).includes("f/env_staging/folder.meta.yaml")).toEqual(true); }); // ============================================================================= // BACKWARD COMPATIBILITY: isRemote undefined behaves like local (false) // ============================================================================= -Deno.test("elementsToMap: isRemote undefined behaves like local (backward compatible)", async () => { +test("elementsToMap: isRemote undefined behaves like local (backward compatible)", async () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; @@ -468,9 +421,5 @@ Deno.test("elementsToMap: isRemote undefined behaves like local (backward compat ); // Base file should be skipped (same behavior as isRemote=false) - assertEquals( - Object.keys(result).includes("f/test.variable.yaml"), - false, - "isRemote undefined should behave like isRemote=false (skip base file)" - ); + expect(Object.keys(result).includes("f/test.variable.yaml")).toEqual(false); }); diff --git a/cli/test/folder_schedule_push.test.ts b/cli/test/folder_schedule_push.test.ts new file mode 100644 index 0000000000..c84e5b0ca8 --- /dev/null +++ b/cli/test/folder_schedule_push.test.ts @@ -0,0 +1,409 @@ +/** + * Integration tests for folder and schedule CLI commands. + * Tests list and push operations via CLI and direct API. + */ + +import { expect, test, describe } from "bun:test"; +import { writeFile, mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +async function setupWorkspaceProfile(backend: any): Promise { + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "localhost_test", + token: backend.token, + }, + { force: true, configDir: backend.testConfigDir } + ); +} + +// ============================================================================= +// Folder Tests +// ============================================================================= + +describe("folder", () => { + test("list returns seeded folders", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand(["folder"], tempDir); + + expect(result.code).toEqual(0); + // seedTestData creates "test" folder + expect(result.stdout).toContain("test"); + }); + }); + + test("push creates a new folder via sync push", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const folderName = `inttest${uniqueId}`; + + // Create wmill.yaml + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + + // Create folder meta file + await mkdir(join(tempDir, "f", folderName), { recursive: true }); + await writeFile( + join(tempDir, "f", folderName, "folder.meta.yaml"), + `display_name: "Integration Test Folder ${uniqueId}"\nowners:\n - "admin@windmill.dev"\nextra_perms: {}\n`, + "utf-8" + ); + + // Push + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `f/${folderName}/**`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify folder was created via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/get/${folderName}` + ); + expect(apiResp.status).toEqual(200); + const folderData = await apiResp.json(); + expect(folderData.name).toBe(folderName); + }); + }); + + test("push updates an existing folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const folderName = `updfolder${uniqueId}`; + + // Create folder via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: folderName }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Create wmill.yaml and updated folder meta + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + await mkdir(join(tempDir, "f", folderName), { recursive: true }); + await writeFile( + join(tempDir, "f", folderName, "folder.meta.yaml"), + `display_name: "Updated Display Name"\nowners:\n - "u/admin"\nextra_perms:\n u/admin: true\n`, + "utf-8" + ); + + // Push the update + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `f/${folderName}/**`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify the display_name was updated + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/get/${folderName}` + ); + expect(apiResp.status).toEqual(200); + const folderData = await apiResp.json(); + expect(folderData.display_name).toBe("Updated Display Name"); + }); + }); + + test("pull retrieves folder metadata", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const folderName = `pullfolder${uniqueId}`; + + // Create folder via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: folderName }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Create wmill.yaml + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "f/${folderName}/**"\nexcludes: []\nskipVariables: true\nskipResources: true\n`, + "utf-8" + ); + + // Pull + const pullResult = await backend.runCLICommand( + ["sync", "pull", "--yes"], + tempDir + ); + expect(pullResult.code).toEqual(0); + + // Check the folder meta file was created + const content = await readFile( + join(tempDir, "f", folderName, "folder.meta.yaml"), "utf-8" + ); + expect(content).toBeDefined(); + }); + }); +}); + +// ============================================================================= +// Schedule Tests +// ============================================================================= + +describe("schedule", () => { + test("list returns empty table for fresh workspace", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand(["schedule"], tempDir); + + expect(result.code).toEqual(0); + // Table headers should be present + expect(result.stdout).toContain("Path"); + expect(result.stdout).toContain("Schedule"); + }); + }); + + test("push creates a schedule targeting an existing script", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + + // First create a script that the schedule can target + const scriptResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/sched_target_${uniqueId}`, + content: 'export async function main() { return "ok"; }', + language: "bun", + summary: "Schedule target script", + description: "", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + }), + } + ); + expect(scriptResp.status).toBeLessThan(300); + await scriptResp.text(); + + // Create wmill.yaml with includeSchedules + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\nincludeSchedules: true\n`, + "utf-8" + ); + + // Create schedule file + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + await writeFile( + join(tempDir, `f/test/cron_${uniqueId}.schedule.yaml`), + `path: "f/test/cron_${uniqueId}"\nschedule: "0 0 */6 * * *"\nscript_path: "f/test/sched_target_${uniqueId}"\nis_flow: false\nargs: {}\nenabled: false\ntimezone: "UTC"\n`, + "utf-8" + ); + + // Push + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `f/test/cron_${uniqueId}**`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/schedules/get/f/test/cron_${uniqueId}` + ); + expect(apiResp.status).toEqual(200); + const schedData = await apiResp.json(); + expect(schedData.schedule).toBe("0 0 */6 * * *"); + expect(schedData.script_path).toBe(`f/test/sched_target_${uniqueId}`); + expect(schedData.enabled).toBe(false); + }); + }); + + test("push updates a schedule's cron expression", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + + // Create target script via API + const scriptResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/upd_sched_target_${uniqueId}`, + content: 'export async function main() { return "ok"; }', + language: "bun", + summary: "Target", + description: "", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + }), + } + ); + expect(scriptResp.status).toBeLessThan(300); + await scriptResp.text(); + + // Create schedule via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/schedules/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/upd_cron_${uniqueId}`, + schedule: "0 0 * * * *", + script_path: `f/test/upd_sched_target_${uniqueId}`, + is_flow: false, + args: {}, + enabled: false, + timezone: "UTC", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Create wmill.yaml with includeSchedules and updated schedule + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\nincludeSchedules: true\n`, + "utf-8" + ); + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + await writeFile( + join(tempDir, `f/test/upd_cron_${uniqueId}.schedule.yaml`), + `path: "f/test/upd_cron_${uniqueId}"\nschedule: "0 30 2 * * *"\nscript_path: "f/test/upd_sched_target_${uniqueId}"\nis_flow: false\nargs: {}\nenabled: false\ntimezone: "UTC"\n`, + "utf-8" + ); + + // Push the update + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `f/test/upd_cron_${uniqueId}**`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify the schedule was updated + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/schedules/get/f/test/upd_cron_${uniqueId}` + ); + expect(apiResp.status).toEqual(200); + const schedData = await apiResp.json(); + expect(schedData.schedule).toBe("0 30 2 * * *"); + }); + }); + + test("pull retrieves schedules into local files", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + + // Create target script via API + const scriptResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/pull_sched_target_${uniqueId}`, + content: 'export async function main() { return "ok"; }', + language: "bun", + summary: "Target for pull test", + description: "", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + }), + } + ); + expect(scriptResp.status).toBeLessThan(300); + await scriptResp.text(); + + // Create schedule via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/schedules/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/pull_cron_${uniqueId}`, + schedule: "0 15 3 * * 1", + script_path: `f/test/pull_sched_target_${uniqueId}`, + is_flow: false, + args: {}, + enabled: false, + timezone: "UTC", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Create wmill.yaml + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "f/test/pull_cron_${uniqueId}**"\nexcludes: []\nincludeSchedules: true\nskipVariables: true\nskipResources: true\nskipScripts: true\n`, + "utf-8" + ); + + // Pull + const pullResult = await backend.runCLICommand( + ["sync", "pull", "--yes"], + tempDir + ); + expect(pullResult.code).toEqual(0); + + // Check the schedule file was created + const content = await readFile( + join(tempDir, `f/test/pull_cron_${uniqueId}.schedule.yaml`), "utf-8" + ); + expect(content).toContain("0 15 3 * * 1"); + expect(content).toContain(`f/test/pull_sched_target_${uniqueId}`); + }); + }); +}); diff --git a/cli/test/generate_metadata.test.ts b/cli/test/generate_metadata.test.ts new file mode 100644 index 0000000000..f8b1cbf997 --- /dev/null +++ b/cli/test/generate_metadata.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for WASM schema parsing across all supported languages. + * + * Calls `inferSchema` directly — no backend needed, fully local. + * Verifies that each language's WASM parser loads correctly and produces + * the expected JSON schema output. + */ + +import { expect, test, describe } from "bun:test"; +import { inferSchema } from "../src/utils/metadata.ts"; +import type { ScriptLanguage } from "../src/utils/script_common.ts"; + +interface LanguageTestCase { + language: ScriptLanguage; + content: string; + /** Property name to verify in schema.properties */ + expectedParam: string; + /** Expected JSON schema type, or undefined to skip type check */ + expectedType?: string; + /** If set, verify this resource format exists on the named param */ + expectedResourceParam?: { name: string; format: string }; +} + +const languageTestCases: LanguageTestCase[] = [ + { + language: "python3", + content: `def main(x: str):\n return x\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "bun", + content: `export async function main(x: string) {\n return x;\n}\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "deno", + content: `export async function main(x: string) {\n return x;\n}\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "nativets", + content: `export async function main(x: string) {\n return x;\n}\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "go", + content: `package inner\n\nfunc main(x string) (interface{}, error) {\n\treturn x, nil\n}\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "bash", + // Bash parser infers params from variable assignments like x="$1" + content: `x="$1"\necho "$x"\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "powershell", + content: `param([string]$x)\nWrite-Output $x\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "postgresql", + content: `-- $1 name = default :: text\nSELECT $1::TEXT\n`, + expectedParam: "name", + expectedType: "string", + expectedResourceParam: { name: "database", format: "resource-postgresql" }, + }, + { + language: "mysql", + // MySQL parser only auto-detects the database resource param + content: `SELECT 1\n`, + expectedParam: "database", + expectedType: "object", + expectedResourceParam: { name: "database", format: "resource-mysql" }, + }, + { + language: "bigquery", + content: `SELECT 1\n`, + expectedParam: "database", + expectedType: "object", + expectedResourceParam: { name: "database", format: "resource-bigquery" }, + }, + { + language: "snowflake", + content: `SELECT 1\n`, + expectedParam: "database", + expectedType: "object", + expectedResourceParam: { name: "database", format: "resource-snowflake" }, + }, + { + language: "mssql", + content: `SELECT 1\n`, + expectedParam: "database", + expectedType: "object", + expectedResourceParam: { + name: "database", + format: "resource-ms_sql_server", + }, + }, + { + language: "oracledb", + content: `SELECT 1 FROM dual\n`, + expectedParam: "database", + expectedType: "object", + expectedResourceParam: { name: "database", format: "resource-oracledb" }, + }, + { + language: "duckdb", + // DuckDB parser doesn't auto-add a database resource + content: `SELECT 1\n`, + expectedParam: undefined as any, + expectedType: undefined, + }, + { + language: "graphql", + content: `query($name: String) {\n user(name: $name) { id }\n}\n`, + expectedParam: "name", + expectedType: "string", + expectedResourceParam: { name: "api", format: "resource-graphql" }, + }, + { + language: "php", + content: ` Result {\n Ok(x)\n}\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "csharp", + content: `class Script {\n public static string Main(string x) {\n return x;\n }\n}\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "nu", + content: `def main [x: string] {\n print $x\n}\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "ansible", + content: `---\ninventory:\n - resource_type: ansible_inventory\n---\n- name: Test\n hosts: 127.0.0.1\n connection: local\n tasks:\n - name: Echo\n debug:\n msg: "hello"\n`, + // Ansible parser produces "inventory.ini" as param name + expectedParam: "inventory.ini", + expectedType: undefined, + }, + { + language: "java", + content: `public class Main {\n public static String main(String x) {\n return x;\n }\n}\n`, + expectedParam: "x", + expectedType: "string", + }, + { + language: "ruby", + content: `def main(x)\n puts x\nend\n`, + expectedParam: "x", + expectedType: undefined, // Ruby is dynamically typed + }, +]; + +describe("generate-metadata schema parsing", () => { + for (const tc of languageTestCases) { + test(`${tc.language}: WASM parser loads and infers schema`, async () => { + const result = await inferSchema( + tc.language, + tc.content, + {}, + `test.${tc.language}` + ); + + expect(result).toBeDefined(); + expect(result.schema).toBeDefined(); + expect(result.schema.properties).toBeDefined(); + + if (tc.expectedParam) { + expect(result.schema.properties[tc.expectedParam]).toBeDefined(); + + if (tc.expectedType !== undefined) { + expect(result.schema.properties[tc.expectedParam].type).toEqual( + tc.expectedType + ); + } + } + + if (tc.expectedResourceParam) { + const rp = result.schema.properties[tc.expectedResourceParam.name]; + expect(rp).toBeDefined(); + expect(rp.type).toEqual("object"); + expect(rp.format).toEqual(tc.expectedResourceParam.format); + } + }); + } +}); + +const allLanguages: ScriptLanguage[] = [ + "python3", "bun", "deno", "nativets", "go", "bash", "powershell", + "postgresql", "mysql", "bigquery", "snowflake", "mssql", "oracledb", + "duckdb", "graphql", "php", "rust", "csharp", "nu", "ansible", "java", "ruby", +]; + +describe("generate-metadata invalid input handling", () => { + for (const lang of allLanguages) { + test(`${lang}: does not crash on invalid input`, async () => { + const result = await inferSchema( + lang, + "THIS IS INVALID GARBAGE @#$%^&*()", + {}, + `test.${lang}` + ); + + expect(result).toBeDefined(); + expect(result.schema).toBeDefined(); + expect(result.schema.properties).toBeDefined(); + // Should return a valid (possibly empty) schema, not throw + expect(typeof result.schema.properties).toBe("object"); + }); + } +}); diff --git a/cli/test/git_unit.test.ts b/cli/test/git_unit.test.ts new file mode 100644 index 0000000000..bcbd0312fd --- /dev/null +++ b/cli/test/git_unit.test.ts @@ -0,0 +1,73 @@ +/** + * Unit tests for git utility functions. + * Tests pure functions only — no git subprocess calls. + */ + +import { expect, test, describe } from "bun:test"; +import { + getOriginalBranchForWorkspaceForks, + getWorkspaceIdForWorkspaceForkFromBranchName, +} from "../src/utils/git.ts"; + +// ============================================================================= +// getOriginalBranchForWorkspaceForks +// ============================================================================= + +describe("getOriginalBranchForWorkspaceForks", () => { + test("extracts original branch from valid fork branch name", () => { + expect(getOriginalBranchForWorkspaceForks("wm-fork/main/my-workspace")).toBe("main"); + }); + + test("extracts multi-segment original branch", () => { + expect( + getOriginalBranchForWorkspaceForks("wm-fork/feature/cool-thing/my-workspace") + ).toBe("feature/cool-thing"); + }); + + test("returns null for null input", () => { + expect(getOriginalBranchForWorkspaceForks(null)).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(getOriginalBranchForWorkspaceForks("")).toBeNull(); + }); + + test("returns null for non-fork branch", () => { + expect(getOriginalBranchForWorkspaceForks("main")).toBeNull(); + expect(getOriginalBranchForWorkspaceForks("feature/my-feature")).toBeNull(); + }); + + test("returns null for branch that starts with wm-fork but has no slashes after", () => { + expect(getOriginalBranchForWorkspaceForks("wm-fork")).toBeNull(); + }); + + test("returns null when branch segment between slashes is empty", () => { + // "wm-fork//workspace" — start=8, end=8, end - start = 0 + expect(getOriginalBranchForWorkspaceForks("wm-fork//workspace")).toBeNull(); + }); +}); + +// ============================================================================= +// getWorkspaceIdForWorkspaceForkFromBranchName +// ============================================================================= + +describe("getWorkspaceIdForWorkspaceForkFromBranchName", () => { + test("extracts workspace id from valid fork branch name", () => { + expect( + getWorkspaceIdForWorkspaceForkFromBranchName("wm-fork/main/my-workspace") + ).toBe("wm-fork-my-workspace"); + }); + + test("returns null for non-fork branch", () => { + expect(getWorkspaceIdForWorkspaceForkFromBranchName("main")).toBeNull(); + expect( + getWorkspaceIdForWorkspaceForkFromBranchName("feature/my-feature") + ).toBeNull(); + }); + + test("extracts workspace id with multi-segment original branch", () => { + expect( + getWorkspaceIdForWorkspaceForkFromBranchName("wm-fork/feature/cool/ws-id") + ).toBe("wm-fork-ws-id"); + }); +}); diff --git a/cli/test/gitsync_settings_features.test.ts b/cli/test/gitsync_settings_features.test.ts index 49d8c91331..9e142e7115 100644 --- a/cli/test/gitsync_settings_features.test.ts +++ b/cli/test/gitsync_settings_features.test.ts @@ -1,4 +1,5 @@ -import { assertEquals, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; +import { writeFile, readFile } from "node:fs/promises"; import { withTestBackend } from "./test_backend.ts"; import { shouldSkipOnCI } from "./cargo_backend.ts"; import { addWorkspace } from "../workspace.ts"; @@ -9,12 +10,7 @@ import { addWorkspace } from "../workspace.ts"; // These tests require EE features (private, enterprise) and are skipped in CI // ============================================================================= -Deno.test({ - name: "GitSync Settings: default mode writes to top-level", - ignore: shouldSkipOnCI(), // Requires EE features - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test.skipIf(shouldSkipOnCI())("GitSync Settings: default mode writes to top-level", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -44,11 +40,11 @@ Deno.test({ }); // Create initial wmill.yaml with different settings - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - f/** excludes: [] -skipVariables: false`); +skipVariables: false`, "utf-8"); // Pull with default flag const result = await backend.runCLICommand([ @@ -57,25 +53,19 @@ skipVariables: false`); '--default' ], tempDir); - assertEquals(result.code, 0, `Default mode pull should succeed: ${result.stderr}`); + expect(result.code).toEqual(0); // Read updated config - const updatedConfig = await Deno.readTextFile(`${tempDir}/wmill.yaml`); + const updatedConfig = await readFile(`${tempDir}/wmill.yaml`, "utf-8"); // Should update top-level settings, not create overrides - assertStringIncludes(updatedConfig, "includes:\n - f/special/**"); - assertStringIncludes(updatedConfig, "excludes:\n - '*.test.ts'"); - assertStringIncludes(updatedConfig, "extraIncludes:\n - g/**"); + expect(updatedConfig).toContain("includes:\n - f/special/**"); + expect(updatedConfig).toContain("excludes:\n - '*.test.ts'"); + expect(updatedConfig).toContain("extraIncludes:\n - g/**"); }); - } }); -Deno.test({ - name: "GitSync Settings: pull shows correct diff output", - ignore: shouldSkipOnCI(), // Requires EE features - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test.skipIf(shouldSkipOnCI())("GitSync Settings: pull shows correct diff output", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -105,12 +95,12 @@ Deno.test({ }); // Create wmill.yaml with different settings - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - f/** excludes: [] skipVariables: true -skipResources: false`); +skipResources: false`, "utf-8"); // Pull with diff flag const result = await backend.runCLICommand([ @@ -119,22 +109,16 @@ skipResources: false`); '--diff' ], tempDir); - assertEquals(result.code, 0, `Diff mode should succeed: ${result.stderr}`); + expect(result.code).toEqual(0); // Should show differences - assertStringIncludes(result.stdout, "Changes that would be applied locally:"); + expect(result.stdout).toContain("Changes that would be applied locally:"); // Should show the change for skipResources (ignoring ANSI color codes) - assertStringIncludes(result.stdout, "skipResources:"); + expect(result.stdout).toContain("skipResources:"); }); - } }); -Deno.test({ - name: "GitSync Settings: replace mode overwrites existing config", - ignore: shouldSkipOnCI(), // Requires EE features - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test.skipIf(shouldSkipOnCI())("GitSync Settings: replace mode overwrites existing config", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -164,12 +148,12 @@ Deno.test({ }); // Create initial wmill.yaml with settings that should be replaced - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - f/old/** excludes: - "*.old.ts" -skipVariables: true`); +skipVariables: true`, "utf-8"); // Pull with replace flag const result = await backend.runCLICommand([ @@ -178,14 +162,13 @@ skipVariables: true`); '--replace' ], tempDir); - assertEquals(result.code, 0, `Replace mode pull should succeed: ${result.stderr}`); + expect(result.code).toEqual(0); // Read updated config - const updatedConfig = await Deno.readTextFile(`${tempDir}/wmill.yaml`); + const updatedConfig = await readFile(`${tempDir}/wmill.yaml`, "utf-8"); // Should have replaced settings from backend - assertStringIncludes(updatedConfig, "f/replaced/**"); - assertStringIncludes(updatedConfig, "*.backup.ts"); + expect(updatedConfig).toContain("f/replaced/**"); + expect(updatedConfig).toContain("*.backup.ts"); }); - } }); diff --git a/cli/test/include_flags_bypass_filtering.test.ts b/cli/test/include_flags_bypass_filtering.test.ts index 897b414876..aa0fc806f4 100644 --- a/cli/test/include_flags_bypass_filtering.test.ts +++ b/cli/test/include_flags_bypass_filtering.test.ts @@ -1,4 +1,5 @@ -import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; @@ -27,16 +28,12 @@ async function setupWorkspaceProfile(backend: any): Promise { // - test apps, resources, variables via seedTestData() // No additional setup needed! -Deno.test({ - name: "CLI include flags bypass restrictive path filtering", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("CLI include flags bypass restrictive path filtering", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); - + // Create wmill.yaml with very restrictive includes that would exclude special files - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "f/**" excludes: [] @@ -45,24 +42,24 @@ skipResources: true includeUsers: false includeGroups: false includeSettings: false -includeKey: false`); - +includeKey: false`, "utf-8"); + // Test: CLI flags should override config and bypass path filtering const result = await backend.runCLICommand([ - 'sync', 'pull', + 'sync', 'pull', '--include-users', - '--include-groups', + '--include-groups', '--include-settings', '--include-key', - '--dry-run', + '--dry-run', '--json-output' ], tempDir); - - assertEquals(result.code, 0, `Command failed: ${result.stderr}`); - + + expect(result.code).toEqual(0); + const output = parseJsonFromCLIOutput(result.stdout); const changePaths = output.changes.map((c: any) => c.path); - + // Assert that special files are included despite restrictive path filtering // Normalize paths for cross-platform comparison (Windows uses backslashes) const normalizedPaths = changePaths.map((p: string) => p.replace(/\\/g, '/')); @@ -70,119 +67,106 @@ includeKey: false`); const hasGroup = normalizedPaths.some((path: string) => path.includes('groups/test_group.group.yaml')); const hasSettings = changePaths.some((path: string) => path === 'settings.yaml'); const hasEncryptionKey = changePaths.some((path: string) => path === 'encryption_key.yaml'); - - assert(hasUser, `Admin user should be included despite restrictive includes. Found paths: ${normalizedPaths.join(', ')}`); - assert(hasGroup, `'test_group' should be included despite restrictive includes. Found paths: ${normalizedPaths.join(', ')}`); - assert(hasSettings, `Settings should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`); - assert(hasEncryptionKey, `Encryption key should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`); - }); -}}); -Deno.test({ - name: "CLI flags override wmill.yaml include settings", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { + expect(hasUser).toBe(true); + expect(hasGroup).toBe(true); + expect(hasSettings).toBe(true); + expect(hasEncryptionKey).toBe(true); + }); +}); + +test("CLI flags override wmill.yaml include settings", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); - + // Config explicitly disables includes, but CLI should override - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] includeUsers: false -includeGroups: false`); - +includeGroups: false`, "utf-8"); + // CLI flags should override config file settings const result = await backend.runCLICommand([ 'sync', 'pull', '--include-users', - '--include-groups', + '--include-groups', '--dry-run', '--json-output' ], tempDir); - - assertEquals(result.code, 0, `Command failed: ${result.stderr}`); - + + expect(result.code).toEqual(0); + const output = parseJsonFromCLIOutput(result.stdout); const changePaths = output.changes.map((c: any) => c.path); - + // Normalize paths for cross-platform comparison (Windows uses backslashes) const normalizedPaths = changePaths.map((p: string) => p.replace(/\\/g, '/')); const hasUser = normalizedPaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml')); const hasGroup = normalizedPaths.some((path: string) => path.includes('groups/test_group.group.yaml')); - assert(hasUser, `CLI --include-users should override config includeUsers: false. Found paths: ${normalizedPaths.join(', ')}`); - assert(hasGroup, `CLI --include-groups should override config includeGroups: false. Found paths: ${normalizedPaths.join(', ')}`); + expect(hasUser).toBe(true); + expect(hasGroup).toBe(true); }); -}}); +}); -Deno.test({ - name: "Skip flags work correctly with getTypeStrFromPath and lock files", - ignore: true, // TODO: Requires backend app creation to work (currently failing with v2_job_queue constraint) - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Skip flags work correctly with getTypeStrFromPath and lock files", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); - + // Create wmill.yaml with skip flags enabled - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] skipScripts: true skipFlows: false -includeUsers: true`); - +includeUsers: true`, "utf-8"); + const result = await backend.runCLICommand([ 'sync', 'pull', '--dry-run', '--json-output' ], tempDir); - - assertEquals(result.code, 0, `Command failed: ${result.stderr}`); - + + expect(result.code).toEqual(0); + const output = parseJsonFromCLIOutput(result.stdout); const changePaths = output.changes.map((c: any) => c.path); - + // Scripts should be skipped (including lock files) - the backend doesn't create scripts by default - const hasScript = changePaths.some((path: string) => + const hasScript = changePaths.some((path: string) => path.endsWith('.py') || path.endsWith('.ts') || path.endsWith('.go') || path.endsWith('.sh') ); const hasScriptLock = changePaths.some((path: string) => path.endsWith('.script.lock')); - + // Apps should be included (the backend creates test apps) const hasApp = changePaths.some((path: string) => path.includes('test_dashboard') || path.endsWith('.app.yaml')); - + // Users should still be included const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml')); - - assert(!hasScript, `Standalone scripts should be skipped when skipScripts: true. Found paths: ${changePaths.join(', ')}`); - assert(!hasScriptLock, `Script lock files should be skipped when skipScripts: true. Found paths: ${changePaths.join(', ')}`); - assert(hasApp, `Apps should be included (inline scripts are part of apps). Found paths: ${changePaths.join(', ')}`); - assert(hasUser, `Users should be included when includeUsers: true. Found paths: ${changePaths.join(', ')}`); - }); -}}); -Deno.test({ - name: "Mixed include and skip flags work together", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { + expect(hasScript).toBe(false); + expect(hasScriptLock).toBe(false); + expect(hasApp).toBe(true); + expect(hasUser).toBe(true); + }); +}); + +test("Mixed include and skip flags work together", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); - + // Create restrictive config with mixed settings - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "f/**" excludes: [] skipScripts: true includeUsers: false -includeSettings: false`); - +includeSettings: false`, "utf-8"); + const result = await backend.runCLICommand([ 'sync', 'pull', '--skip-scripts', // Reinforce script skipping @@ -190,25 +174,25 @@ includeSettings: false`); '--dry-run', '--json-output' ], tempDir); - - assertEquals(result.code, 0, `Command failed: ${result.stderr}`); - + + expect(result.code).toEqual(0); + const output = parseJsonFromCLIOutput(result.stdout); const changePaths = output.changes.map((c: any) => c.path); - + // Scripts should be excluded - const hasScript = changePaths.some((path: string) => + const hasScript = changePaths.some((path: string) => path.endsWith('.py') || path.endsWith('.ts') || path.endsWith('.go') || path.endsWith('.sh') ); - + // Users should be included (CLI override) const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml')); - + // Settings should be excluded (no CLI override, restrictive path filtering) const hasSettings = changePaths.some((path: string) => path === 'settings.yaml'); - - assert(!hasScript, `Scripts should be excluded due to skipScripts. Found paths: ${changePaths.join(', ')}`); - assert(hasUser, `Users should be included due to CLI --include-users override. Found paths: ${changePaths.join(', ')}`); - assert(!hasSettings, `Settings should be excluded (no CLI override + restrictive paths). Found paths: ${changePaths.join(', ')}`); + + expect(hasScript).toBe(false); + expect(hasUser).toBe(true); + expect(hasSettings).toBe(false); }); -}}); \ No newline at end of file +}); diff --git a/cli/test/init_no_git_sync.test.ts b/cli/test/init_no_git_sync.test.ts index 89551f13a8..a5914cd7ce 100644 --- a/cli/test/init_no_git_sync.test.ts +++ b/cli/test/init_no_git_sync.test.ts @@ -3,7 +3,8 @@ * This creates a unit test that directly tests the logic without needing a backend */ -import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; import { DEFAULT_SYNC_OPTIONS } from "../src/core/conf.ts"; import { withTestBackend } from "./test_backend.ts"; import { shouldSkipOnCI } from "./cargo_backend.ts"; @@ -36,55 +37,50 @@ function createWorkspaceProfileNoRepos(workspace: any): any { return workspaceProfile; } -Deno.test("Init: createWorkspaceProfile includes defaults when no repositories exist", () => { - console.log('🧪 Testing init logic for workspace with no git-sync repositories...'); - +test("Init: createWorkspaceProfile includes defaults when no repositories exist", () => { + console.log('Testing init logic for workspace with no git-sync repositories...'); + const workspaceProfile = createWorkspaceProfileNoRepos(mockWorkspace); - + console.log('Generated workspace profile:', JSON.stringify(workspaceProfile, null, 2)); - + // Verify basic workspace info - assertEquals(workspaceProfile.baseUrl, 'https://app.windmill.dev/'); - assertEquals(workspaceProfile.workspaceId, 'test-workspace'); - + expect(workspaceProfile.baseUrl).toEqual('https://app.windmill.dev/'); + expect(workspaceProfile.workspaceId).toEqual('test-workspace'); + // Verify default sync settings are included - assert(Array.isArray(workspaceProfile.includes), 'Should have includes array'); - assertEquals(workspaceProfile.includes.length, 1, 'Should have one include pattern'); - assertEquals(workspaceProfile.includes[0], 'f/**', 'Should include f/** pattern'); - - assert(Array.isArray(workspaceProfile.excludes), 'Should have excludes array'); - assertEquals(workspaceProfile.excludes.length, 0, 'Should have empty excludes array'); - - assertEquals(workspaceProfile.defaultTs, 'bun', 'Should have bun as default TypeScript runtime'); - - console.log('✅ Workspace profile correctly includes default sync settings when no repositories exist'); + expect(Array.isArray(workspaceProfile.includes)).toBeTruthy(); + expect(workspaceProfile.includes.length).toEqual(1); + expect(workspaceProfile.includes[0]).toEqual('f/**'); + + expect(Array.isArray(workspaceProfile.excludes)).toBeTruthy(); + expect(workspaceProfile.excludes.length).toEqual(0); + + expect(workspaceProfile.defaultTs).toEqual('bun'); + + console.log('Workspace profile correctly includes default sync settings when no repositories exist'); }); -Deno.test("Init: verify DEFAULT_SYNC_OPTIONS has expected values", () => { - console.log('🔍 Verifying DEFAULT_SYNC_OPTIONS contains expected values...'); - +test("Init: verify DEFAULT_SYNC_OPTIONS has expected values", () => { + console.log('Verifying DEFAULT_SYNC_OPTIONS contains expected values...'); + console.log('DEFAULT_SYNC_OPTIONS:', JSON.stringify(DEFAULT_SYNC_OPTIONS, null, 2)); - + // Verify the default options include the expected f/** pattern - assert(Array.isArray(DEFAULT_SYNC_OPTIONS.includes), 'DEFAULT_SYNC_OPTIONS should have includes array'); - assertEquals(DEFAULT_SYNC_OPTIONS.includes.length, 1, 'Should have one include pattern'); - assertEquals(DEFAULT_SYNC_OPTIONS.includes[0], 'f/**', 'Should default to f/** pattern'); - - assert(Array.isArray(DEFAULT_SYNC_OPTIONS.excludes), 'DEFAULT_SYNC_OPTIONS should have excludes array'); - assertEquals(DEFAULT_SYNC_OPTIONS.excludes.length, 0, 'Should have empty excludes array by default'); - - assertEquals(DEFAULT_SYNC_OPTIONS.defaultTs, 'bun', 'Should default to bun runtime'); - - console.log('✅ DEFAULT_SYNC_OPTIONS has expected values'); + expect(Array.isArray(DEFAULT_SYNC_OPTIONS.includes)).toBeTruthy(); + expect(DEFAULT_SYNC_OPTIONS.includes.length).toEqual(1); + expect(DEFAULT_SYNC_OPTIONS.includes[0]).toEqual('f/**'); + + expect(Array.isArray(DEFAULT_SYNC_OPTIONS.excludes)).toBeTruthy(); + expect(DEFAULT_SYNC_OPTIONS.excludes.length).toEqual(0); + + expect(DEFAULT_SYNC_OPTIONS.defaultTs).toEqual('bun'); + + console.log('DEFAULT_SYNC_OPTIONS has expected values'); }); -Deno.test({ - name: "Init: --use-backend flag applies git-sync settings", - ignore: shouldSkipOnCI(), // Requires EE features - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { - await withTestBackend(async (backend, tempDir) => { +test.skipIf(shouldSkipOnCI())("Init: --use-backend flag applies git-sync settings", async () => { + await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { remote: backend.baseUrl, @@ -122,29 +118,23 @@ Deno.test({ '--repository', 'u/test/init_repo' ], tempDir); - assertEquals(result.code, 0, `Init with --use-backend should succeed: ${result.stderr}`); + expect(result.code).toEqual(0); // Verify wmill.yaml was created with backend settings - const wmillYaml = await Deno.readTextFile(`${tempDir}/wmill.yaml`); - + const wmillYaml = await readFile(`${tempDir}/wmill.yaml`, "utf-8"); + // Should have backend-applied settings written to top-level (not overrides) - assertStringIncludes(wmillYaml, "f/backend/**", "Should include backend's include_path"); - assertStringIncludes(wmillYaml, "*.test.ts", "Should include backend's exclude_path"); - assertStringIncludes(wmillYaml, "g/**", "Should include backend's extra_include_path"); - + expect(wmillYaml).toContain("f/backend/**"); + expect(wmillYaml).toContain("*.test.ts"); + expect(wmillYaml).toContain("g/**"); + // Should have empty overrides section for consistency - assertStringIncludes(wmillYaml, "gitBranches: {}"); - }); - } + expect(wmillYaml).toContain("gitBranches: {}"); + }); }); -Deno.test({ - name: "Init: --use-default bypasses backend settings check", - ignore: shouldSkipOnCI(), // Requires EE features - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { - await withTestBackend(async (backend, tempDir) => { +test.skipIf(shouldSkipOnCI())("Init: --use-default bypasses backend settings check", async () => { + await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { remote: backend.baseUrl, @@ -181,18 +171,17 @@ Deno.test({ '--use-default' ], tempDir); - assertEquals(result.code, 0, `Init with --use-default should succeed: ${result.stderr}`); + expect(result.code).toEqual(0); // Verify wmill.yaml was created with default settings only - const wmillYaml = await Deno.readTextFile(`${tempDir}/wmill.yaml`); - + const wmillYaml = await readFile(`${tempDir}/wmill.yaml`, "utf-8"); + // Should have default settings, not backend settings - assertStringIncludes(wmillYaml, "includes:\n - f/**", "Should use default includes"); - assertStringIncludes(wmillYaml, "defaultTs: bun", "Should use default TypeScript runtime"); - + expect(wmillYaml).toContain("includes:\n - f/**"); + expect(wmillYaml).toContain("defaultTs: bun"); + // Should NOT have backend-specific settings - assertEquals(wmillYaml.includes("f/should-be-ignored/**"), false, "Should not include backend settings"); - assertStringIncludes(wmillYaml, "gitBranches: {}", "Should have empty overrides section for consistency"); - }); - } -}); \ No newline at end of file + expect(wmillYaml.includes("f/should-be-ignored/**")).toEqual(false); + expect(wmillYaml).toContain("gitBranches: {}"); + }); +}); diff --git a/cli/test/lint_command.test.ts b/cli/test/lint_command.test.ts index f7871c7034..d0f4226568 100644 --- a/cli/test/lint_command.test.ts +++ b/cli/test/lint_command.test.ts @@ -1,8 +1,7 @@ -import { - assert, - assertEquals, - assertStringIncludes, -} from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import * as path from "node:path"; import { formatValidationError, runLint, @@ -11,30 +10,31 @@ import { async function withTempDir( fn: (tempDir: string) => Promise, ): Promise { - const tempDir = await Deno.makeTempDir({ prefix: "wmill_lint_test_" }); - const originalCwd = Deno.cwd(); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "wmill_lint_test_")); + const originalCwd = process.cwd(); try { - Deno.chdir(tempDir); + process.chdir(tempDir); await fn(tempDir); } finally { - Deno.chdir(originalCwd); - await Deno.remove(tempDir, { recursive: true }); + process.chdir(originalCwd); + await rm(tempDir, { recursive: true }); } } -Deno.test("lint: validates flow, schedule, and trigger yaml files", async () => { +test("lint: validates flow, schedule, and trigger yaml files", async () => { await withTempDir(async (tempDir) => { - await Deno.mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true }); + await writeFile( `${tempDir}/f/my_flow.flow/flow.yaml`, `summary: My flow value: modules: [] `, + "utf-8" ); - await Deno.mkdir(`${tempDir}/f/jobs`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/jobs`, { recursive: true }); + await writeFile( `${tempDir}/f/jobs/daily.schedule.yaml`, `schedule: "0 0 12 * * *" timezone: "UTC" @@ -42,10 +42,11 @@ enabled: true script_path: "f/jobs/daily_sync" is_flow: false `, + "utf-8" ); - await Deno.mkdir(`${tempDir}/f/triggers`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/triggers`, { recursive: true }); + await writeFile( `${tempDir}/f/triggers/hook.http_trigger.yaml`, `script_path: "f/triggers/http_handler" is_flow: false @@ -58,93 +59,97 @@ workspaced_route: false wrap_body: false raw_string: false `, + "utf-8" ); - await Deno.writeTextFile( + await writeFile( `${tempDir}/f/triggers/inbox.email_trigger.yaml`, `script_path: "f/triggers/email_handler" is_flow: false local_part: "inbox" `, + "utf-8" ); const report = await runLint({} as any, tempDir); - assertEquals(report.exitCode, 0); - assertEquals(report.validatedFiles, 4); - assertEquals(report.validFiles, 4); - assertEquals(report.invalidFiles, 0); - assertEquals(report.warnings.length, 0); + expect(report.exitCode).toEqual(0); + expect(report.validatedFiles).toEqual(4); + expect(report.validFiles).toEqual(4); + expect(report.invalidFiles).toEqual(0); + expect(report.warnings.length).toEqual(0); }); }); -Deno.test("lint: returns errors for invalid schedule documents", async () => { +test("lint: returns errors for invalid schedule documents", async () => { await withTempDir(async (tempDir) => { - await Deno.mkdir(`${tempDir}/f/jobs`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/jobs`, { recursive: true }); + await writeFile( `${tempDir}/f/jobs/broken.schedule.yaml`, `timezone: "UTC" enabled: true script_path: "f/jobs/broken" is_flow: false `, + "utf-8" ); const report = await runLint({} as any, tempDir); - assertEquals(report.exitCode, 1); - assertEquals(report.validatedFiles, 1); - assertEquals(report.invalidFiles, 1); - assertEquals(report.issues[0].path, "f/jobs/broken.schedule.yaml"); - assert( + expect(report.exitCode).toEqual(1); + expect(report.validatedFiles).toEqual(1); + expect(report.invalidFiles).toEqual(1); + expect(report.issues[0].path).toEqual("f/jobs/broken.schedule.yaml"); + expect( report.issues[0].errors.some((message) => message.includes("missing required property 'schedule'") ), - ); + ).toBeTruthy(); }); }); -Deno.test("lint: warns and skips unsupported native trigger schemas", async () => { +test("lint: warns and skips unsupported native trigger schemas", async () => { await withTempDir(async (tempDir) => { - await Deno.mkdir(`${tempDir}/f/triggers`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/triggers`, { recursive: true }); + await writeFile( `${tempDir}/f/triggers/webhook.script.123.nextcloud_native_trigger.yaml`, `path: "f/triggers/native" `, + "utf-8" ); const report = await runLint({} as any, tempDir); - assertEquals(report.exitCode, 0); - assertEquals(report.validatedFiles, 0); - assertEquals(report.skippedUnsupportedFiles, 1); - assertEquals(report.warnings.length, 1); - assertStringIncludes( + expect(report.exitCode).toEqual(0); + expect(report.validatedFiles).toEqual(0); + expect(report.skippedUnsupportedFiles).toEqual(1); + expect(report.warnings.length).toEqual(1); + expect( report.warnings[0].message, - "Unsupported trigger schema", - ); + ).toContain("Unsupported trigger schema"); const failOnWarnReport = await runLint( { failOnWarn: true } as any, tempDir, ); - assertEquals(failOnWarnReport.exitCode, 1); + expect(failOnWarnReport.exitCode).toEqual(1); }); }); -Deno.test("lint: uses wmill.yaml include filters for file discovery", async () => { +test("lint: uses wmill.yaml include filters for file discovery", async () => { await withTempDir(async (tempDir) => { - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "f/allowed/**" excludes: [] `, + "utf-8" ); - await Deno.mkdir(`${tempDir}/f/allowed`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/allowed`, { recursive: true }); + await writeFile( `${tempDir}/f/allowed/ok.schedule.yaml`, `schedule: "0 0 12 * * *" timezone: "UTC" @@ -152,113 +157,109 @@ enabled: true script_path: "f/jobs/ok" is_flow: false `, + "utf-8" ); - await Deno.mkdir(`${tempDir}/f/blocked`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/blocked`, { recursive: true }); + await writeFile( `${tempDir}/f/blocked/bad.schedule.yaml`, `timezone: "UTC" enabled: true script_path: "f/jobs/bad" is_flow: false `, + "utf-8" ); const report = await runLint({} as any, tempDir); - assertEquals(report.exitCode, 0); - assertEquals(report.validatedFiles, 1); - assertEquals(report.validFiles, 1); - assertEquals(report.invalidFiles, 0); - assertEquals(report.issues.length, 0); + expect(report.exitCode).toEqual(0); + expect(report.validatedFiles).toEqual(1); + expect(report.validFiles).toEqual(1); + expect(report.invalidFiles).toEqual(0); + expect(report.issues.length).toEqual(0); }); }); // --- formatValidationError unit tests --- -Deno.test("formatValidationError: required keyword", () => { - assertEquals( +test("formatValidationError: required keyword", () => { + expect( formatValidationError({ instancePath: "/value", keyword: "required", message: "must have required property 'modules'", params: { missingProperty: "modules" }, }), - "/value missing required property 'modules'", - ); + ).toEqual("/value missing required property 'modules'"); }); -Deno.test("formatValidationError: additionalProperties keyword", () => { - assertEquals( +test("formatValidationError: additionalProperties keyword", () => { + expect( formatValidationError({ instancePath: "/value", keyword: "additionalProperties", message: "must NOT have additional properties", params: { additionalProperty: "typo_field" }, }), - "/value has unknown property 'typo_field'", - ); + ).toEqual("/value has unknown property 'typo_field'"); }); -Deno.test("formatValidationError: enum keyword filters null values", () => { - assertEquals( +test("formatValidationError: enum keyword filters null values", () => { + expect( formatValidationError({ instancePath: "/http_method", keyword: "enum", message: "must be equal to one of the allowed values", params: { allowedValues: [null, "get", "post", "put"] }, }), - "/http_method must be one of: 'get', 'post', 'put'", - ); + ).toEqual("/http_method must be one of: 'get', 'post', 'put'"); }); -Deno.test("formatValidationError: falls back to message", () => { - assertEquals( +test("formatValidationError: falls back to message", () => { + expect( formatValidationError({ instancePath: "/timeout", keyword: "type", message: "must be integer", }), - "/timeout must be integer", - ); + ).toEqual("/timeout must be integer"); }); -Deno.test("formatValidationError: uses / for empty instancePath", () => { - assertEquals( +test("formatValidationError: uses / for empty instancePath", () => { + expect( formatValidationError({ instancePath: "", keyword: "required", message: "must have required property 'summary'", params: { missingProperty: "summary" }, }), - "/ missing required property 'summary'", - ); + ).toEqual("/ missing required property 'summary'"); }); -Deno.test("formatValidationError: generic fallback when no message", () => { - assertEquals( +test("formatValidationError: generic fallback when no message", () => { + expect( formatValidationError({ instancePath: "/field", keyword: "custom" }), - "/field validation error", - ); + ).toEqual("/field validation error"); }); // --- runLint integration tests --- -Deno.test("lint: throws for non-existent directory", async () => { +test("lint: throws for non-existent directory", async () => { let threw = false; try { await runLint({} as any, "/tmp/wmill_lint_nonexistent_" + Date.now()); } catch (e) { threw = true; - assertStringIncludes((e as Error).message, "Directory not found"); + expect((e as Error).message).toContain("Directory not found"); } - assert(threw, "Expected runLint to throw for non-existent directory"); + expect(threw).toBeTruthy(); }); -Deno.test("lint: json-shaped report contains all fields", async () => { +test("lint: json-shaped report contains all fields", async () => { await withTempDir(async (tempDir) => { - await Deno.mkdir(`${tempDir}/f/jobs`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/jobs`, { recursive: true }); + await writeFile( `${tempDir}/f/jobs/ok.schedule.yaml`, `schedule: "0 0 * * *" timezone: "UTC" @@ -266,33 +267,34 @@ enabled: true script_path: "f/jobs/ok" is_flow: false `, + "utf-8" ); const report = await runLint({ json: true } as any, tempDir); // Verify the report object has the shape expected by --json output - assertEquals(typeof report.scannedFiles, "number"); - assertEquals(typeof report.validatedFiles, "number"); - assertEquals(typeof report.validFiles, "number"); - assertEquals(typeof report.invalidFiles, "number"); - assertEquals(typeof report.skippedUnsupportedFiles, "number"); - assert(Array.isArray(report.warnings)); - assert(Array.isArray(report.issues)); - assertEquals(typeof report.success, "boolean"); - assertEquals(typeof report.exitCode, "number"); + expect(typeof report.scannedFiles).toEqual("number"); + expect(typeof report.validatedFiles).toEqual("number"); + expect(typeof report.validFiles).toEqual("number"); + expect(typeof report.invalidFiles).toEqual("number"); + expect(typeof report.skippedUnsupportedFiles).toEqual("number"); + expect(Array.isArray(report.warnings)).toBeTruthy(); + expect(Array.isArray(report.issues)).toBeTruthy(); + expect(typeof report.success).toEqual("boolean"); + expect(typeof report.exitCode).toEqual("number"); // JSON.stringify should round-trip cleanly const json = JSON.parse(JSON.stringify(report)); - assertEquals(json.success, true); - assertEquals(json.exitCode, 0); + expect(json.success).toEqual(true); + expect(json.exitCode).toEqual(0); }); }); -Deno.test("lint: --fail-on-warn with mixed valid and warning files", async () => { +test("lint: --fail-on-warn with mixed valid and warning files", async () => { await withTempDir(async (tempDir) => { // A valid schedule - await Deno.mkdir(`${tempDir}/f/jobs`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/jobs`, { recursive: true }); + await writeFile( `${tempDir}/f/jobs/ok.schedule.yaml`, `schedule: "0 0 * * *" timezone: "UTC" @@ -300,36 +302,38 @@ enabled: true script_path: "f/jobs/ok" is_flow: false `, + "utf-8" ); // An unsupported native trigger that produces a warning - await Deno.mkdir(`${tempDir}/f/triggers`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/triggers`, { recursive: true }); + await writeFile( `${tempDir}/f/triggers/webhook.script.123.nextcloud_native_trigger.yaml`, `path: "f/triggers/native" `, + "utf-8" ); // Without --fail-on-warn: passes const normalReport = await runLint({} as any, tempDir); - assertEquals(normalReport.exitCode, 0); - assertEquals(normalReport.success, true); - assertEquals(normalReport.validFiles, 1); - assertEquals(normalReport.warnings.length, 1); + expect(normalReport.exitCode).toEqual(0); + expect(normalReport.success).toEqual(true); + expect(normalReport.validFiles).toEqual(1); + expect(normalReport.warnings.length).toEqual(1); // With --fail-on-warn: fails due to warning const strictReport = await runLint({ failOnWarn: true } as any, tempDir); - assertEquals(strictReport.exitCode, 1); - assertEquals(strictReport.success, false); - assertEquals(strictReport.validFiles, 1); - assertEquals(strictReport.warnings.length, 1); + expect(strictReport.exitCode).toEqual(1); + expect(strictReport.success).toEqual(false); + expect(strictReport.validFiles).toEqual(1); + expect(strictReport.warnings.length).toEqual(1); }); }); -Deno.test("lint: reports enum errors with allowed values for invalid trigger", async () => { +test("lint: reports enum errors with allowed values for invalid trigger", async () => { await withTempDir(async (tempDir) => { - await Deno.mkdir(`${tempDir}/f/triggers`, { recursive: true }); - await Deno.writeTextFile( + await mkdir(`${tempDir}/f/triggers`, { recursive: true }); + await writeFile( `${tempDir}/f/triggers/hook.http_trigger.yaml`, `script_path: "f/triggers/http_handler" is_flow: false @@ -341,14 +345,14 @@ workspaced_route: false wrap_body: false raw_string: false `, + "utf-8" ); const report = await runLint({} as any, tempDir); - assertEquals(report.invalidFiles, 1); - assert( + expect(report.invalidFiles).toEqual(1); + expect( report.issues[0].errors.some((msg) => msg.includes("must be one of:")), - `Expected 'must be one of' error but got: ${report.issues[0].errors}`, - ); + ).toBeTruthy(); }); }); diff --git a/cli/test/lint_locks.test.ts b/cli/test/lint_locks.test.ts new file mode 100644 index 0000000000..6ec4363e82 --- /dev/null +++ b/cli/test/lint_locks.test.ts @@ -0,0 +1,331 @@ +import { expect, test, describe } from "bun:test"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import * as path from "node:path"; +import { checkMissingLocks, runLint } from "../src/commands/lint/lint.ts"; + +async function withTempDir( + fn: (tempDir: string) => Promise, +): Promise { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "wmill_lint_locks_")); + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + await fn(tempDir); + } finally { + process.chdir(originalCwd); + await rm(tempDir, { recursive: true }); + } +} + +// Helper to create a script with metadata and optional lock +async function createScript( + tempDir: string, + scriptBase: string, + ext: string, + opts: { lock?: string; lockFileContent?: string } = {}, +) { + const dir = path.dirname(path.join(tempDir, scriptBase)); + await mkdir(dir, { recursive: true }); + + // Script content file + await writeFile(path.join(tempDir, scriptBase + ext), "# placeholder", "utf-8"); + + // Metadata YAML + const lockLine = opts.lock !== undefined ? `lock: "${opts.lock}"` : "lock: ''"; + await writeFile( + path.join(tempDir, scriptBase + ".script.yaml"), + `summary: test\n${lockLine}\nschema:\n properties: {}\n`, + "utf-8", + ); + + // Lock file (if inline reference) + if (opts.lockFileContent !== undefined) { + await writeFile( + path.join(tempDir, scriptBase + ".script.lock"), + opts.lockFileContent, + "utf-8", + ); + } +} + +// --- checkMissingLocks unit tests --- + +describe("checkMissingLocks", () => { + test("reports missing lock for python script", async () => { + await withTempDir(async (tempDir) => { + await createScript(tempDir, "f/my_script", ".py", { lock: "" }); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(1); + expect(issues[0].target).toBe("script"); + expect(issues[0].errors[0]).toContain("Missing lock"); + expect(issues[0].errors[0]).toContain("python3"); + }); + }); + + test("no issues for python script with inline lock file", async () => { + await withTempDir(async (tempDir) => { + await createScript(tempDir, "f/my_script", ".py", { + lock: "!inline f/my_script.script.lock", + lockFileContent: "some-dep==1.0.0", + }); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(0); + }); + }); + + test("reports missing lock when inline lock file is empty", async () => { + await withTempDir(async (tempDir) => { + await createScript(tempDir, "f/my_script", ".py", { + lock: "!inline f/my_script.script.lock", + lockFileContent: "", + }); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(1); + expect(issues[0].errors[0]).toContain("Missing lock"); + }); + }); + + test("no issues for bash script without lock (lock not required)", async () => { + await withTempDir(async (tempDir) => { + await createScript(tempDir, "f/my_bash", ".sh", { lock: "" }); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(0); + }); + }); + + test("reports missing lock for bun script", async () => { + await withTempDir(async (tempDir) => { + await createScript(tempDir, "f/my_ts", ".ts", { lock: "" }); + + const issues = await checkMissingLocks( + { defaultTs: "bun" } as any, + tempDir, + ); + + expect(issues.length).toBe(1); + expect(issues[0].errors[0]).toContain("Missing lock"); + expect(issues[0].errors[0]).toContain("bun"); + }); + }); + + test("reports missing lock for flow inline rawscript", async () => { + await withTempDir(async (tempDir) => { + await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true }); + await writeFile( + `${tempDir}/f/my_flow.flow/flow.yaml`, + `summary: test flow +value: + modules: + - id: step1 + value: + type: rawscript + language: python3 + content: "print('hello')" +`, + "utf-8", + ); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(1); + expect(issues[0].target).toBe("flow_inline_script"); + expect(issues[0].errors[0]).toContain("step1"); + expect(issues[0].errors[0]).toContain("python3"); + }); + }); + + test("no issues for flow inline rawscript with lock", async () => { + await withTempDir(async (tempDir) => { + await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true }); + await writeFile( + `${tempDir}/f/my_flow.flow/flow.yaml`, + `summary: test flow +value: + modules: + - id: step1 + value: + type: rawscript + language: python3 + content: "print('hello')" + lock: "some-dep==1.0.0" +`, + "utf-8", + ); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(0); + }); + }); + + test("reports missing lock for nested flow modules (forloopflow)", async () => { + await withTempDir(async (tempDir) => { + await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true }); + await writeFile( + `${tempDir}/f/my_flow.flow/flow.yaml`, + `summary: test flow +value: + modules: + - id: loop1 + value: + type: forloopflow + modules: + - id: inner_step + value: + type: rawscript + language: python3 + content: "print('inner')" +`, + "utf-8", + ); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(1); + expect(issues[0].errors[0]).toContain("inner_step"); + }); + }); + + test("reports missing lock for app inline script", async () => { + await withTempDir(async (tempDir) => { + await mkdir(`${tempDir}/f/my_app.app`, { recursive: true }); + await writeFile( + `${tempDir}/f/my_app.app/app.yaml`, + `value: + grid: + - data: + inlineScript: + language: python3 + content: "x = 1" +`, + "utf-8", + ); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(1); + expect(issues[0].target).toBe("app_inline_script"); + expect(issues[0].errors[0]).toContain("python3"); + }); + }); + + test("no issues for app inline script with lock", async () => { + await withTempDir(async (tempDir) => { + await mkdir(`${tempDir}/f/my_app.app`, { recursive: true }); + await writeFile( + `${tempDir}/f/my_app.app/app.yaml`, + `value: + grid: + - data: + inlineScript: + language: python3 + content: "x = 1" + lock: "some-dep==1.0.0" +`, + "utf-8", + ); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(0); + }); + }); + + test("no issues for flow with non-lock-requiring language (bash)", async () => { + await withTempDir(async (tempDir) => { + await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true }); + await writeFile( + `${tempDir}/f/my_flow.flow/flow.yaml`, + `summary: test flow +value: + modules: + - id: step1 + value: + type: rawscript + language: bash + content: "echo hello" +`, + "utf-8", + ); + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(0); + }); + }); + + test("skips raw app without backend folder", async () => { + await withTempDir(async (tempDir) => { + await mkdir(`${tempDir}/f/my_rawapp.raw_app`, { recursive: true }); + await writeFile( + `${tempDir}/f/my_rawapp.raw_app/raw_app.yaml`, + `summary: test raw app +`, + "utf-8", + ); + // No backend/ folder created + + const issues = await checkMissingLocks({} as any, tempDir); + + expect(issues.length).toBe(0); + }); + }); +}); + +// --- runLint --locks-required integration tests --- + +describe("runLint with --locks-required", () => { + test("reports lock issues when locksRequired is true", async () => { + await withTempDir(async (tempDir) => { + await createScript(tempDir, "f/my_script", ".py", { lock: "" }); + + const report = await runLint({ locksRequired: true } as any, tempDir); + + expect(report.success).toBe(false); + expect(report.exitCode).toBe(1); + expect(report.issues.length).toBeGreaterThanOrEqual(1); + expect( + report.issues.some((i) => i.errors.some((e) => e.includes("Missing lock"))), + ).toBe(true); + }); + }); + + test("does not check locks when locksRequired is false", async () => { + await withTempDir(async (tempDir) => { + await createScript(tempDir, "f/my_script", ".py", { lock: "" }); + + const report = await runLint({} as any, tempDir); + + // Without locksRequired, no lock issues should appear + expect( + report.issues.some((i) => i.errors.some((e) => e.includes("Missing lock"))), + ).toBe(false); + }); + }); + + test("passes when locksRequired is true and locks exist", async () => { + await withTempDir(async (tempDir) => { + await createScript(tempDir, "f/my_script", ".py", { + lock: "!inline f/my_script.script.lock", + lockFileContent: "some-dep==1.0.0", + }); + + const report = await runLint({ locksRequired: true } as any, tempDir); + + expect(report.success).toBe(true); + expect(report.exitCode).toBe(0); + expect( + report.issues.some((i) => i.errors.some((e) => e.includes("Missing lock"))), + ).toBe(false); + }); + }); +}); diff --git a/cli/test/list_get_new_commands.test.ts b/cli/test/list_get_new_commands.test.ts new file mode 100644 index 0000000000..8df67cab9b --- /dev/null +++ b/cli/test/list_get_new_commands.test.ts @@ -0,0 +1,639 @@ +/** + * Integration tests for the new list/get/new CLI commands. + * + * Tests: + * - `list --json` for all item types + * - `get ` and `get --json` for all item types + * - `new` (bootstrap) for script, flow, resource, resource-type, variable, schedule, folder, trigger + * - `bootstrap` alias for script and flow + */ + +import { expect, test, describe } from "bun:test"; +import { writeFile, mkdir, stat, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withTestBackend, type TestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +async function setupWorkspaceProfile(backend: TestBackend): Promise { + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "localhost_test", + token: backend.token!, + }, + { force: true, configDir: backend.testConfigDir } + ); +} + +async function createRemoteScript( + backend: TestBackend, + scriptPath: string, + content: string = 'export async function main() { return "hello"; }' +): Promise { + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content, + language: "bun", + summary: "Test script summary", + description: "Test script description", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + }), + } + ); + expect(resp.status).toBeLessThan(300); + await resp.text(); +} + +// ============================================================================= +// list --json +// ============================================================================= + +describe("list --json flag", () => { + test("script list --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const scriptPath = `f/test/list_json_script_${uniqueId}`; + await createRemoteScript(backend, scriptPath); + + const result = await backend.runCLICommand( + ["script", "list", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.some((s: any) => s.path === scriptPath)).toBe(true); + }); + }); + + test("flow list --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["flow", "list", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }); + }); + + test("resource list --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["resource", "list", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + // seedTestData creates f/test/my_resource + expect(parsed.some((r: any) => r.path === "f/test/my_resource")).toBe( + true + ); + }); + }); + + test("variable list --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["variable", "list", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }); + }); + + test("folder list --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["folder", "list", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.some((f: any) => f.name === "test")).toBe(true); + }); + }); + + test("schedule list --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["schedule", "list", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }); + }); + + test("resource-type list --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["resource-type", "list", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }); + }); + + test("trigger list --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["trigger", "list", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }); + }); + + test("app list --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["app", "list", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }); + }); + + test("default action with --json works (e.g. wmill script --json)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["script", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }); + }); +}); + +// ============================================================================= +// get and get --json +// ============================================================================= + +describe("get command", () => { + test("script get pretty-prints details", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const scriptPath = `f/test/get_script_${uniqueId}`; + await createRemoteScript(backend, scriptPath); + + const result = await backend.runCLICommand( + ["script", "get", scriptPath], + tempDir + ); + + expect(result.code).toEqual(0); + const output = result.stdout; + expect(output).toContain("Path:"); + expect(output).toContain(scriptPath); + expect(output).toContain("Summary:"); + expect(output).toContain("Language:"); + expect(output).toContain("bun"); + }); + }); + + test("script get --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const scriptPath = `f/test/get_json_script_${uniqueId}`; + await createRemoteScript(backend, scriptPath); + + const result = await backend.runCLICommand( + ["script", "get", scriptPath, "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.path).toBe(scriptPath); + expect(parsed.language).toBe("bun"); + expect(parsed.summary).toBe("Test script summary"); + }); + }); + + test("resource get --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["resource", "get", "f/test/my_resource", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.path).toBe("f/test/my_resource"); + expect(parsed.resource_type).toBe("any"); + }); + }); + + test("resource get pretty-prints details", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["resource", "get", "f/test/my_resource"], + tempDir + ); + + expect(result.code).toEqual(0); + const output = result.stdout; + expect(output).toContain("Path:"); + expect(output).toContain("f/test/my_resource"); + expect(output).toContain("Resource Type:"); + }); + }); + + test("variable get --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["variable", "get", "f/test/my_variable", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.path).toBe("f/test/my_variable"); + }); + }); + + test("folder get --json outputs valid JSON", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["folder", "get", "test", "--json"], + tempDir + ); + + expect(result.code).toEqual(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.name).toBe("test"); + }); + }); + + test("folder get pretty-prints details", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["folder", "get", "test"], + tempDir + ); + + expect(result.code).toEqual(0); + const output = result.stdout; + expect(output).toContain("Name:"); + expect(output).toContain("test"); + }); + }); +}); + +// ============================================================================= +// new command +// ============================================================================= + +describe("new command", () => { + test("script new creates files (same as bootstrap)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["script", "new", "f/test/new_cmd_script", "bun", "--summary", "Test new"], + tempDir + ); + + expect(result.code).toEqual(0); + + const codeStat = await stat(join(tempDir, "f/test/new_cmd_script.ts")); + expect(codeStat.isFile()).toBe(true); + + const metaStat = await stat( + join(tempDir, "f/test/new_cmd_script.script.yaml") + ); + expect(metaStat.isFile()).toBe(true); + + const metaContent = await readFile( + join(tempDir, "f/test/new_cmd_script.script.yaml"), + "utf-8" + ); + expect(metaContent).toContain("Test new"); + }); + }); + + test("script bootstrap still works as alias", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["script", "bootstrap", "f/test/alias_script", "bun"], + tempDir + ); + + expect(result.code).toEqual(0); + + const codeStat = await stat(join(tempDir, "f/test/alias_script.ts")); + expect(codeStat.isFile()).toBe(true); + }); + }); + + test("flow new creates flow directory and flow.yaml", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["flow", "new", "f/test/new_flow", "--summary", "My flow"], + tempDir + ); + + expect(result.code).toEqual(0); + + const flowYamlStat = await stat( + join(tempDir, "f/test/new_flow.flow/flow.yaml") + ); + expect(flowYamlStat.isFile()).toBe(true); + + const flowContent = await readFile( + join(tempDir, "f/test/new_flow.flow/flow.yaml"), + "utf-8" + ); + expect(flowContent).toContain("My flow"); + }); + }); + + test("flow bootstrap still works as alias", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["flow", "bootstrap", "f/test/alias_flow"], + tempDir + ); + + expect(result.code).toEqual(0); + + const flowYamlStat = await stat( + join(tempDir, "f/test/alias_flow.flow/flow.yaml") + ); + expect(flowYamlStat.isFile()).toBe(true); + }); + }); + + test("resource new creates resource yaml template", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["resource", "new", "f/test/new_resource"], + tempDir + ); + + expect(result.code).toEqual(0); + + const filePath = join(tempDir, "f/test/new_resource.resource.yaml"); + const fileStat = await stat(filePath); + expect(fileStat.isFile()).toBe(true); + + const content = await readFile(filePath, "utf-8"); + expect(content).toContain("resource_type"); + expect(content).toContain("value"); + }); + }); + + test("resource-type new creates resource-type yaml template", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["resource-type", "new", "my_custom_type"], + tempDir + ); + + expect(result.code).toEqual(0); + + const filePath = join(tempDir, "my_custom_type.resource-type.yaml"); + const fileStat = await stat(filePath); + expect(fileStat.isFile()).toBe(true); + + const content = await readFile(filePath, "utf-8"); + expect(content).toContain("schema"); + expect(content).toContain("description"); + }); + }); + + test("variable new creates variable yaml template", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["variable", "new", "f/test/new_var"], + tempDir + ); + + expect(result.code).toEqual(0); + + const filePath = join(tempDir, "f/test/new_var.variable.yaml"); + const fileStat = await stat(filePath); + expect(fileStat.isFile()).toBe(true); + + const content = await readFile(filePath, "utf-8"); + expect(content).toContain("is_secret"); + expect(content).toContain("value"); + }); + }); + + test("schedule new creates schedule yaml template", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["schedule", "new", "f/test/new_sched"], + tempDir + ); + + expect(result.code).toEqual(0); + + const filePath = join(tempDir, "f/test/new_sched.schedule.yaml"); + const fileStat = await stat(filePath); + expect(fileStat.isFile()).toBe(true); + + const content = await readFile(filePath, "utf-8"); + expect(content).toContain("schedule"); + expect(content).toContain("script_path"); + expect(content).toContain("timezone"); + }); + }); + + test("folder new creates folder.meta.yaml in f//", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["folder", "new", "new_folder"], + tempDir + ); + + expect(result.code).toEqual(0); + + const filePath = join(tempDir, "f/new_folder/folder.meta.yaml"); + const fileStat = await stat(filePath); + expect(fileStat.isFile()).toBe(true); + + const content = await readFile(filePath, "utf-8"); + expect(content).toContain("owners"); + expect(content).toContain("extra_perms"); + }); + }); + + test("trigger new --kind http creates http trigger yaml template", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["trigger", "new", "f/test/new_trigger", "--kind", "http"], + tempDir + ); + + expect(result.code).toEqual(0); + + const filePath = join( + tempDir, + "f/test/new_trigger.http_trigger.yaml" + ); + const fileStat = await stat(filePath); + expect(fileStat.isFile()).toBe(true); + + const content = await readFile(filePath, "utf-8"); + expect(content).toContain("script_path"); + expect(content).toContain("route_path"); + }); + }); + + test("trigger new without --kind fails with error", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["trigger", "new", "f/test/fail_trigger"], + tempDir + ); + + expect(result.code).not.toEqual(0); + }); + }); + + test("trigger new --kind kafka creates kafka trigger yaml template", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["trigger", "new", "f/test/kafka_trigger", "--kind", "kafka"], + tempDir + ); + + expect(result.code).toEqual(0); + + const filePath = join( + tempDir, + "f/test/kafka_trigger.kafka_trigger.yaml" + ); + const fileStat = await stat(filePath); + expect(fileStat.isFile()).toBe(true); + + const content = await readFile(filePath, "utf-8"); + expect(content).toContain("kafka_resource_path"); + expect(content).toContain("topics"); + }); + }); +}); diff --git a/cli/test/local_encryption_unit.test.ts b/cli/test/local_encryption_unit.test.ts new file mode 100644 index 0000000000..a83d230e43 --- /dev/null +++ b/cli/test/local_encryption_unit.test.ts @@ -0,0 +1,94 @@ +/** + * Unit tests for local_encryption.ts encrypt/decrypt functions. + * Tests round-trip encryption, different key lengths, and error handling. + */ + +import { expect, test, describe } from "bun:test"; +import { encrypt, decrypt } from "../src/utils/local_encryption.ts"; + +// ============================================================================= +// encrypt / decrypt round-trip +// ============================================================================= + +describe("encrypt and decrypt", () => { + test("round-trip with a simple message", async () => { + const key = "my-secret-key"; + const message = "Hello, World!"; + const encrypted = await encrypt(message, key); + const decrypted = await decrypt(encrypted, key); + expect(decrypted).toBe(message); + }); + + test("round-trip with empty string", async () => { + const key = "key"; + const encrypted = await encrypt("", key); + const decrypted = await decrypt(encrypted, key); + expect(decrypted).toBe(""); + }); + + test("round-trip with long message", async () => { + const key = "test-key-123"; + const message = "A".repeat(10000); + const encrypted = await encrypt(message, key); + const decrypted = await decrypt(encrypted, key); + expect(decrypted).toBe(message); + }); + + test("round-trip with unicode characters", async () => { + const key = "unicode-key"; + const message = "Hello 🌍 世界 مرحبا"; + const encrypted = await encrypt(message, key); + const decrypted = await decrypt(encrypted, key); + expect(decrypted).toBe(message); + }); + + test("round-trip with very short key", async () => { + const key = "k"; + const message = "short key test"; + const encrypted = await encrypt(message, key); + const decrypted = await decrypt(encrypted, key); + expect(decrypted).toBe(message); + }); + + test("round-trip with very long key", async () => { + const key = "x".repeat(1000); + const message = "long key test"; + const encrypted = await encrypt(message, key); + const decrypted = await decrypt(encrypted, key); + expect(decrypted).toBe(message); + }); + + test("encrypted output is base64", async () => { + const encrypted = await encrypt("test", "key"); + // base64 characters: A-Z, a-z, 0-9, +, /, = + expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/); + }); + + test("same message encrypted twice produces different ciphertexts (random IV)", async () => { + const key = "determinism-test"; + const message = "same input"; + const enc1 = await encrypt(message, key); + const enc2 = await encrypt(message, key); + expect(enc1).not.toBe(enc2); + }); + + test("decrypting with wrong key throws", async () => { + const encrypted = await encrypt("secret", "correct-key"); + await expect(decrypt(encrypted, "wrong-key")).rejects.toThrow(); + }); + + test("decrypting corrupted ciphertext throws", async () => { + await expect(decrypt("not-valid-ciphertext-at-all!!", "key")).rejects.toThrow(); + }); + + test("round-trip with JSON content", async () => { + const key = "json-key"; + const message = JSON.stringify({ license_key: "abc-123", secret: true }); + const encrypted = await encrypt(message, key); + const decrypted = await decrypt(encrypted, key); + expect(JSON.parse(decrypted)).toEqual({ + license_key: "abc-123", + secret: true, + }); + }); +}); diff --git a/cli/test/lock_cache.test.ts b/cli/test/lock_cache.test.ts index c217749c6c..9f30b67d90 100644 --- a/cli/test/lock_cache.test.ts +++ b/cli/test/lock_cache.test.ts @@ -10,11 +10,8 @@ * vs new logic (caches by key, skips duplicate fetches). */ -import { - assertEquals, - assertNotEquals, -} from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"; +import { expect, test } from "bun:test"; +import { Buffer } from "node:buffer"; // --------------------------------------------------------------------------- // Mirrors extractWorkspaceDepsAnnotation + computeLockCacheKey from @@ -113,7 +110,7 @@ async function computeLockCacheKey( .join(";"); const content = `${language}|${annotationStr}|${depsStr}`; const buf = new TextEncoder().encode(content); - return encodeHex(await crypto.subtle.digest("SHA-256", buf)); + return Buffer.from(await crypto.subtle.digest("SHA-256", buf)).toString("hex"); } // --------------------------------------------------------------------------- @@ -163,7 +160,7 @@ async function fetchScriptLockNew( // Part 1 — Annotation parsing // ============================================================================= -Deno.test("python: manual requirements with external refs + inline deps", () => { +test("python: manual requirements with external refs + inline deps", () => { const code = `# requirements: default, base #requests==2.31.0 #pandas>=1.5.0 @@ -171,40 +168,40 @@ Deno.test("python: manual requirements with external refs + inline deps", () => def main(): pass`; const r = extractWorkspaceDepsAnnotation(code, "python3")!; - assertEquals(r.mode, "manual"); - assertEquals(r.external, ["default", "base"]); - assertEquals(r.inline, "requests==2.31.0\npandas>=1.5.0"); + expect(r.mode).toEqual("manual"); + expect(r.external).toEqual(["default", "base"]); + expect(r.inline).toEqual("requests==2.31.0\npandas>=1.5.0"); }); -Deno.test("python: extra_requirements mode", () => { +test("python: extra_requirements mode", () => { const code = `# extra_requirements: utils #numpy>=1.24.0 def main(): pass`; const r = extractWorkspaceDepsAnnotation(code, "python3")!; - assertEquals(r.mode, "extra"); - assertEquals(r.external, ["utils"]); - assertEquals(r.inline, "numpy>=1.24.0"); + expect(r.mode).toEqual("extra"); + expect(r.external).toEqual(["utils"]); + expect(r.inline).toEqual("numpy>=1.24.0"); }); -Deno.test("python: empty requirements (opt-out)", () => { +test("python: empty requirements (opt-out)", () => { const code = `# requirements: def main(): pass`; const r = extractWorkspaceDepsAnnotation(code, "python3")!; - assertEquals(r.mode, "manual"); - assertEquals(r.external, []); - assertEquals(r.inline, null); + expect(r.mode).toEqual("manual"); + expect(r.external).toEqual([]); + expect(r.inline).toEqual(null); }); -Deno.test("python: no annotation → null", () => { +test("python: no annotation → null", () => { const code = `def main(): print("hello")`; - assertEquals(extractWorkspaceDepsAnnotation(code, "python3"), null); + expect(extractWorkspaceDepsAnnotation(code, "python3")).toEqual(null); }); -Deno.test("bun: package_json annotation with inline", () => { +test("bun: package_json annotation with inline", () => { const code = `// package_json: utils, base //{ // "dependencies": { @@ -214,47 +211,47 @@ Deno.test("bun: package_json annotation with inline", () => { export function main() {}`; const r = extractWorkspaceDepsAnnotation(code, "bun")!; - assertEquals(r.mode, "manual"); - assertEquals(r.external, ["utils", "base"]); - assertEquals(r.inline, `{ + expect(r.mode).toEqual("manual"); + expect(r.external).toEqual(["utils", "base"]); + expect(r.inline).toEqual(`{ "dependencies": { "axios": "^1.6.0" } }`); }); -Deno.test("go: go_mod annotation", () => { +test("go: go_mod annotation", () => { const code = `// go_mod: base, //github.com/gin-gonic/gin v1.9.1 package main func main() {}`; const r = extractWorkspaceDepsAnnotation(code, "go")!; - assertEquals(r.mode, "manual"); - assertEquals(r.external, ["base"]); - assertEquals(r.inline, "github.com/gin-gonic/gin v1.9.1"); + expect(r.mode).toEqual("manual"); + expect(r.external).toEqual(["base"]); + expect(r.inline).toEqual("github.com/gin-gonic/gin v1.9.1"); }); -Deno.test("unsupported language → null", () => { - assertEquals(extractWorkspaceDepsAnnotation("print(1)", "deno"), null); - assertEquals(extractWorkspaceDepsAnnotation("print(1)", "bash"), null); +test("unsupported language → null", () => { + expect(extractWorkspaceDepsAnnotation("print(1)", "deno")).toEqual(null); + expect(extractWorkspaceDepsAnnotation("print(1)", "bash")).toEqual(null); }); // ============================================================================= // Part 2 — Cache key computation // ============================================================================= -Deno.test("same annotation + language + deps → same key", async () => { +test("same annotation + language + deps → same key", async () => { const code = `# requirements: default #requests==2.31.0 print("hello")`; const deps = { "dependencies/requirements.in": "requests==2.31.0" }; const a = await computeLockCacheKey(code, "python3", deps); const b = await computeLockCacheKey(code, "python3", deps); - assertEquals(a, b); + expect(a).toEqual(b); }); -Deno.test("different code, same annotation → same key", async () => { +test("different code, same annotation → same key", async () => { const codeA = `# requirements: default #requests==2.31.0 print("hello")`; @@ -262,13 +259,10 @@ print("hello")`; #requests==2.31.0 print("world")`; const deps = { "dependencies/requirements.in": "requests==2.31.0" }; - assertEquals( - await computeLockCacheKey(codeA, "python3", deps), - await computeLockCacheKey(codeB, "python3", deps), - ); + expect(await computeLockCacheKey(codeA, "python3", deps)).toEqual(await computeLockCacheKey(codeB, "python3", deps)); }); -Deno.test("different annotation inline → different key", async () => { +test("different annotation inline → different key", async () => { const codeA = `# requirements: default #requests==2.31.0 print("hello")`; @@ -276,67 +270,46 @@ print("hello")`; #flask==3.0.0 print("hello")`; const deps = { "dependencies/requirements.in": "requests==2.31.0" }; - assertNotEquals( - await computeLockCacheKey(codeA, "python3", deps), - await computeLockCacheKey(codeB, "python3", deps), - ); + expect(await computeLockCacheKey(codeA, "python3", deps)).not.toEqual(await computeLockCacheKey(codeB, "python3", deps)); }); -Deno.test("different annotation external refs → different key", async () => { +test("different annotation external refs → different key", async () => { const codeA = `# requirements: default print("hello")`; const codeB = `# requirements: base print("hello")`; const deps = { "dependencies/requirements.in": "requests==2.31.0" }; - assertNotEquals( - await computeLockCacheKey(codeA, "python3", deps), - await computeLockCacheKey(codeB, "python3", deps), - ); + expect(await computeLockCacheKey(codeA, "python3", deps)).not.toEqual(await computeLockCacheKey(codeB, "python3", deps)); }); -Deno.test("manual vs extra mode → different key", async () => { +test("manual vs extra mode → different key", async () => { const codeA = `# requirements: default print("hello")`; const codeB = `# extra_requirements: default print("hello")`; const deps = { "dependencies/requirements.in": "requests==2.31.0" }; - assertNotEquals( - await computeLockCacheKey(codeA, "python3", deps), - await computeLockCacheKey(codeB, "python3", deps), - ); + expect(await computeLockCacheKey(codeA, "python3", deps)).not.toEqual(await computeLockCacheKey(codeB, "python3", deps)); }); -Deno.test("no annotation, same code → same key", async () => { +test("no annotation, same code → same key", async () => { const deps = { "dependencies/requirements.in": "requests==2.31.0" }; - assertEquals( - await computeLockCacheKey("print('a')", "python3", deps), - await computeLockCacheKey("print('b')", "python3", deps), - ); + expect(await computeLockCacheKey("print('a')", "python3", deps)).toEqual(await computeLockCacheKey("print('b')", "python3", deps)); }); -Deno.test("different deps → different key", async () => { +test("different deps → different key", async () => { const code = `# requirements: default print("hello")`; - assertNotEquals( - await computeLockCacheKey(code, "python3", { d: "a" }), - await computeLockCacheKey(code, "python3", { d: "b" }), - ); + expect(await computeLockCacheKey(code, "python3", { d: "a" })).not.toEqual(await computeLockCacheKey(code, "python3", { d: "b" })); }); -Deno.test("different language → different key", async () => { +test("different language → different key", async () => { const deps = { d: "v" }; - assertNotEquals( - await computeLockCacheKey("x", "bun", deps), - await computeLockCacheKey("x", "python3", deps), - ); + expect(await computeLockCacheKey("x", "bun", deps)).not.toEqual(await computeLockCacheKey("x", "python3", deps)); }); -Deno.test("dep key order does not matter", async () => { +test("dep key order does not matter", async () => { const code = "print('hello')"; - assertEquals( - await computeLockCacheKey(code, "python3", { a: "1", b: "2" }), - await computeLockCacheKey(code, "python3", { b: "2", a: "1" }), - ); + expect(await computeLockCacheKey(code, "python3", { a: "1", b: "2" })).toEqual(await computeLockCacheKey(code, "python3", { b: "2", a: "1" })); }); // ============================================================================= @@ -360,7 +333,7 @@ function makeRemoteFn(): { // -- Two scripts, same annotation + language + deps ------------------------- -Deno.test("old logic: two scripts same annotation → 2 remote calls", async () => { +test("old logic: two scripts same annotation → 2 remote calls", async () => { const { remoteFn, callCount } = makeRemoteFn(); const deps = { "dependencies/requirements.in": "requests==2.31.0" }; @@ -370,10 +343,10 @@ Deno.test("old logic: two scripts same annotation → 2 remote calls", async () ]; for (const s of scripts) await fetchScriptLockOld(s, remoteFn); - assertEquals(callCount(), 2); + expect(callCount()).toEqual(2); }); -Deno.test("new logic: two scripts same annotation → 1 remote call (cache shared)", async () => { +test("new logic: two scripts same annotation → 1 remote call (cache shared)", async () => { const { remoteFn, callCount } = makeRemoteFn(); const cache = new Map(); const deps = { "dependencies/requirements.in": "requests==2.31.0" }; @@ -385,13 +358,13 @@ Deno.test("new logic: two scripts same annotation → 1 remote call (cache share const results: string[] = []; for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); - assertEquals(callCount(), 1); - assertEquals(results[0], results[1]); + expect(callCount()).toEqual(1); + expect(results[0]).toEqual(results[1]); }); // -- Two scripts, different annotations + same deps ------------------------- -Deno.test("new logic: different annotations same deps → 2 remote calls", async () => { +test("new logic: different annotations same deps → 2 remote calls", async () => { const { remoteFn, callCount } = makeRemoteFn(); const cache = new Map(); const deps = { "dependencies/requirements.in": "requests==2.31.0" }; @@ -403,13 +376,13 @@ Deno.test("new logic: different annotations same deps → 2 remote calls", async const results: string[] = []; for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); - assertEquals(callCount(), 2); - assertNotEquals(results[0], results[1]); + expect(callCount()).toEqual(2); + expect(results[0]).not.toEqual(results[1]); }); // -- Two scripts, same annotation + different deps -------------------------- -Deno.test("new logic: same annotation different deps → 2 remote calls", async () => { +test("new logic: same annotation different deps → 2 remote calls", async () => { const { remoteFn, callCount } = makeRemoteFn(); const cache = new Map(); @@ -422,13 +395,13 @@ Deno.test("new logic: same annotation different deps → 2 remote calls", async const results: string[] = []; for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); - assertEquals(callCount(), 2); - assertNotEquals(results[0], results[1]); + expect(callCount()).toEqual(2); + expect(results[0]).not.toEqual(results[1]); }); // -- Many scripts, same annotation + deps ----------------------------------- -Deno.test("old logic: 5 scripts same annotation+deps → 5 remote calls", async () => { +test("old logic: 5 scripts same annotation+deps → 5 remote calls", async () => { const { remoteFn, callCount } = makeRemoteFn(); const deps = { "dependencies/requirements.in": "requests==2.31.0" }; const ann = "# requirements: default\n"; @@ -442,10 +415,10 @@ Deno.test("old logic: 5 scripts same annotation+deps → 5 remote calls", async ]; for (const s of scripts) await fetchScriptLockOld(s, remoteFn); - assertEquals(callCount(), 5); + expect(callCount()).toEqual(5); }); -Deno.test("new logic: 5 scripts same annotation+deps → 1 remote call", async () => { +test("new logic: 5 scripts same annotation+deps → 1 remote call", async () => { const { remoteFn, callCount } = makeRemoteFn(); const cache = new Map(); const deps = { "dependencies/requirements.in": "requests==2.31.0" }; @@ -461,15 +434,15 @@ Deno.test("new logic: 5 scripts same annotation+deps → 1 remote call", async ( const results: string[] = []; for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); - assertEquals(callCount(), 1); + expect(callCount()).toEqual(1); for (let i = 1; i < results.length; i++) { - assertEquals(results[0], results[i]); + expect(results[0]).toEqual(results[i]); } }); // -- Many scripts, 2 annotation groups + same deps ------------------------- -Deno.test("new logic: 4 scripts with 2 annotation groups → 2 remote calls", async () => { +test("new logic: 4 scripts with 2 annotation groups → 2 remote calls", async () => { const { remoteFn, callCount } = makeRemoteFn(); const cache = new Map(); const deps = { "dependencies/requirements.in": "requests==2.31.0" }; @@ -483,15 +456,15 @@ Deno.test("new logic: 4 scripts with 2 annotation groups → 2 remote calls", as const results: string[] = []; for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); - assertEquals(callCount(), 2); - assertEquals(results[0], results[2]); // same annotation "default" - assertEquals(results[1], results[3]); // same annotation "base" - assertNotEquals(results[0], results[1]); + expect(callCount()).toEqual(2); + expect(results[0]).toEqual(results[2]); // same annotation "default" + expect(results[1]).toEqual(results[3]); // same annotation "base" + expect(results[0]).not.toEqual(results[1]); }); // -- Scripts with no workspace deps (empty) --------------------------------- -Deno.test("new logic: empty deps → no caching", async () => { +test("new logic: empty deps → no caching", async () => { const { remoteFn, callCount } = makeRemoteFn(); const cache = new Map(); @@ -501,13 +474,13 @@ Deno.test("new logic: empty deps → no caching", async () => { ]; for (const s of scripts) await fetchScriptLockNew(s, remoteFn, cache); - assertEquals(callCount(), 2); - assertEquals(cache.size, 0); + expect(callCount()).toEqual(2); + expect(cache.size).toEqual(0); }); // -- No annotation scripts with raw deps → share cache --------------------- -Deno.test("new logic: no annotation + same deps → 1 remote call", async () => { +test("new logic: no annotation + same deps → 1 remote call", async () => { const { remoteFn, callCount } = makeRemoteFn(); const cache = new Map(); const deps = { "dependencies/requirements.in": "requests==2.31.0" }; @@ -519,13 +492,13 @@ Deno.test("new logic: no annotation + same deps → 1 remote call", async () => const results: string[] = []; for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); - assertEquals(callCount(), 1); - assertEquals(results[0], results[1]); + expect(callCount()).toEqual(1); + expect(results[0]).toEqual(results[1]); }); // -- Mix of annotated and non-annotated scripts ----------------------------- -Deno.test("new logic: mix of annotated and non-annotated → separate cache groups", async () => { +test("new logic: mix of annotated and non-annotated → separate cache groups", async () => { const { remoteFn, callCount } = makeRemoteFn(); const cache = new Map(); const deps = { "dependencies/requirements.in": "requests==2.31.0" }; @@ -539,15 +512,15 @@ Deno.test("new logic: mix of annotated and non-annotated → separate cache grou const results: string[] = []; for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache)); - assertEquals(callCount(), 2); // one for annotated group, one for no-annotation group - assertEquals(results[0], results[2]); // both annotated "default" - assertEquals(results[1], results[3]); // both no annotation - assertNotEquals(results[0], results[1]); // annotated ≠ non-annotated + expect(callCount()).toEqual(2); // one for annotated group, one for no-annotation group + expect(results[0]).toEqual(results[2]); // both annotated "default" + expect(results[1]).toEqual(results[3]); // both no annotation + expect(results[0]).not.toEqual(results[1]); // annotated ≠ non-annotated }); // -- Cache returns correct lock value --------------------------------------- -Deno.test("new logic: cached value matches original remote response", async () => { +test("new logic: cached value matches original remote response", async () => { const cache = new Map(); const deps = { "dependencies/requirements.in": "requests==2.31.0" }; @@ -566,7 +539,7 @@ Deno.test("new logic: cached value matches original remote response", async () = remoteFn, cache, ); - assertEquals(callIdx, 1); - assertEquals(r1, "resolved-lock-content-abc123"); - assertEquals(r2, "resolved-lock-content-abc123"); + expect(callIdx).toEqual(1); + expect(r1).toEqual("resolved-lock-content-abc123"); + expect(r2).toEqual("resolved-lock-content-abc123"); }); diff --git a/cli/test/mixed_case_paths.test.ts b/cli/test/mixed_case_paths.test.ts index 74c7674678..ffbb3f21d4 100644 --- a/cli/test/mixed_case_paths.test.ts +++ b/cli/test/mixed_case_paths.test.ts @@ -12,9 +12,9 @@ * 3. The modifications are correctly applied on the server */ -import { assertEquals, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { ensureDir } from "https://deno.land/std@0.224.0/fs/mod.ts"; -import * as path from "https://deno.land/std@0.224.0/path/mod.ts"; +import { expect, test } from "bun:test"; +import * as path from "node:path"; +import { writeFile, readFile, stat } from "node:fs/promises"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; @@ -250,27 +250,19 @@ async function verifyNoDiffOnPull(backend: any, tempDir: string): Promise ["sync", "pull", "--yes", "--dry-run", "--json-output"], tempDir ); - assertEquals(pullResult.code, 0, `Pull for diff check should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); const output = parseJsonFromCLIOutput(pullResult.stdout); const changes = output.changes || []; - assertEquals( - changes.length, - 0, - `Should have no changes after push, but found: ${JSON.stringify(changes.map((c: any) => c.path))}` - ); + expect(changes.length).toEqual(0); } // ============================================================================= // TESTS // ============================================================================= -Deno.test({ - name: "Mixed Case Paths: pull and push script with capitalized folder", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Mixed Case Paths: pull and push script with capitalized folder", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); @@ -285,56 +277,51 @@ Deno.test({ await createScript(backend, scriptPath, originalContent, "My Test Script"); // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( path.join(tempDir, "wmill.yaml"), `defaultTs: bun includes: - "**" excludes: [] -` +`, + "utf-8" ); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pullResult.code, 0, `Pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Verify file exists with correct path (normalized for comparison) const expectedScriptPath = path.join(tempDir, "f", "MyFolder", "MyScript.ts"); - const scriptExists = await Deno.stat(expectedScriptPath).then(() => true).catch(() => false); - assert(scriptExists, `Script file should exist at ${expectedScriptPath}`); + const scriptExists = await stat(expectedScriptPath).then(() => true).catch(() => false); + expect(scriptExists).toBeTruthy(); // Read and verify content - const pulledContent = await Deno.readTextFile(expectedScriptPath); - assert(pulledContent.includes("original content"), "Pulled content should match original"); + const pulledContent = await readFile(expectedScriptPath, "utf-8"); + expect(pulledContent.includes("original content")).toBeTruthy(); // Modify the script const modifiedContent = `export async function main() { return "modified content from test"; }`; - await Deno.writeTextFile(expectedScriptPath, modifiedContent); + await writeFile(expectedScriptPath, modifiedContent, "utf-8"); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals(pushResult.code, 0, `Push should succeed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Verify modification on server const updatedScript = await getScript(backend, scriptPath); - assert( - updatedScript.content.includes("modified content from test"), - `Server should have modified content. Got: ${updatedScript.content}` - ); + expect( + updatedScript.content.includes("modified content from test") + ).toBeTruthy(); // Verify no diff on subsequent pull (idempotency) await verifyNoDiffOnPull(backend, tempDir); }); - }, }); -Deno.test({ - name: "Mixed Case Paths: pull and push flow with capitalized folder", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Mixed Case Paths: pull and push flow with capitalized folder", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); @@ -349,59 +336,51 @@ Deno.test({ await createFlow(backend, flowPath, originalContent, "Data Processor Flow"); // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( path.join(tempDir, "wmill.yaml"), `defaultTs: bun includes: - "**" excludes: [] -` +`, + "utf-8" ); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pullResult.code, 0, `Pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Verify flow directory exists const flowDir = path.join(tempDir, "f", "MyFlows", "DataProcessor.flow"); - const flowDirExists = await Deno.stat(flowDir).then(s => s.isDirectory).catch(() => false); - assert(flowDirExists, `Flow directory should exist at ${flowDir}`); + const flowDirExists = await stat(flowDir).then(s => s.isDirectory()).catch(() => false); + expect(flowDirExists).toBeTruthy(); // Modify the flow metadata (summary) instead of inline script const flowMetadataPath = path.join(flowDir, "flow.yaml"); - const flowMetadataExists = await Deno.stat(flowMetadataPath).then(() => true).catch(() => false); - assert(flowMetadataExists, `Flow metadata should exist at ${flowMetadataPath}`); + const flowMetadataExists = await stat(flowMetadataPath).then(() => true).catch(() => false); + expect(flowMetadataExists).toBeTruthy(); - const flowMetadata = await Deno.readTextFile(flowMetadataPath); + const flowMetadata = await readFile(flowMetadataPath, "utf-8"); const modifiedMetadata = flowMetadata.replace( /summary:.*$/m, 'summary: "Modified Data Processor Flow from test"' ); - await Deno.writeTextFile(flowMetadataPath, modifiedMetadata); + await writeFile(flowMetadataPath, modifiedMetadata, "utf-8"); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals(pushResult.code, 0, `Push should succeed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Verify modification on server const updatedFlow = await getFlow(backend, flowPath); - assertEquals( - updatedFlow.summary, - "Modified Data Processor Flow from test", - `Server should have modified flow summary. Got: ${updatedFlow.summary}` - ); + expect(updatedFlow.summary).toEqual("Modified Data Processor Flow from test"); // Verify no diff on subsequent pull (idempotency) await verifyNoDiffOnPull(backend, tempDir); }); - }, }); -Deno.test({ - name: "Mixed Case Paths: pull and push app with capitalized folder", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Mixed Case Paths: pull and push app with capitalized folder", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); @@ -413,56 +392,48 @@ Deno.test({ await createApp(backend, appPath, "My Dashboard App"); // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( path.join(tempDir, "wmill.yaml"), `defaultTs: bun includes: - "**" excludes: [] -` +`, + "utf-8" ); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pullResult.code, 0, `Pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Verify app directory exists const appDir = path.join(tempDir, "f", "MyApps", "Dashboard.app"); - const appDirExists = await Deno.stat(appDir).then(s => s.isDirectory).catch(() => false); - assert(appDirExists, `App directory should exist at ${appDir}`); + const appDirExists = await stat(appDir).then(s => s.isDirectory()).catch(() => false); + expect(appDirExists).toBeTruthy(); // Modify the app metadata const appMetadataPath = path.join(appDir, "app.yaml"); - const appMetadata = await Deno.readTextFile(appMetadataPath); + const appMetadata = await readFile(appMetadataPath, "utf-8"); const modifiedMetadata = appMetadata.replace( /summary:.*$/m, 'summary: "Modified Dashboard App from test"' ); - await Deno.writeTextFile(appMetadataPath, modifiedMetadata); + await writeFile(appMetadataPath, modifiedMetadata, "utf-8"); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals(pushResult.code, 0, `Push should succeed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Verify modification on server const updatedApp = await getApp(backend, appPath); - assertEquals( - updatedApp.summary, - "Modified Dashboard App from test", - `Server should have modified app summary. Got: ${updatedApp.summary}` - ); + expect(updatedApp.summary).toEqual("Modified Dashboard App from test"); // Verify no diff on subsequent pull (idempotency) await verifyNoDiffOnPull(backend, tempDir); }); - }, }); -Deno.test({ - name: "Mixed Case Paths: pull and push variable with capitalized folder", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Mixed Case Paths: pull and push variable with capitalized folder", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); @@ -474,55 +445,47 @@ Deno.test({ await createVariable(backend, varPath, "original-api-key-value", "API Key Variable"); // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( path.join(tempDir, "wmill.yaml"), `defaultTs: bun includes: - "**" excludes: [] -` +`, + "utf-8" ); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pullResult.code, 0, `Pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Verify variable file exists const varFilePath = path.join(tempDir, "f", "MyVars", "ApiKey.variable.yaml"); - const varExists = await Deno.stat(varFilePath).then(() => true).catch(() => false); - assert(varExists, `Variable file should exist at ${varFilePath}`); + const varExists = await stat(varFilePath).then(() => true).catch(() => false); + expect(varExists).toBeTruthy(); // Modify the variable - const varContent = await Deno.readTextFile(varFilePath); + const varContent = await readFile(varFilePath, "utf-8"); const modifiedVarContent = varContent.replace( /value:.*$/m, 'value: "modified-api-key-from-test"' ); - await Deno.writeTextFile(varFilePath, modifiedVarContent); + await writeFile(varFilePath, modifiedVarContent, "utf-8"); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals(pushResult.code, 0, `Push should succeed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Verify modification on server const updatedVar = await getVariable(backend, varPath); - assertEquals( - updatedVar.value, - "modified-api-key-from-test", - `Server should have modified variable value. Got: ${updatedVar.value}` - ); + expect(updatedVar.value).toEqual("modified-api-key-from-test"); // Verify no diff on subsequent pull (idempotency) await verifyNoDiffOnPull(backend, tempDir); }); - }, }); -Deno.test({ - name: "Mixed Case Paths: deeply nested capitalized folders", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Mixed Case Paths: deeply nested capitalized folders", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); @@ -539,52 +502,47 @@ Deno.test({ await createScript(backend, scriptPath, originalContent, "Nested Script"); // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( path.join(tempDir, "wmill.yaml"), `defaultTs: bun includes: - "**" excludes: [] -` +`, + "utf-8" ); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pullResult.code, 0, `Pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Verify file exists const scriptFilePath = path.join(tempDir, "f", "MyProject", "SubFolder_A.ts"); - const scriptExists = await Deno.stat(scriptFilePath).then(() => true).catch(() => false); - assert(scriptExists, `Nested script should exist at ${scriptFilePath}`); + const scriptExists = await stat(scriptFilePath).then(() => true).catch(() => false); + expect(scriptExists).toBeTruthy(); // Modify const modifiedContent = `export async function main() { return "deeply nested modified from test"; }`; - await Deno.writeTextFile(scriptFilePath, modifiedContent); + await writeFile(scriptFilePath, modifiedContent, "utf-8"); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals(pushResult.code, 0, `Push should succeed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Verify on server const updatedScript = await getScript(backend, scriptPath); - assert( - updatedScript.content.includes("deeply nested modified from test"), - `Server should have modified nested content` - ); + expect( + updatedScript.content.includes("deeply nested modified from test") + ).toBeTruthy(); // Verify no diff on subsequent pull (idempotency) await verifyNoDiffOnPull(backend, tempDir); }); - }, }); -Deno.test({ - name: "Mixed Case Paths: multiple resources in same capitalized folder", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Mixed Case Paths: multiple resources in same capitalized folder", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); @@ -608,65 +566,63 @@ Deno.test({ await createResource(backend, "f/SharedFolder/ResourceOne", "any", { key: "original" }); // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( path.join(tempDir, "wmill.yaml"), `defaultTs: bun includes: - "**" excludes: [] -` +`, + "utf-8" ); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pullResult.code, 0, `Pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Verify all files exist const folderPath = path.join(tempDir, "f", "SharedFolder"); - const script1Exists = await Deno.stat(path.join(folderPath, "ScriptOne.ts")).then(() => true).catch(() => false); - const script2Exists = await Deno.stat(path.join(folderPath, "ScriptTwo.ts")).then(() => true).catch(() => false); - const var1Exists = await Deno.stat(path.join(folderPath, "VarOne.variable.yaml")).then(() => true).catch(() => false); - const res1Exists = await Deno.stat(path.join(folderPath, "ResourceOne.resource.yaml")).then(() => true).catch(() => false); + const script1Exists = await stat(path.join(folderPath, "ScriptOne.ts")).then(() => true).catch(() => false); + const script2Exists = await stat(path.join(folderPath, "ScriptTwo.ts")).then(() => true).catch(() => false); + const var1Exists = await stat(path.join(folderPath, "VarOne.variable.yaml")).then(() => true).catch(() => false); + const res1Exists = await stat(path.join(folderPath, "ResourceOne.resource.yaml")).then(() => true).catch(() => false); - assert(script1Exists, "ScriptOne should exist"); - assert(script2Exists, "ScriptTwo should exist"); - assert(var1Exists, "VarOne should exist"); - assert(res1Exists, "ResourceOne should exist"); + expect(script1Exists).toBeTruthy(); + expect(script2Exists).toBeTruthy(); + expect(var1Exists).toBeTruthy(); + expect(res1Exists).toBeTruthy(); // Modify script one - await Deno.writeTextFile( + await writeFile( path.join(folderPath, "ScriptOne.ts"), - 'export async function main() { return "script one MODIFIED"; }' + 'export async function main() { return "script one MODIFIED"; }', + "utf-8" ); // Modify script two - await Deno.writeTextFile( + await writeFile( path.join(folderPath, "ScriptTwo.ts"), - 'export async function main() { return "script two MODIFIED"; }' + 'export async function main() { return "script two MODIFIED"; }', + "utf-8" ); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals(pushResult.code, 0, `Push should succeed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Verify modifications on server const script1 = await getScript(backend, "f/SharedFolder/ScriptOne"); const script2 = await getScript(backend, "f/SharedFolder/ScriptTwo"); - assert(script1.content.includes("script one MODIFIED"), "Script one should be modified on server"); - assert(script2.content.includes("script two MODIFIED"), "Script two should be modified on server"); + expect(script1.content.includes("script one MODIFIED")).toBeTruthy(); + expect(script2.content.includes("script two MODIFIED")).toBeTruthy(); // Verify no diff on subsequent pull (idempotency) await verifyNoDiffOnPull(backend, tempDir); }); - }, }); -Deno.test({ - name: "Mixed Case Paths: CamelCase folder names with numbers", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Mixed Case Paths: CamelCase folder names with numbers", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); @@ -683,40 +639,41 @@ Deno.test({ ); // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( path.join(tempDir, "wmill.yaml"), `defaultTs: bun includes: - "**" excludes: [] -` +`, + "utf-8" ); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pullResult.code, 0, `Pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Verify file exists const scriptFilePath = path.join(tempDir, "f", "Project2024", "DataHandler_V2.ts"); - const scriptExists = await Deno.stat(scriptFilePath).then(() => true).catch(() => false); - assert(scriptExists, `Script should exist at ${scriptFilePath}`); + const scriptExists = await stat(scriptFilePath).then(() => true).catch(() => false); + expect(scriptExists).toBeTruthy(); // Modify - await Deno.writeTextFile( + await writeFile( scriptFilePath, - 'export async function main() { return "handler v2 MODIFIED"; }' + 'export async function main() { return "handler v2 MODIFIED"; }', + "utf-8" ); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals(pushResult.code, 0, `Push should succeed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Verify on server const updatedScript = await getScript(backend, scriptPath); - assert(updatedScript.content.includes("handler v2 MODIFIED"), "Server should have modified content"); + expect(updatedScript.content.includes("handler v2 MODIFIED")).toBeTruthy(); // Verify no diff on subsequent pull (idempotency) await verifyNoDiffOnPull(backend, tempDir); }); - }, }); diff --git a/cli/test/multi_instance_workspace.test.ts b/cli/test/multi_instance_workspace.test.ts index 9cdd331477..1d0172166d 100644 --- a/cli/test/multi_instance_workspace.test.ts +++ b/cli/test/multi_instance_workspace.test.ts @@ -1,4 +1,5 @@ -import { assertEquals, assert, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; @@ -20,16 +21,12 @@ async function setupWorkspaceProfile(backend: any, workspaceName: string): Promi await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); } -Deno.test({ - name: "Multi-Branch: sync pull with branch-specific overrides", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Multi-Branch: sync pull with branch-specific overrides", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend, "multi_branch_test"); // Create wmill.yaml with gitBranches configuration - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] @@ -46,7 +43,7 @@ gitBranches: prod: overrides: skipVariables: true - skipResources: true`); + skipResources: true`, "utf-8"); // Test main branch - should include variables and resources const mainResult = await backend.runCLICommand([ @@ -56,7 +53,7 @@ gitBranches: '--json-output' ], tempDir, "multi_branch_test"); - assertEquals(mainResult.code, 0, `Main branch sync should succeed: ${mainResult.stderr}`); + expect(mainResult.code).toEqual(0); const mainData = parseJsonFromCLIOutput(mainResult.stdout); const mainPaths = (mainData.changes || []).map((c: any) => c.path); @@ -64,8 +61,8 @@ gitBranches: const mainHasVariables = mainPaths.some((path: string) => path.includes('.variable.yaml')); const mainHasResources = mainPaths.some((path: string) => path.includes('.resource.yaml')); - assertEquals(mainHasVariables, true, "Main branch should include variables"); - assertEquals(mainHasResources, true, "Main branch should include resources"); + expect(mainHasVariables).toEqual(true); + expect(mainHasResources).toEqual(true); // Test staging branch - should skip variables but include resources const stagingResult = await backend.runCLICommand([ @@ -75,7 +72,7 @@ gitBranches: '--json-output' ], tempDir, "multi_branch_test"); - assertEquals(stagingResult.code, 0, `Staging branch sync should succeed: ${stagingResult.stderr}`); + expect(stagingResult.code).toEqual(0); const stagingData = parseJsonFromCLIOutput(stagingResult.stdout); const stagingPaths = (stagingData.changes || []).map((c: any) => c.path); @@ -83,8 +80,8 @@ gitBranches: const stagingHasVariables = stagingPaths.some((path: string) => path.includes('.variable.yaml')); const stagingHasResources = stagingPaths.some((path: string) => path.includes('.resource.yaml')); - assertEquals(stagingHasVariables, false, "Staging branch should skip variables"); - assertEquals(stagingHasResources, true, "Staging branch should include resources"); + expect(stagingHasVariables).toEqual(false); + expect(stagingHasResources).toEqual(true); // Test prod branch - should skip both variables and resources const prodResult = await backend.runCLICommand([ @@ -94,7 +91,7 @@ gitBranches: '--json-output' ], tempDir, "multi_branch_test"); - assertEquals(prodResult.code, 0, `Prod branch sync should succeed: ${prodResult.stderr}`); + expect(prodResult.code).toEqual(0); const prodData = parseJsonFromCLIOutput(prodResult.stdout); const prodPaths = (prodData.changes || []).map((c: any) => c.path); @@ -102,21 +99,16 @@ gitBranches: const prodHasVariables = prodPaths.some((path: string) => path.includes('.variable.yaml')); const prodHasResources = prodPaths.some((path: string) => path.includes('.resource.yaml')); - assertEquals(prodHasVariables, false, "Prod branch should skip variables"); - assertEquals(prodHasResources, false, "Prod branch should skip resources"); + expect(prodHasVariables).toEqual(false); + expect(prodHasResources).toEqual(false); }); - } }); -Deno.test({ - name: "Multi-Branch: branch override with includes filtering", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Multi-Branch: branch override with includes filtering", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend, "includes_branch_test"); - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" @@ -131,7 +123,7 @@ gitBranches: includes: - "f/**" - "users/**" - skipVariables: false`); + skipVariables: false`, "utf-8"); // Test feature branch - should skip variables and only include f/** const featureResult = await backend.runCLICommand([ @@ -141,7 +133,7 @@ gitBranches: '--json-output' ], tempDir, "includes_branch_test"); - assertEquals(featureResult.code, 0, `Feature branch sync should succeed: ${featureResult.stderr}`); + expect(featureResult.code).toEqual(0); const featureData = parseJsonFromCLIOutput(featureResult.stdout); const featurePaths = (featureData.changes || []).map((c: any) => c.path); @@ -151,8 +143,8 @@ gitBranches: const featureHasVariables = normalizedFeaturePaths.some((path: string) => path.includes('.variable.yaml')); const featureHasUsers = normalizedFeaturePaths.some((path: string) => path.startsWith('users/')); - assertEquals(featureHasVariables, false, "Feature branch should skip variables"); - assertEquals(featureHasUsers, false, "Feature branch should not include users (not in includes)"); + expect(featureHasVariables).toEqual(false); + expect(featureHasUsers).toEqual(false); // Test release branch - should include variables and users const releaseResult = await backend.runCLICommand([ @@ -163,7 +155,7 @@ gitBranches: '--json-output' ], tempDir, "includes_branch_test"); - assertEquals(releaseResult.code, 0, `Release branch sync should succeed: ${releaseResult.stderr}`); + expect(releaseResult.code).toEqual(0); const releaseData = parseJsonFromCLIOutput(releaseResult.stdout); const releasePaths = (releaseData.changes || []).map((c: any) => c.path); @@ -173,21 +165,16 @@ gitBranches: const releaseHasVariables = normalizedReleasePaths.some((path: string) => path.includes('.variable.yaml')); const releaseHasUsers = normalizedReleasePaths.some((path: string) => path.startsWith('users/')); - assertEquals(releaseHasVariables, true, "Release branch should include variables"); - assertEquals(releaseHasUsers, true, "Release branch should include users"); + expect(releaseHasVariables).toEqual(true); + expect(releaseHasUsers).toEqual(true); }); - } }); -Deno.test({ - name: "Multi-Branch: fallback to base config when branch not defined", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Multi-Branch: fallback to base config when branch not defined", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend, "fallback_test"); - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" skipVariables: true @@ -197,7 +184,7 @@ gitBranches: main: overrides: skipVariables: false - skipResources: false`); + skipResources: false`, "utf-8"); // Test undefined branch - should use base config (skip variables and resources) const undefinedResult = await backend.runCLICommand([ @@ -207,7 +194,7 @@ gitBranches: '--json-output' ], tempDir, "fallback_test"); - assertEquals(undefinedResult.code, 0, `Undefined branch sync should succeed: ${undefinedResult.stderr}`); + expect(undefinedResult.code).toEqual(0); const undefinedData = parseJsonFromCLIOutput(undefinedResult.stdout); const undefinedPaths = (undefinedData.changes || []).map((c: any) => c.path); @@ -216,8 +203,8 @@ gitBranches: const undefinedHasResources = undefinedPaths.some((path: string) => path.includes('.resource.yaml')); // Should use base config since branch is not defined - assertEquals(undefinedHasVariables, false, "Undefined branch should use base config skipVariables: true"); - assertEquals(undefinedHasResources, false, "Undefined branch should use base config skipResources: true"); + expect(undefinedHasVariables).toEqual(false); + expect(undefinedHasResources).toEqual(false); // Test defined main branch - should use branch overrides const mainResult = await backend.runCLICommand([ @@ -227,7 +214,7 @@ gitBranches: '--json-output' ], tempDir, "fallback_test"); - assertEquals(mainResult.code, 0, `Main branch sync should succeed: ${mainResult.stderr}`); + expect(mainResult.code).toEqual(0); const mainData = parseJsonFromCLIOutput(mainResult.stdout); const mainPaths = (mainData.changes || []).map((c: any) => c.path); @@ -235,21 +222,16 @@ gitBranches: const mainHasVariables = mainPaths.some((path: string) => path.includes('.variable.yaml')); const mainHasResources = mainPaths.some((path: string) => path.includes('.resource.yaml')); - assertEquals(mainHasVariables, true, "Main branch should use override skipVariables: false"); - assertEquals(mainHasResources, true, "Main branch should use override skipResources: false"); + expect(mainHasVariables).toEqual(true); + expect(mainHasResources).toEqual(true); }); - } }); -Deno.test({ - name: "Multi-Branch: branch inherits unspecified settings from base", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Multi-Branch: branch inherits unspecified settings from base", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend, "inherit_test"); - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" skipVariables: true @@ -259,7 +241,7 @@ skipApps: true gitBranches: partial: overrides: - skipVariables: false`); + skipVariables: false`, "utf-8"); // Test partial branch - should inherit skipResources and skipApps from base const result = await backend.runCLICommand([ @@ -269,7 +251,7 @@ gitBranches: '--json-output' ], tempDir, "inherit_test"); - assertEquals(result.code, 0, `Partial branch sync should succeed: ${result.stderr}`); + expect(result.code).toEqual(0); const data = parseJsonFromCLIOutput(result.stdout); const paths = (data.changes || []).map((c: any) => c.path); @@ -279,10 +261,9 @@ gitBranches: const hasApps = paths.some((path: string) => path.includes('.app/') || path.endsWith('.app.yaml')); // skipVariables is overridden to false - assertEquals(hasVariables, true, "Partial branch should include variables (override)"); + expect(hasVariables).toEqual(true); // skipResources and skipApps are inherited from base (true) - assertEquals(hasResources, false, "Partial branch should skip resources (inherited)"); - assertEquals(hasApps, false, "Partial branch should skip apps (inherited)"); + expect(hasResources).toEqual(false); + expect(hasApps).toEqual(false); }); - } }); diff --git a/cli/test/override_settings_behavior.test.ts b/cli/test/override_settings_behavior.test.ts index b7a1c1069c..bbbcd75bff 100644 --- a/cli/test/override_settings_behavior.test.ts +++ b/cli/test/override_settings_behavior.test.ts @@ -1,4 +1,5 @@ -import { assertEquals, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; import { getEffectiveSettings } from "../src/core/conf.ts"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; @@ -9,11 +10,7 @@ import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; // Tests for gitBranches override inheritance and file filtering behavior // ============================================================================= -Deno.test({ - name: "Override Settings: branch override inherits non-overridden settings from base config", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Override Settings: branch override inherits non-overridden settings from base config", async () => { const config = { includes: ["default/**"], skipVariables: true, // Base has this as true @@ -39,21 +36,16 @@ Deno.test({ ); // Override values should be used - assertEquals(effective.includes, ["override/**"], "Must use override includes"); - assertEquals(effective.skipApps, true, "Must use override skipApps"); + expect(effective.includes).toEqual(["override/**"]); + expect(effective.skipApps).toEqual(true); // Should inherit skip flags from base config - assertEquals(effective.skipVariables, true, "Must inherit skipVariables=true from base config"); - assertEquals(effective.skipResources, true, "Must inherit skipResources=true from base config"); - assertEquals(effective.defaultTs, "bun", "Must inherit defaultTs from base config"); - } + expect(effective.skipVariables).toEqual(true); + expect(effective.skipResources).toEqual(true); + expect(effective.defaultTs).toEqual("bun"); }); -Deno.test({ - name: "Override Settings: branch-specific settings take precedence", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Override Settings: branch-specific settings take precedence", async () => { const config = { includes: ["default/**"], skipVariables: false, @@ -81,8 +73,8 @@ Deno.test({ true, "main" ); - assertEquals(mainEffective.includes, ["main/**"], "Main branch must use its own includes"); - assertEquals(mainEffective.skipVariables, true, "Main branch must use its own skipVariables"); + expect(mainEffective.includes).toEqual(["main/**"]); + expect(mainEffective.skipVariables).toEqual(true); // Test dev branch const devEffective = await getEffectiveSettings( @@ -92,20 +84,15 @@ Deno.test({ true, "dev" ); - assertEquals(devEffective.includes, ["dev/**"], "Dev branch must use its own includes"); - assertEquals(devEffective.skipVariables, false, "Dev branch must use its own skipVariables"); - } + expect(devEffective.includes).toEqual(["dev/**"]); + expect(devEffective.skipVariables).toEqual(false); }); // ============================================================================= // INTEGRATION TESTS - File Filtering Behavior with gitBranches // ============================================================================= -Deno.test({ - name: "Integration: sync pull with skipVariables branch override excludes variable files", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Integration: sync pull with skipVariables branch override excludes variable files", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -117,7 +104,7 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml with gitBranches override that skips variables - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" skipVariables: false @@ -125,7 +112,7 @@ skipVariables: false gitBranches: test_branch: overrides: - skipVariables: true`); + skipVariables: true`, "utf-8"); // Run sync pull with --branch to force using test_branch config const result = await backend.runCLICommand([ @@ -135,29 +122,24 @@ gitBranches: '--json-output' ], tempDir); - assertEquals(result.code, 0, `Sync pull should succeed: ${result.stderr}`); + expect(result.code).toEqual(0); // Parse output and verify variable files are NOT included const output = parseJsonFromCLIOutput(result.stdout); const changePaths = (output.changes || []).map((c: any) => c.path); const hasVariableFile = changePaths.some((path: string) => path.includes('.variable.yaml')); - assertEquals(hasVariableFile, false, "Variable files should NOT be included due to skipVariables override"); + expect(hasVariableFile).toEqual(false); // Verify other files ARE included const hasOtherFiles = changePaths.some((path: string) => !path.includes('.variable.yaml') && !path.includes('wmill.yaml') ); - assert(hasOtherFiles, `Other files should be included. Found paths: ${changePaths.join(', ')}`); + expect(hasOtherFiles).toBeTruthy(); }); - } }); -Deno.test({ - name: "Integration: sync pull respects includes branch override for file filtering", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Integration: sync pull respects includes branch override for file filtering", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -169,7 +151,7 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml with gitBranches override for includes - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" @@ -178,7 +160,7 @@ gitBranches: overrides: includes: - "users/**" - - "groups/**"`); + - "groups/**"`, "utf-8"); // Run sync pull with --branch to use restricted includes const result = await backend.runCLICommand([ @@ -190,7 +172,7 @@ gitBranches: '--json-output' ], tempDir); - assertEquals(result.code, 0, `Sync pull should succeed: ${result.stderr}`); + expect(result.code).toEqual(0); // Parse output const output = parseJsonFromCLIOutput(result.stdout); @@ -202,20 +184,15 @@ gitBranches: const hasUserFiles = normalizedPaths.some((path: string) => path.includes('users/')); const hasGroupFiles = normalizedPaths.some((path: string) => path.includes('groups/')); - assert(hasUserFiles || hasGroupFiles, `User or group files should be included. Found: ${normalizedPaths.join(', ')}`); + expect(hasUserFiles || hasGroupFiles).toBeTruthy(); // Verify f/** files are NOT included (due to restrictive includes) const hasFolderFiles = normalizedPaths.some((path: string) => path.startsWith('f/')); - assertEquals(hasFolderFiles, false, `f/ files should NOT be included due to restrictive includes. Found: ${normalizedPaths.join(', ')}`); + expect(hasFolderFiles).toEqual(false); }); - } }); -Deno.test({ - name: "Integration: different branches have different settings", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Integration: different branches have different settings", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -227,7 +204,7 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml with different settings per branch - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" skipVariables: false @@ -241,7 +218,7 @@ gitBranches: dev: overrides: skipVariables: false - skipResources: false`); + skipResources: false`, "utf-8"); // Test prod branch - should skip variables and resources const prodResult = await backend.runCLICommand([ @@ -251,7 +228,7 @@ gitBranches: '--json-output' ], tempDir); - assertEquals(prodResult.code, 0, `Prod sync pull should succeed: ${prodResult.stderr}`); + expect(prodResult.code).toEqual(0); const prodOutput = parseJsonFromCLIOutput(prodResult.stdout); const prodPaths = (prodOutput.changes || []).map((c: any) => c.path); @@ -259,8 +236,8 @@ gitBranches: const prodHasVariables = prodPaths.some((path: string) => path.includes('.variable.yaml')); const prodHasResources = prodPaths.some((path: string) => path.includes('.resource.yaml')); - assertEquals(prodHasVariables, false, "Prod branch should skip variables"); - assertEquals(prodHasResources, false, "Prod branch should skip resources"); + expect(prodHasVariables).toEqual(false); + expect(prodHasResources).toEqual(false); // Test dev branch - should include variables and resources const devResult = await backend.runCLICommand([ @@ -270,7 +247,7 @@ gitBranches: '--json-output' ], tempDir); - assertEquals(devResult.code, 0, `Dev sync pull should succeed: ${devResult.stderr}`); + expect(devResult.code).toEqual(0); const devOutput = parseJsonFromCLIOutput(devResult.stdout); const devPaths = (devOutput.changes || []).map((c: any) => c.path); @@ -278,8 +255,7 @@ gitBranches: const devHasVariables = devPaths.some((path: string) => path.includes('.variable.yaml')); const devHasResources = devPaths.some((path: string) => path.includes('.resource.yaml')); - assertEquals(devHasVariables, true, "Dev branch should include variables"); - assertEquals(devHasResources, true, "Dev branch should include resources"); + expect(devHasVariables).toEqual(true); + expect(devHasResources).toEqual(true); }); - } }); diff --git a/cli/test/preview.test.ts b/cli/test/preview.test.ts index eeb3718309..93dc7def48 100644 --- a/cli/test/preview.test.ts +++ b/cli/test/preview.test.ts @@ -1,5 +1,6 @@ -import { assertEquals, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { withTestBackend, cleanupTestBackend } from "./test_backend.ts"; +import { expect, test } from "bun:test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { withTestBackend } from "./test_backend.ts"; // ============================================================================= // PREVIEW COMMAND INTEGRATION TESTS @@ -53,7 +54,7 @@ async function createWmillConfig( } } - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, yamlContent); + await writeFile(`${tempDir}/wmill.yaml`, yamlContent, "utf-8"); } // Helper to create a script file with metadata @@ -67,8 +68,8 @@ async function createScript( } ): Promise { const dir = `${tempDir}/${path.substring(0, path.lastIndexOf("/"))}`; - await Deno.mkdir(dir, { recursive: true }); - await Deno.writeTextFile(`${tempDir}/${path}`, content); + await mkdir(dir, { recursive: true }); + await writeFile(`${tempDir}/${path}`, content, "utf-8"); // Create metadata file const metaPath = path.replace(/\.[^.]+$/, ".script.yaml"); @@ -84,7 +85,7 @@ schema: default: "World" required: [] `; - await Deno.writeTextFile(`${tempDir}/${metaPath}`, metaContent); + await writeFile(`${tempDir}/${metaPath}`, metaContent, "utf-8"); } // Helper to create a flow directory with flow.yaml @@ -97,7 +98,7 @@ async function createFlow( } ): Promise { const dir = `${tempDir}/${flowPath}`; - await Deno.mkdir(dir, { recursive: true }); + await mkdir(dir, { recursive: true }); const flowYaml = `summary: "${options.summary}" description: "Test flow" @@ -118,125 +119,109 @@ schema: default: "World" required: [] `; - await Deno.writeTextFile(`${dir}/flow.yaml`, flowYaml); + await writeFile(`${dir}/flow.yaml`, flowYaml, "utf-8"); } // ============================================================================= // SCRIPT PREVIEW TESTS // ============================================================================= -Deno.test({ - name: "script preview: regular script (non-codebase)", - async fn() { - await withTestBackend(async (backend, tempDir) => { - await createWmillConfig(tempDir, { defaultTs: "bun" }); - await createScript( - tempDir, - "f/test/simple_script.ts", - `export function main(name: string = "World") { +test("script preview: regular script (non-codebase)", async () => { + await withTestBackend(async (backend, tempDir) => { + await createWmillConfig(tempDir, { defaultTs: "bun" }); + await createScript( + tempDir, + "f/test/simple_script.ts", + `export function main(name: string = "World") { return \`Hello, \${name}!\`; }` - ); + ); - const result = await backend.runCLICommand( - ["script", "preview", "f/test/simple_script.ts"], - tempDir - ); + const result = await backend.runCLICommand( + ["script", "preview", "f/test/simple_script.ts"], + tempDir + ); - assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`); - assertStringIncludes(result.stdout + result.stderr, "Hello, World!"); - }); - }, - sanitizeResources: false, - sanitizeOps: false, + expect(result.code).toEqual(0); + expect(result.stdout + result.stderr).toContain("Hello, World!"); + }); }); -Deno.test({ - name: "script preview: codebase script (CJS)", - async fn() { - await withTestBackend(async (backend, tempDir) => { - await createWmillConfig(tempDir, { - defaultTs: "bun", - codebases: [{ relative_path: "f/codebase", includes: ["**"] }], - }); +test("script preview: codebase script (CJS)", async () => { + await withTestBackend(async (backend, tempDir) => { + await createWmillConfig(tempDir, { + defaultTs: "bun", + codebases: [{ relative_path: "f/codebase", includes: ["**"] }], + }); - await createScript( - tempDir, - "f/codebase/cjs_script.ts", - `export function main(name: string = "World") { + await createScript( + tempDir, + "f/codebase/cjs_script.ts", + `export function main(name: string = "World") { console.log("CJS codebase script running"); return \`Hello from CJS codebase, \${name}!\`; }` - ); + ); - const result = await backend.runCLICommand( - ["script", "preview", "f/codebase/cjs_script.ts"], - tempDir - ); + const result = await backend.runCLICommand( + ["script", "preview", "f/codebase/cjs_script.ts"], + tempDir + ); - assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`); - assertStringIncludes(result.stdout + result.stderr, "Hello from CJS codebase, World!"); - }); - }, - sanitizeResources: false, - sanitizeOps: false, + expect(result.code).toEqual(0); + expect(result.stdout + result.stderr).toContain("Hello from CJS codebase, World!"); + }); }); -Deno.test({ - name: "script preview: codebase script (ESM)", - async fn() { - await withTestBackend(async (backend, tempDir) => { - await createWmillConfig(tempDir, { - defaultTs: "bun", - codebases: [{ relative_path: "f/codebase_esm", includes: ["**"], format: "esm" }], - }); +test("script preview: codebase script (ESM)", async () => { + await withTestBackend(async (backend, tempDir) => { + await createWmillConfig(tempDir, { + defaultTs: "bun", + codebases: [{ relative_path: "f/codebase_esm", includes: ["**"], format: "esm" }], + }); - await createScript( - tempDir, - "f/codebase_esm/esm_script.ts", - `export function main(name: string = "World") { + await createScript( + tempDir, + "f/codebase_esm/esm_script.ts", + `export function main(name: string = "World") { console.log("ESM codebase script running"); return \`Hello from ESM codebase, \${name}!\`; }` - ); + ); - const result = await backend.runCLICommand( - ["script", "preview", "f/codebase_esm/esm_script.ts"], - tempDir - ); + const result = await backend.runCLICommand( + ["script", "preview", "f/codebase_esm/esm_script.ts"], + tempDir + ); - assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`); - assertStringIncludes(result.stdout + result.stderr, "Hello from ESM codebase, World!"); - }); - }, - sanitizeResources: false, - sanitizeOps: false, + expect(result.code).toEqual(0); + expect(result.stdout + result.stderr).toContain("Hello from ESM codebase, World!"); + }); }); -Deno.test({ - name: "script preview: codebase script with assets (tar)", - async fn() { - await withTestBackend(async (backend, tempDir) => { - await createWmillConfig(tempDir, { - defaultTs: "bun", - codebases: [{ - relative_path: "f/codebase_tar", - includes: ["**"], - assets: [{ from: "f/codebase_tar/data.json", to: "data.json" }], - }], - }); +test("script preview: codebase script with assets (tar)", async () => { + await withTestBackend(async (backend, tempDir) => { + await createWmillConfig(tempDir, { + defaultTs: "bun", + codebases: [{ + relative_path: "f/codebase_tar", + includes: ["**"], + assets: [{ from: "f/codebase_tar/data.json", to: "data.json" }], + }], + }); - // Create asset file - await Deno.mkdir(`${tempDir}/f/codebase_tar`, { recursive: true }); - await Deno.writeTextFile( - `${tempDir}/f/codebase_tar/data.json`, - JSON.stringify({ message: "Hello from asset!" }) - ); + // Create asset file + await mkdir(`${tempDir}/f/codebase_tar`, { recursive: true }); + await writeFile( + `${tempDir}/f/codebase_tar/data.json`, + JSON.stringify({ message: "Hello from asset!" }), + "utf-8" + ); - await createScript( - tempDir, - "f/codebase_tar/tar_script.ts", - `import * as fs from "fs"; + await createScript( + tempDir, + "f/codebase_tar/tar_script.ts", + `import * as fs from "fs"; export function main(name: string = "World") { console.log("Tar codebase script running"); @@ -244,46 +229,42 @@ export function main(name: string = "World") { const parsed = JSON.parse(data); return \`Hello \${name}! Asset says: \${parsed.message}\`; }` - ); + ); - const result = await backend.runCLICommand( - ["script", "preview", "f/codebase_tar/tar_script.ts"], - tempDir - ); + const result = await backend.runCLICommand( + ["script", "preview", "f/codebase_tar/tar_script.ts"], + tempDir + ); - assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`); - assertStringIncludes(result.stdout + result.stderr, "Hello World! Asset says: Hello from asset!"); - }); - }, - sanitizeResources: false, - sanitizeOps: false, + expect(result.code).toEqual(0); + expect(result.stdout + result.stderr).toContain("Hello World! Asset says: Hello from asset!"); + }); }); -Deno.test({ - name: "script preview: codebase script ESM + tar (assets)", - async fn() { - await withTestBackend(async (backend, tempDir) => { - await createWmillConfig(tempDir, { - defaultTs: "bun", - codebases: [{ - relative_path: "f/codebase_esm_tar", - includes: ["**"], - format: "esm", - assets: [{ from: "f/codebase_esm_tar/config.json", to: "config.json" }], - }], - }); +test("script preview: codebase script ESM + tar (assets)", async () => { + await withTestBackend(async (backend, tempDir) => { + await createWmillConfig(tempDir, { + defaultTs: "bun", + codebases: [{ + relative_path: "f/codebase_esm_tar", + includes: ["**"], + format: "esm", + assets: [{ from: "f/codebase_esm_tar/config.json", to: "config.json" }], + }], + }); - // Create asset file - await Deno.mkdir(`${tempDir}/f/codebase_esm_tar`, { recursive: true }); - await Deno.writeTextFile( - `${tempDir}/f/codebase_esm_tar/config.json`, - JSON.stringify({ setting: "esm_tar_value" }) - ); + // Create asset file + await mkdir(`${tempDir}/f/codebase_esm_tar`, { recursive: true }); + await writeFile( + `${tempDir}/f/codebase_esm_tar/config.json`, + JSON.stringify({ setting: "esm_tar_value" }), + "utf-8" + ); - await createScript( - tempDir, - "f/codebase_esm_tar/esm_tar_script.ts", - `import * as fs from "fs"; + await createScript( + tempDir, + "f/codebase_esm_tar/esm_tar_script.ts", + `import * as fs from "fs"; export function main(name: string = "World") { console.log("ESM + tar codebase script running"); @@ -291,68 +272,65 @@ export function main(name: string = "World") { const parsed = JSON.parse(config); return \`Hello \${name}! Config setting: \${parsed.setting}\`; }` - ); + ); - const result = await backend.runCLICommand( - ["script", "preview", "f/codebase_esm_tar/esm_tar_script.ts"], - tempDir - ); + const result = await backend.runCLICommand( + ["script", "preview", "f/codebase_esm_tar/esm_tar_script.ts"], + tempDir + ); - assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`); - assertStringIncludes(result.stdout + result.stderr, "Hello World! Config setting: esm_tar_value"); - }); - }, - sanitizeResources: false, - sanitizeOps: false, + expect(result.code).toEqual(0); + expect(result.stdout + result.stderr).toContain("Hello World! Config setting: esm_tar_value"); + }); }); -Deno.test({ - name: "script preview: codebase with imports (simulates ../shared layout)", - async fn() { - await withTestBackend(async (backend, tempDir) => { - // This test simulates a codebase that could be in a parent directory. - // The structure is: - // tempDir/ - // wmill.yaml (codebase at ".") - // f/ - // lib/ - // helper.ts (shared module) - // main_script.ts (imports helper) - // - // This tests that codebase bundling correctly includes imported modules, - // which is the key functionality needed for ../shared codebases during sync. - // Note: Preview requires valid windmill paths (u/, g/, f/), so we run - // from within the codebase directory. +test("script preview: codebase with imports (simulates ../shared layout)", async () => { + await withTestBackend(async (backend, tempDir) => { + // This test simulates a codebase that could be in a parent directory. + // The structure is: + // tempDir/ + // wmill.yaml (codebase at ".") + // f/ + // lib/ + // helper.ts (shared module) + // main_script.ts (imports helper) + // + // This tests that codebase bundling correctly includes imported modules, + // which is the key functionality needed for ../shared codebases during sync. + // Note: Preview requires valid windmill paths (u/, g/, f/), so we run + // from within the codebase directory. - await createWmillConfig(tempDir, { - defaultTs: "bun", - codebases: [{ relative_path: ".", includes: ["**"] }], - }); + await createWmillConfig(tempDir, { + defaultTs: "bun", + codebases: [{ relative_path: ".", includes: ["**"] }], + }); - // Create helper module - await Deno.mkdir(`${tempDir}/f/lib`, { recursive: true }); - await Deno.writeTextFile( - `${tempDir}/f/lib/helper.ts`, - `export function greet(name: string): string { + // Create helper module + await mkdir(`${tempDir}/f/lib`, { recursive: true }); + await writeFile( + `${tempDir}/f/lib/helper.ts`, + `export function greet(name: string): string { return \`Hello from shared codebase, \${name}!\`; -}` - ); +}`, + "utf-8" + ); - // Create main script that imports the helper - await Deno.writeTextFile( - `${tempDir}/f/lib/main_script.ts`, - `import { greet } from "./helper"; + // Create main script that imports the helper + await writeFile( + `${tempDir}/f/lib/main_script.ts`, + `import { greet } from "./helper"; export function main(name: string = "World") { console.log("Running codebase script with imports"); return greet(name); -}` - ); +}`, + "utf-8" + ); - // Create script metadata - await Deno.writeTextFile( - `${tempDir}/f/lib/main_script.script.yaml`, - `summary: "Test script with imports" + // Create script metadata + await writeFile( + `${tempDir}/f/lib/main_script.script.yaml`, + `summary: "Test script with imports" description: "Test script that imports from helper module" lock: "" schema: @@ -363,64 +341,43 @@ schema: type: string default: "World" required: [] -` - ); +`, + "utf-8" + ); - // Run preview - the script should be bundled with the helper module - const result = await backend.runCLICommand( - ["script", "preview", "f/lib/main_script.ts"], - tempDir - ); + // Run preview - the script should be bundled with the helper module + const result = await backend.runCLICommand( + ["script", "preview", "f/lib/main_script.ts"], + tempDir + ); - assertEquals(result.code, 0, `Preview failed: ${result.stderr}\n${result.stdout}`); - // The script should be bundled (includes the helper) and run successfully - assertStringIncludes( - result.stdout + result.stderr, - "Hello from shared codebase, World!", - `Expected codebase script output not found. Got: ${result.stdout}\n${result.stderr}` - ); - }); - }, - sanitizeResources: false, - sanitizeOps: false, + expect(result.code).toEqual(0); + // The script should be bundled (includes the helper) and run successfully + expect( + result.stdout + result.stderr, + ).toContain("Hello from shared codebase, World!"); + }); }); // ============================================================================= // FLOW PREVIEW TESTS // ============================================================================= -Deno.test({ - name: "flow preview: simple flow", - async fn() { - await withTestBackend(async (backend, tempDir) => { - await createWmillConfig(tempDir, { defaultTs: "bun" }); - await createFlow(tempDir, "f/test/simple_flow.flow", { - summary: "Test flow", - scriptContent: `export function main(name: string = "World") { return \`Flow says: Hello, \${name}!\`; }`, - }); - - const result = await backend.runCLICommand( - ["flow", "preview", "f/test/simple_flow.flow"], - tempDir - ); - - assertEquals(result.code, 0, `Flow preview failed: ${result.stderr}\n${result.stdout}`); - assertStringIncludes(result.stdout + result.stderr, "Flow says: Hello, World!"); +test("flow preview: simple flow", async () => { + await withTestBackend(async (backend, tempDir) => { + await createWmillConfig(tempDir, { defaultTs: "bun" }); + await createFlow(tempDir, "f/test/simple_flow.flow", { + summary: "Test flow", + scriptContent: `export function main(name: string = "World") { return \`Flow says: Hello, \${name}!\`; }`, }); - }, - sanitizeResources: false, - sanitizeOps: false, + + const result = await backend.runCLICommand( + ["flow", "preview", "f/test/simple_flow.flow"], + tempDir + ); + + expect(result.code).toEqual(0); + expect(result.stdout + result.stderr).toContain("Flow says: Hello, World!"); + }); }); -// ============================================================================= -// CLEANUP -// ============================================================================= - -Deno.test({ - name: "cleanup test backend", - async fn() { - await cleanupTestBackend(); - }, - sanitizeResources: false, - sanitizeOps: false, -}); diff --git a/cli/test/raw_app_sync.test.ts b/cli/test/raw_app_sync.test.ts index 8f9d19b588..f77b4b045b 100644 --- a/cli/test/raw_app_sync.test.ts +++ b/cli/test/raw_app_sync.test.ts @@ -1,8 +1,8 @@ -import { assertEquals, assert, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; -import * as path from "https://deno.land/std@0.224.0/path/mod.ts"; -import { ensureDir } from "https://deno.land/std@0.224.0/fs/mod.ts"; +import * as path from "node:path"; +import { writeFile, readFile, stat, rm, mkdir } from "node:fs/promises"; // ============================================================================= // RAW APP SYNC TESTS @@ -92,7 +92,7 @@ policy: async function fileExists(filePath: string): Promise { try { - await Deno.stat(filePath); + await stat(filePath); return true; } catch { return false; @@ -100,7 +100,7 @@ async function fileExists(filePath: string): Promise { } async function readFileContent(filePath: string): Promise { - return await Deno.readTextFile(filePath); + return await readFile(filePath, "utf-8"); } /** @@ -108,35 +108,32 @@ async function readFileContent(filePath: string): Promise { * Uses .raw_app folder suffix with raw_app.yaml metadata */ async function createRawAppOnDisk(appDir: string): Promise { - await ensureDir(appDir); - await ensureDir(path.join(appDir, "inline_scripts")); + await mkdir(appDir, { recursive: true }); + await mkdir(path.join(appDir, "inline_scripts"), { recursive: true }); // Create raw_app.yaml metadata file - await Deno.writeTextFile(path.join(appDir, "raw_app.yaml"), RAW_APP_YAML); + await writeFile(path.join(appDir, "raw_app.yaml"), RAW_APP_YAML, "utf-8"); // Create app source files - await Deno.writeTextFile(path.join(appDir, "App.tsx"), APP_TSX); - await Deno.writeTextFile(path.join(appDir, "index.css"), INDEX_CSS); - await Deno.writeTextFile(path.join(appDir, "index.tsx"), INDEX_TSX); - await Deno.writeTextFile(path.join(appDir, "package.json"), PACKAGE_JSON); + await writeFile(path.join(appDir, "App.tsx"), APP_TSX, "utf-8"); + await writeFile(path.join(appDir, "index.css"), INDEX_CSS, "utf-8"); + await writeFile(path.join(appDir, "index.tsx"), INDEX_TSX, "utf-8"); + await writeFile(path.join(appDir, "package.json"), PACKAGE_JSON, "utf-8"); // Create inline script in inline_scripts folder - await Deno.writeTextFile( + await writeFile( path.join(appDir, "inline_scripts", "a.inline_script.ts"), - INLINE_SCRIPT_A + INLINE_SCRIPT_A, + "utf-8" ); - await Deno.writeTextFile( + await writeFile( path.join(appDir, "inline_scripts", "a.inline_script.lock"), - INLINE_SCRIPT_A_LOCK + INLINE_SCRIPT_A_LOCK, + "utf-8" ); } -Deno.test({ - name: "Raw App: full sync workflow - push, pull, modify, push, clear, pull", - ignore: false, - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Raw App: full sync workflow - push, pull, modify, push, clear, pull", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -148,14 +145,14 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" -excludes: []`); +excludes: []`, "utf-8"); // Create folder structure const appDir = path.join(tempDir, "f", "test", "my_raw_app.raw_app"); - await ensureDir(path.join(tempDir, "f", "test")); + await mkdir(path.join(tempDir, "f", "test"), { recursive: true }); await createRawAppOnDisk(appDir); // ========================================================================= @@ -166,23 +163,23 @@ excludes: []`); '--yes' ], tempDir, "raw_app_test"); - assertEquals(pushResult1.code, 0, `Initial sync push should succeed: ${pushResult1.stderr}`); + expect(pushResult1.code).toEqual(0); // ========================================================================= // STEP 2: Clear disk and pull - verify raw app is pulled correctly // ========================================================================= - await Deno.remove(appDir, { recursive: true }); - assert(!(await fileExists(appDir)), "App directory should be deleted before pull"); + await rm(appDir, { recursive: true }); + expect(!(await fileExists(appDir))).toBeTruthy(); const pullResult1 = await backend.runCLICommand([ 'sync', 'pull', '--yes' ], tempDir, "raw_app_test"); - assertEquals(pullResult1.code, 0, `Sync pull should succeed: ${pullResult1.stderr}`); + expect(pullResult1.code).toEqual(0); // Verify raw app directory structure was created - assert(await fileExists(appDir), `Raw app directory should exist at ${appDir}`); + expect(await fileExists(appDir)).toBeTruthy(); // Verify files were pulled const appTsxPath = path.join(appDir, "App.tsx"); @@ -191,22 +188,22 @@ excludes: []`); const packageJsonPath = path.join(appDir, "package.json"); const inlineScriptPath = path.join(appDir, "inline_scripts", "a.inline_script.ts"); - assert(await fileExists(appTsxPath), "App.tsx should exist"); - assert(await fileExists(indexCssPath), "index.css should exist"); - assert(await fileExists(indexTsxPath), "index.tsx should exist"); - assert(await fileExists(packageJsonPath), "package.json should exist"); - assert(await fileExists(inlineScriptPath), "Inline script a.inline_script.ts should exist"); + expect(await fileExists(appTsxPath)).toBeTruthy(); + expect(await fileExists(indexCssPath)).toBeTruthy(); + expect(await fileExists(indexTsxPath)).toBeTruthy(); + expect(await fileExists(packageJsonPath)).toBeTruthy(); + expect(await fileExists(inlineScriptPath)).toBeTruthy(); // Verify file contents const appTsxContent = await readFileContent(appTsxPath); - assertStringIncludes(appTsxContent, "hello world", "App.tsx should contain 'hello world'"); - assertStringIncludes(appTsxContent, "backend.a", "App.tsx should reference backend.a"); + expect(appTsxContent).toContain("hello world"); + expect(appTsxContent).toContain("backend.a"); const indexCssContent = await readFileContent(indexCssPath); - assertStringIncludes(indexCssContent, ".myclass", "index.css should contain .myclass"); + expect(indexCssContent).toContain(".myclass"); const inlineScriptContent = await readFileContent(inlineScriptPath); - assertStringIncludes(inlineScriptContent, "export async function main", "Inline script should have main function"); + expect(inlineScriptContent).toContain("export async function main"); // ========================================================================= // STEP 3: Modify files locally @@ -214,15 +211,15 @@ excludes: []`); // Modify App.tsx - change the heading const modifiedAppTsx = appTsxContent.replace("hello world", "hello modified world"); - await Deno.writeTextFile(appTsxPath, modifiedAppTsx); + await writeFile(appTsxPath, modifiedAppTsx, "utf-8"); // Modify index.css - change the border color const modifiedIndexCss = indexCssContent.replace("gray", "blue"); - await Deno.writeTextFile(indexCssPath, modifiedIndexCss); + await writeFile(indexCssPath, modifiedIndexCss, "utf-8"); // Modify inline script - change the return value const modifiedInlineScript = inlineScriptContent.replace("return x", "return `modified: ${x}`"); - await Deno.writeTextFile(inlineScriptPath, modifiedInlineScript); + await writeFile(inlineScriptPath, modifiedInlineScript, "utf-8"); // ========================================================================= // STEP 4: Push changes @@ -232,13 +229,13 @@ excludes: []`); '--yes' ], tempDir, "raw_app_test"); - assertEquals(pushResult2.code, 0, `Sync push should succeed: ${pushResult2.stderr}`); + expect(pushResult2.code).toEqual(0); // ========================================================================= // STEP 5: Clear disk (delete the app directory) // ========================================================================= - await Deno.remove(appDir, { recursive: true }); - assert(!(await fileExists(appDir)), "App directory should be deleted"); + await rm(appDir, { recursive: true }); + expect(!(await fileExists(appDir))).toBeTruthy(); // ========================================================================= // STEP 6: Pull again and verify modifications persisted @@ -248,37 +245,31 @@ excludes: []`); '--yes' ], tempDir, "raw_app_test"); - assertEquals(pullResult2.code, 0, `Second sync pull should succeed: ${pullResult2.stderr}`); + expect(pullResult2.code).toEqual(0); // Verify app directory exists again - assert(await fileExists(appDir), "Raw app directory should exist after second pull"); + expect(await fileExists(appDir)).toBeTruthy(); // Verify all files were pulled again - assert(await fileExists(appTsxPath), "App.tsx should exist after second pull"); - assert(await fileExists(indexCssPath), "index.css should exist after second pull"); - assert(await fileExists(indexTsxPath), "index.tsx should exist after second pull"); - assert(await fileExists(packageJsonPath), "package.json should exist after second pull"); - assert(await fileExists(inlineScriptPath), "Inline script should exist after second pull"); + expect(await fileExists(appTsxPath)).toBeTruthy(); + expect(await fileExists(indexCssPath)).toBeTruthy(); + expect(await fileExists(indexTsxPath)).toBeTruthy(); + expect(await fileExists(packageJsonPath)).toBeTruthy(); + expect(await fileExists(inlineScriptPath)).toBeTruthy(); // Verify modifications were persisted const pulledAppTsx = await readFileContent(appTsxPath); - assertStringIncludes(pulledAppTsx, "hello modified world", "Modifications to App.tsx should persist"); + expect(pulledAppTsx).toContain("hello modified world"); const pulledIndexCss = await readFileContent(indexCssPath); - assertStringIncludes(pulledIndexCss, "blue", "Modifications to index.css should persist"); + expect(pulledIndexCss).toContain("blue"); const pulledInlineScript = await readFileContent(inlineScriptPath); - assertStringIncludes(pulledInlineScript, "modified:", "Modifications to inline script should persist"); + expect(pulledInlineScript).toContain("modified:"); }); - } }); -Deno.test({ - name: "Raw App: add new file and push", - ignore: false, - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Raw App: add new file and push", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -290,14 +281,14 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" -excludes: []`); +excludes: []`, "utf-8"); // Create initial raw app const appDir = path.join(tempDir, "f", "test", "new_file_app.raw_app"); - await ensureDir(path.join(tempDir, "f", "test")); + await mkdir(path.join(tempDir, "f", "test"), { recursive: true }); await createRawAppOnDisk(appDir); // Initial push @@ -306,14 +297,14 @@ excludes: []`); '--yes' ], tempDir, "raw_app_new_file_test"); - assertEquals(pushResult1.code, 0, `Initial sync push should succeed: ${pushResult1.stderr}`); + expect(pushResult1.code).toEqual(0); // Add a new file const newFilePath = path.join(appDir, "utils.ts"); - await Deno.writeTextFile(newFilePath, `export function formatValue(val: string): string { + await writeFile(newFilePath, `export function formatValue(val: string): string { return \`Formatted: \${val}\`; } -`); +`, "utf-8"); // Push changes const pushResult2 = await backend.runCLICommand([ @@ -321,32 +312,26 @@ excludes: []`); '--yes' ], tempDir, "raw_app_new_file_test"); - assertEquals(pushResult2.code, 0, `Sync push with new file should succeed: ${pushResult2.stderr}`); + expect(pushResult2.code).toEqual(0); // Clear and pull again - await Deno.remove(appDir, { recursive: true }); + await rm(appDir, { recursive: true }); const pullResult = await backend.runCLICommand([ 'sync', 'pull', '--yes' ], tempDir, "raw_app_new_file_test"); - assertEquals(pullResult.code, 0, `Sync pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Verify new file was persisted - assert(await fileExists(newFilePath), "New file utils.ts should exist after pull"); + expect(await fileExists(newFilePath)).toBeTruthy(); const newFileContent = await readFileContent(newFilePath); - assertStringIncludes(newFileContent, "formatValue", "New file content should persist"); + expect(newFileContent).toContain("formatValue"); }); - } }); -Deno.test({ - name: "Raw App: delete file and push", - ignore: false, - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Raw App: delete file and push", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -358,14 +343,14 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" -excludes: []`); +excludes: []`, "utf-8"); // Create initial raw app const appDir = path.join(tempDir, "f", "test", "delete_file_app.raw_app"); - await ensureDir(path.join(tempDir, "f", "test")); + await mkdir(path.join(tempDir, "f", "test"), { recursive: true }); await createRawAppOnDisk(appDir); // Initial push @@ -374,20 +359,20 @@ excludes: []`); '--yes' ], tempDir, "raw_app_delete_file_test"); - assertEquals(pushResult1.code, 0, `Initial sync push should succeed: ${pushResult1.stderr}`); + expect(pushResult1.code).toEqual(0); const indexCssPath = path.join(appDir, "index.css"); const appTsxPath = path.join(appDir, "App.tsx"); - assert(await fileExists(indexCssPath), "index.css should exist after initial push"); + expect(await fileExists(indexCssPath)).toBeTruthy(); // First, update App.tsx to remove the CSS import (otherwise bundle will fail) const appTsxContent = await readFileContent(appTsxPath); const updatedAppTsx = appTsxContent.replace("import './index.css'\n", ""); - await Deno.writeTextFile(appTsxPath, updatedAppTsx); + await writeFile(appTsxPath, updatedAppTsx, "utf-8"); // Delete the CSS file - await Deno.remove(indexCssPath); - assert(!(await fileExists(indexCssPath)), "index.css should be deleted locally"); + await rm(indexCssPath); + expect(!(await fileExists(indexCssPath))).toBeTruthy(); // Push changes const pushResult2 = await backend.runCLICommand([ @@ -395,33 +380,27 @@ excludes: []`); '--yes' ], tempDir, "raw_app_delete_file_test"); - assertEquals(pushResult2.code, 0, `Sync push after delete should succeed: ${pushResult2.stderr}`); + expect(pushResult2.code).toEqual(0); // Clear and pull again - await Deno.remove(appDir, { recursive: true }); + await rm(appDir, { recursive: true }); const pullResult = await backend.runCLICommand([ 'sync', 'pull', '--yes' ], tempDir, "raw_app_delete_file_test"); - assertEquals(pullResult.code, 0, `Sync pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Verify the deleted file is NOT pulled (it was deleted from backend) - assert(!(await fileExists(indexCssPath)), "Deleted index.css should not exist after pull"); + expect(!(await fileExists(indexCssPath))).toBeTruthy(); // But other files should still exist - assert(await fileExists(appTsxPath), "App.tsx should still exist after pull"); + expect(await fileExists(appTsxPath)).toBeTruthy(); }); - } }); -Deno.test({ - name: "Raw App: dry-run push shows expected changes", - ignore: false, - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Raw App: dry-run push shows expected changes", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -433,14 +412,14 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" -excludes: []`); +excludes: []`, "utf-8"); // Create raw app const appDir = path.join(tempDir, "f", "test", "dry_run_app.raw_app"); - await ensureDir(path.join(tempDir, "f", "test")); + await mkdir(path.join(tempDir, "f", "test"), { recursive: true }); await createRawAppOnDisk(appDir); // Dry-run push @@ -450,7 +429,7 @@ excludes: []`); '--json-output' ], tempDir, "raw_app_dry_run_test"); - assertEquals(dryRunResult.code, 0, `Dry-run push should succeed: ${dryRunResult.stderr}`); + expect(dryRunResult.code).toEqual(0); // Parse JSON output (may be pretty-printed across multiple lines) let jsonOutput = null; @@ -469,13 +448,12 @@ excludes: []`); } } - assert(jsonOutput !== null, `Should have JSON output. Got: ${dryRunResult.stdout}`); - assert(Array.isArray(jsonOutput.changes), `Should have changes array. Got: ${JSON.stringify(jsonOutput)}`); + expect(jsonOutput !== null).toBeTruthy(); + expect(Array.isArray(jsonOutput.changes)).toBeTruthy(); // Should include raw app in changes const changePaths = jsonOutput.changes.map((c: any) => c.path); const hasRawApp = changePaths.some((p: string) => p.includes("dry_run_app")); - assert(hasRawApp, `Dry-run should show raw app. Found: ${changePaths.join(', ')}`); + expect(hasRawApp).toBeTruthy(); }); - } }); diff --git a/cli/test/resource_folders_unit.test.ts b/cli/test/resource_folders_unit.test.ts new file mode 100644 index 0000000000..cbcf6d72ea --- /dev/null +++ b/cli/test/resource_folders_unit.test.ts @@ -0,0 +1,525 @@ +/** + * Unit tests for resource_folders.ts path detection and manipulation functions. + * Tests both dotted (.flow, .app, .raw_app) and non-dotted (__flow, __app, __raw_app) modes. + */ + +import { expect, test, describe, beforeEach } from "bun:test"; +import { + setNonDottedPaths, + getNonDottedPaths, + getFolderSuffixes, + getFolderSuffix, + getMetadataFileName, + getMetadataPathSuffix, + isFlowPath, + isAppPath, + isRawAppPath, + isFolderResourcePath, + detectFolderResourceType, + isRawAppBackendPath, + isAppInlineScriptPath, + isFlowInlineScriptPath, + extractResourceName, + extractFolderPath, + buildFolderPath, + buildMetadataPath, + hasFolderSuffix, + validateFolderName, + extractNameFromFolder, + isFlowMetadataFile, + isAppMetadataFile, + isRawAppMetadataFile, + isRawAppFolderMetadataFile, + getDeleteSuffix, + transformJsonPathToDir, +} from "../src/utils/resource_folders.ts"; +import { removeWorkerPrefix } from "../src/commands/worker-groups/worker-groups.ts"; + +// ============================================================================= +// Helper: reset to dotted mode before each test +// ============================================================================= + +beforeEach(() => { + setNonDottedPaths(false); +}); + +// ============================================================================= +// Configuration Functions +// ============================================================================= + +describe("setNonDottedPaths / getNonDottedPaths", () => { + test("defaults to false (dotted)", () => { + expect(getNonDottedPaths()).toBe(false); + }); + + test("can be set to true", () => { + setNonDottedPaths(true); + expect(getNonDottedPaths()).toBe(true); + }); + + test("can be toggled back to false", () => { + setNonDottedPaths(true); + setNonDottedPaths(false); + expect(getNonDottedPaths()).toBe(false); + }); +}); + +describe("getFolderSuffixes", () => { + test("returns dotted suffixes by default", () => { + const suffixes = getFolderSuffixes(); + expect(suffixes.flow).toBe(".flow"); + expect(suffixes.app).toBe(".app"); + expect(suffixes.raw_app).toBe(".raw_app"); + }); + + test("returns non-dotted suffixes when configured", () => { + setNonDottedPaths(true); + const suffixes = getFolderSuffixes(); + expect(suffixes.flow).toBe("__flow"); + expect(suffixes.app).toBe("__app"); + expect(suffixes.raw_app).toBe("__raw_app"); + }); +}); + +describe("getFolderSuffix", () => { + test("returns correct suffix for each type (dotted)", () => { + expect(getFolderSuffix("flow")).toBe(".flow"); + expect(getFolderSuffix("app")).toBe(".app"); + expect(getFolderSuffix("raw_app")).toBe(".raw_app"); + }); + + test("returns correct suffix for each type (non-dotted)", () => { + setNonDottedPaths(true); + expect(getFolderSuffix("flow")).toBe("__flow"); + expect(getFolderSuffix("app")).toBe("__app"); + expect(getFolderSuffix("raw_app")).toBe("__raw_app"); + }); +}); + +// ============================================================================= +// Metadata File Names +// ============================================================================= + +describe("getMetadataFileName", () => { + test("returns correct metadata file names", () => { + expect(getMetadataFileName("flow", "yaml")).toBe("flow.yaml"); + expect(getMetadataFileName("flow", "json")).toBe("flow.json"); + expect(getMetadataFileName("app", "yaml")).toBe("app.yaml"); + expect(getMetadataFileName("app", "json")).toBe("app.json"); + expect(getMetadataFileName("raw_app", "yaml")).toBe("raw_app.yaml"); + expect(getMetadataFileName("raw_app", "json")).toBe("raw_app.json"); + }); +}); + +describe("getMetadataPathSuffix", () => { + test("returns correct path suffix (dotted)", () => { + expect(getMetadataPathSuffix("flow", "yaml")).toBe(".flow/flow.yaml"); + expect(getMetadataPathSuffix("app", "json")).toBe(".app/app.json"); + expect(getMetadataPathSuffix("raw_app", "yaml")).toBe(".raw_app/raw_app.yaml"); + }); + + test("returns correct path suffix (non-dotted)", () => { + setNonDottedPaths(true); + expect(getMetadataPathSuffix("flow", "yaml")).toBe("__flow/flow.yaml"); + expect(getMetadataPathSuffix("app", "json")).toBe("__app/app.json"); + expect(getMetadataPathSuffix("raw_app", "yaml")).toBe("__raw_app/raw_app.yaml"); + }); +}); + +// ============================================================================= +// Path Detection Functions (dotted mode) +// ============================================================================= + +describe("isFlowPath (dotted)", () => { + test("detects flow paths", () => { + expect(isFlowPath("f/my_flow.flow/flow.yaml")).toBe(true); + expect(isFlowPath("u/admin/test.flow/step.ts")).toBe(true); + }); + + test("rejects non-flow paths", () => { + expect(isFlowPath("f/my_script.ts")).toBe(false); + expect(isFlowPath("f/my_app.app/app.yaml")).toBe(false); + }); +}); + +describe("isAppPath (dotted)", () => { + test("detects app paths", () => { + expect(isAppPath("f/my_app.app/app.yaml")).toBe(true); + expect(isAppPath("u/admin/dashboard.app/inline.ts")).toBe(true); + }); + + test("rejects non-app paths", () => { + expect(isAppPath("f/my_script.ts")).toBe(false); + expect(isAppPath("f/my_flow.flow/flow.yaml")).toBe(false); + }); +}); + +describe("isRawAppPath (dotted)", () => { + test("detects raw_app paths", () => { + expect(isRawAppPath("f/my_raw.raw_app/raw_app.yaml")).toBe(true); + }); + + test("rejects non-raw_app paths", () => { + expect(isRawAppPath("f/my_app.app/app.yaml")).toBe(false); + expect(isRawAppPath("f/my_script.ts")).toBe(false); + }); +}); + +// ============================================================================= +// Path Detection Functions (non-dotted mode) +// ============================================================================= + +describe("isFlowPath (non-dotted)", () => { + test("detects non-dotted flow paths", () => { + setNonDottedPaths(true); + expect(isFlowPath("f/my_flow__flow/flow.yaml")).toBe(true); + }); + + test("rejects dotted flow paths in non-dotted mode", () => { + setNonDottedPaths(true); + expect(isFlowPath("f/my_flow.flow/flow.yaml")).toBe(false); + }); +}); + +describe("isAppPath (non-dotted)", () => { + test("detects non-dotted app paths", () => { + setNonDottedPaths(true); + expect(isAppPath("f/my_app__app/app.yaml")).toBe(true); + }); +}); + +describe("isRawAppPath (non-dotted)", () => { + test("detects non-dotted raw_app paths", () => { + setNonDottedPaths(true); + expect(isRawAppPath("f/my_raw__raw_app/raw_app.yaml")).toBe(true); + }); +}); + +// ============================================================================= +// Composite Path Detection +// ============================================================================= + +describe("isFolderResourcePath", () => { + test("returns true for any folder resource path", () => { + expect(isFolderResourcePath("f/x.flow/flow.yaml")).toBe(true); + expect(isFolderResourcePath("f/x.app/app.yaml")).toBe(true); + expect(isFolderResourcePath("f/x.raw_app/raw_app.yaml")).toBe(true); + }); + + test("returns false for non-folder paths", () => { + expect(isFolderResourcePath("f/script.ts")).toBe(false); + expect(isFolderResourcePath("f/var.variable.yaml")).toBe(false); + }); +}); + +describe("detectFolderResourceType", () => { + test("detects flow type", () => { + expect(detectFolderResourceType("f/x.flow/flow.yaml")).toBe("flow"); + }); + + test("detects app type", () => { + expect(detectFolderResourceType("f/x.app/app.yaml")).toBe("app"); + }); + + test("detects raw_app type", () => { + expect(detectFolderResourceType("f/x.raw_app/raw_app.yaml")).toBe("raw_app"); + }); + + test("returns null for non-folder paths", () => { + expect(detectFolderResourceType("f/script.ts")).toBeNull(); + }); +}); + +// ============================================================================= +// Inline Script / Backend Path Detection +// ============================================================================= + +describe("isRawAppBackendPath", () => { + test("detects raw app backend paths (dotted)", () => { + expect(isRawAppBackendPath("f/my_app.raw_app/backend/handler.ts")).toBe(true); + }); + + test("rejects non-backend raw app paths", () => { + expect(isRawAppBackendPath("f/my_app.raw_app/raw_app.yaml")).toBe(false); + }); + + test("detects raw app backend paths (non-dotted)", () => { + setNonDottedPaths(true); + expect(isRawAppBackendPath("f/my_app__raw_app/backend/handler.ts")).toBe(true); + }); +}); + +describe("isAppInlineScriptPath", () => { + test("detects inline script paths in apps", () => { + expect(isAppInlineScriptPath("f/dashboard.app/inline_0.ts")).toBe(true); + }); + + test("rejects non-app paths", () => { + expect(isAppInlineScriptPath("f/script.ts")).toBe(false); + }); +}); + +describe("isFlowInlineScriptPath", () => { + test("detects inline script paths in flows", () => { + expect(isFlowInlineScriptPath("f/pipeline.flow/step_0.ts")).toBe(true); + }); + + test("rejects non-flow paths", () => { + expect(isFlowInlineScriptPath("f/script.ts")).toBe(false); + }); +}); + +// ============================================================================= +// Path Manipulation Functions +// ============================================================================= + +describe("extractResourceName", () => { + test("extracts name from flow path", () => { + expect(extractResourceName("f/my_flow.flow/flow.yaml", "flow")).toBe("f/my_flow"); + }); + + test("extracts name from app path", () => { + expect(extractResourceName("f/dashboard.app/app.yaml", "app")).toBe("f/dashboard"); + }); + + test("extracts name from raw_app path", () => { + expect(extractResourceName("f/my_raw.raw_app/raw_app.yaml", "raw_app")).toBe("f/my_raw"); + }); + + test("returns null when type doesn't match", () => { + expect(extractResourceName("f/script.ts", "flow")).toBeNull(); + }); + + test("works in non-dotted mode", () => { + setNonDottedPaths(true); + expect(extractResourceName("f/my_flow__flow/flow.yaml", "flow")).toBe("f/my_flow"); + }); +}); + +describe("extractFolderPath", () => { + test("extracts folder path from flow", () => { + expect(extractFolderPath("f/my_flow.flow/flow.yaml", "flow")).toBe("f/my_flow.flow/"); + }); + + test("returns null when type doesn't match", () => { + expect(extractFolderPath("f/script.ts", "flow")).toBeNull(); + }); +}); + +describe("buildFolderPath", () => { + test("builds folder path (dotted)", () => { + expect(buildFolderPath("f/my_flow", "flow")).toBe("f/my_flow.flow"); + expect(buildFolderPath("f/dashboard", "app")).toBe("f/dashboard.app"); + expect(buildFolderPath("f/my_raw", "raw_app")).toBe("f/my_raw.raw_app"); + }); + + test("builds folder path (non-dotted)", () => { + setNonDottedPaths(true); + expect(buildFolderPath("f/my_flow", "flow")).toBe("f/my_flow__flow"); + expect(buildFolderPath("f/dashboard", "app")).toBe("f/dashboard__app"); + expect(buildFolderPath("f/my_raw", "raw_app")).toBe("f/my_raw__raw_app"); + }); +}); + +describe("buildMetadataPath", () => { + test("builds metadata path (dotted, yaml)", () => { + expect(buildMetadataPath("f/my_flow", "flow", "yaml")).toBe("f/my_flow.flow/flow.yaml"); + }); + + test("builds metadata path (dotted, json)", () => { + expect(buildMetadataPath("f/dashboard", "app", "json")).toBe("f/dashboard.app/app.json"); + }); + + test("builds metadata path (non-dotted)", () => { + setNonDottedPaths(true); + expect(buildMetadataPath("f/my_flow", "flow", "yaml")).toBe("f/my_flow__flow/flow.yaml"); + }); +}); + +// ============================================================================= +// Folder Validation Functions +// ============================================================================= + +describe("hasFolderSuffix", () => { + test("returns true for matching suffix", () => { + expect(hasFolderSuffix("my_flow.flow", "flow")).toBe(true); + expect(hasFolderSuffix("dashboard.app", "app")).toBe(true); + expect(hasFolderSuffix("my_raw.raw_app", "raw_app")).toBe(true); + }); + + test("returns false for non-matching suffix", () => { + expect(hasFolderSuffix("my_flow.app", "flow")).toBe(false); + expect(hasFolderSuffix("script.ts", "flow")).toBe(false); + }); + + test("works in non-dotted mode", () => { + setNonDottedPaths(true); + expect(hasFolderSuffix("my_flow__flow", "flow")).toBe(true); + expect(hasFolderSuffix("my_flow.flow", "flow")).toBe(false); + }); +}); + +describe("validateFolderName", () => { + test("returns null for valid folder name", () => { + expect(validateFolderName("my_flow.flow", "flow")).toBeNull(); + }); + + test("returns error message for invalid folder name", () => { + const result = validateFolderName("my_flow.app", "flow"); + expect(result).not.toBeNull(); + expect(result).toContain("my_flow.app"); + expect(result).toContain(".flow"); + }); +}); + +describe("extractNameFromFolder", () => { + test("extracts name by removing suffix (dotted)", () => { + expect(extractNameFromFolder("my_flow.flow", "flow")).toBe("my_flow"); + expect(extractNameFromFolder("dashboard.app", "app")).toBe("dashboard"); + expect(extractNameFromFolder("my_raw.raw_app", "raw_app")).toBe("my_raw"); + }); + + test("returns original name if suffix doesn't match", () => { + expect(extractNameFromFolder("my_script", "flow")).toBe("my_script"); + }); + + test("extracts name (non-dotted)", () => { + setNonDottedPaths(true); + expect(extractNameFromFolder("my_flow__flow", "flow")).toBe("my_flow"); + }); +}); + +// ============================================================================= +// Metadata File Detection Functions +// ============================================================================= + +describe("isFlowMetadataFile", () => { + test("detects dotted flow metadata files", () => { + expect(isFlowMetadataFile("f/my_flow.flow.json")).toBe(true); + expect(isFlowMetadataFile("f/my_flow.flow.yaml")).toBe(true); + }); + + test("rejects non-flow metadata files", () => { + expect(isFlowMetadataFile("f/my_app.app.json")).toBe(false); + expect(isFlowMetadataFile("f/script.ts")).toBe(false); + }); + + test("detects non-dotted flow metadata files when configured", () => { + setNonDottedPaths(true); + expect(isFlowMetadataFile("f/my_flow__flow.json")).toBe(true); + expect(isFlowMetadataFile("f/my_flow__flow.yaml")).toBe(true); + // API format (dotted) is always detected + expect(isFlowMetadataFile("f/my_flow.flow.json")).toBe(true); + }); +}); + +describe("isAppMetadataFile", () => { + test("detects dotted app metadata files", () => { + expect(isAppMetadataFile("f/dashboard.app.json")).toBe(true); + expect(isAppMetadataFile("f/dashboard.app.yaml")).toBe(true); + }); + + test("rejects non-app metadata files", () => { + expect(isAppMetadataFile("f/my_flow.flow.json")).toBe(false); + }); + + test("detects non-dotted app metadata files when configured", () => { + setNonDottedPaths(true); + expect(isAppMetadataFile("f/dashboard__app.json")).toBe(true); + // API format always detected + expect(isAppMetadataFile("f/dashboard.app.json")).toBe(true); + }); +}); + +describe("isRawAppMetadataFile", () => { + test("detects dotted raw_app metadata files", () => { + expect(isRawAppMetadataFile("f/my_raw.raw_app.json")).toBe(true); + expect(isRawAppMetadataFile("f/my_raw.raw_app.yaml")).toBe(true); + }); + + test("rejects non-raw_app metadata files", () => { + expect(isRawAppMetadataFile("f/my_app.app.json")).toBe(false); + }); + + test("detects non-dotted raw_app metadata files when configured", () => { + setNonDottedPaths(true); + expect(isRawAppMetadataFile("f/my_raw__raw_app.json")).toBe(true); + expect(isRawAppMetadataFile("f/my_raw.raw_app.json")).toBe(true); + }); +}); + +describe("isRawAppFolderMetadataFile", () => { + test("detects raw_app folder metadata file (dotted)", () => { + expect(isRawAppFolderMetadataFile("f/my_raw.raw_app/raw_app.yaml")).toBe(true); + expect(isRawAppFolderMetadataFile("f/my_raw.raw_app/raw_app.json")).toBe(true); + }); + + test("rejects non-metadata files", () => { + expect(isRawAppFolderMetadataFile("f/my_raw.raw_app/backend/handler.ts")).toBe(false); + }); +}); + +// ============================================================================= +// Sync-related Path Functions +// ============================================================================= + +describe("getDeleteSuffix", () => { + test("returns correct delete suffix", () => { + expect(getDeleteSuffix("flow", "yaml")).toBe(".flow/flow.yaml"); + expect(getDeleteSuffix("app", "json")).toBe(".app/app.json"); + expect(getDeleteSuffix("raw_app", "yaml")).toBe(".raw_app/raw_app.yaml"); + }); + + test("returns correct delete suffix (non-dotted)", () => { + setNonDottedPaths(true); + expect(getDeleteSuffix("flow", "yaml")).toBe("__flow/flow.yaml"); + }); +}); + +describe("transformJsonPathToDir", () => { + test("transforms API dotted .flow.json to dotted dir", () => { + expect(transformJsonPathToDir("f/my_flow.flow.json", "flow")).toBe("f/my_flow.flow"); + }); + + test("transforms API dotted .app.json to dotted dir", () => { + expect(transformJsonPathToDir("f/dashboard.app.json", "app")).toBe("f/dashboard.app"); + }); + + test("transforms API dotted to non-dotted dir when configured", () => { + setNonDottedPaths(true); + expect(transformJsonPathToDir("f/my_flow.flow.json", "flow")).toBe("f/my_flow__flow"); + }); + + test("handles already-configured format", () => { + setNonDottedPaths(true); + expect(transformJsonPathToDir("f/my_flow__flow.json", "flow")).toBe("f/my_flow__flow"); + }); + + test("returns unchanged path when suffix doesn't match", () => { + expect(transformJsonPathToDir("f/script.ts", "flow")).toBe("f/script.ts"); + }); +}); + +// ============================================================================= +// removeWorkerPrefix (from worker-groups.ts) +// ============================================================================= + +describe("removeWorkerPrefix", () => { + test("removes worker__ prefix", () => { + expect(removeWorkerPrefix("worker__default")).toBe("default"); + expect(removeWorkerPrefix("worker__gpu")).toBe("gpu"); + }); + + test("returns name unchanged if no prefix", () => { + expect(removeWorkerPrefix("default")).toBe("default"); + expect(removeWorkerPrefix("gpu")).toBe("gpu"); + }); + + test("handles empty string", () => { + expect(removeWorkerPrefix("")).toBe(""); + }); + + test("handles worker__ as the entire name", () => { + expect(removeWorkerPrefix("worker__")).toBe(""); + }); +}); diff --git a/cli/test/script_envs_sync.test.ts b/cli/test/script_envs_sync.test.ts index 6e0c03bea1..aa515cb5bd 100644 --- a/cli/test/script_envs_sync.test.ts +++ b/cli/test/script_envs_sync.test.ts @@ -7,21 +7,17 @@ * the env variables aren't there anymore. */ -import { assertEquals, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { ensureDir } from "https://deno.land/std@0.224.0/fs/mod.ts"; +import { expect, test } from "bun:test"; +import { writeFile, readFile, mkdir } from "node:fs/promises"; import { withTestBackend } from "./test_backend.ts"; -Deno.test({ - name: "Integration: Script envs field is preserved during sync pull/push cycle", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Script envs field is preserved during sync pull/push cycle", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/envs_script_${uniqueId}`; // Step 1: Create a script via API with envs set - await ensureDir(`${tempDir}/f/test`); + await mkdir(`${tempDir}/f/test`, { recursive: true }); // Create folder first const folderResp = await backend.apiRequest!(`/api/w/${backend.workspace}/folders/create`, { @@ -46,102 +42,77 @@ Deno.test({ }), }); - assertEquals( - createResp.ok, - true, - `Failed to create script: ${await createResp.text()}`, - ); + expect(createResp.ok).toEqual(true); // Verify the script was created with envs const getResp = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/get/p/${scriptPath}`, ); const createdScriptText = await getResp.text(); - assertEquals(getResp.ok, true, `Failed to get script: ${createdScriptText}`); + expect(getResp.ok).toEqual(true); const createdScript = JSON.parse(createdScriptText); - assertEquals( - createdScript.envs, - ["MY_ENV_VAR", "ANOTHER_VAR"], - "Script should have envs after creation", - ); + expect(createdScript.envs).toEqual(["MY_ENV_VAR", "ANOTHER_VAR"]); // Step 2: Create wmill.yaml and sync pull - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "f/test/envs_script_${uniqueId}**" excludes: [] `, + "utf-8", ); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals( - pullResult.code, - 0, - `Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`, - ); + expect(pullResult.code).toEqual(0); // Verify the pulled metadata contains envs const metadataPath = `${tempDir}/f/test/envs_script_${uniqueId}.script.yaml`; - const metadataContent = await Deno.readTextFile(metadataPath); - assert( + const metadataContent = await readFile(metadataPath, "utf-8"); + expect( metadataContent.includes("envs:") || metadataContent.includes("MY_ENV_VAR") || metadataContent.includes("ANOTHER_VAR"), - `Pulled metadata should contain envs. Content:\n${metadataContent}`, - ); + ).toBeTruthy(); // Step 3: Modify the script locally (change content) const scriptFilePath = `${tempDir}/f/test/envs_script_${uniqueId}.ts`; - const originalContent = await Deno.readTextFile(scriptFilePath); - await Deno.writeTextFile( + const originalContent = await readFile(scriptFilePath, "utf-8"); + await writeFile( scriptFilePath, originalContent.replace("Hello world", "Hello world modified"), + "utf-8", ); // Step 4: Sync push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals( - pushResult.code, - 0, - `Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`, - ); + expect(pushResult.code).toEqual(0); // Step 5: Verify envs are still present on the remote const getResp2 = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/get/p/${scriptPath}`, ); const updatedScriptText = await getResp2.text(); - assertEquals(getResp2.ok, true, `Failed to get script after push: ${updatedScriptText}`); + expect(getResp2.ok).toEqual(true); const updatedScript = JSON.parse(updatedScriptText); - assertEquals( - updatedScript.envs, - ["MY_ENV_VAR", "ANOTHER_VAR"], - `Script envs should be preserved after push. Got: ${JSON.stringify(updatedScript.envs)}`, - ); + expect(updatedScript.envs).toEqual(["MY_ENV_VAR", "ANOTHER_VAR"]); // Also verify the content was updated - assert( + expect( updatedScript.content.includes("Hello world modified"), - "Script content should be updated", - ); + ).toBeTruthy(); }); - }, }); -Deno.test({ - name: "Integration: Script envs field changes are detected and pushed", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Script envs field changes are detected and pushed", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/envs_change_${uniqueId}`; // Create folder - await ensureDir(`${tempDir}/f/test`); + await mkdir(`${tempDir}/f/test`, { recursive: true }); await backend.apiRequest!(`/api/w/${backend.workspace}/folders/create`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -162,25 +133,26 @@ Deno.test({ kind: "script", }), }); - assertEquals(createResp.ok, true, `Failed to create script: ${await createResp.text()}`); + expect(createResp.ok).toEqual(true); // Setup wmill.yaml - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "f/test/envs_change_${uniqueId}**" excludes: [] `, + "utf-8", ); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pullResult.code, 0, `Pull failed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Modify envs in the local metadata file const metadataPath = `${tempDir}/f/test/envs_change_${uniqueId}.script.yaml`; - let metadataContent = await Deno.readTextFile(metadataPath); + let metadataContent = await readFile(metadataPath, "utf-8"); // Replace the envs line(s) if (metadataContent.includes("envs:")) { @@ -193,40 +165,31 @@ excludes: [] // Add envs if not present metadataContent += "\nenvs:\n - NEW_VAR1\n - NEW_VAR2\n"; } - await Deno.writeTextFile(metadataPath, metadataContent); + await writeFile(metadataPath, metadataContent, "utf-8"); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals(pushResult.code, 0, `Push failed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Verify envs were updated on remote const getResp = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/get/p/${scriptPath}`, ); const scriptText = await getResp.text(); - assertEquals(getResp.ok, true, `Failed to get script: ${scriptText}`); + expect(getResp.ok).toEqual(true); const script = JSON.parse(scriptText); - assertEquals( - script.envs, - ["NEW_VAR1", "NEW_VAR2"], - `Script envs should be updated to new values. Got: ${JSON.stringify(script.envs)}`, - ); + expect(script.envs).toEqual(["NEW_VAR1", "NEW_VAR2"]); }); - }, }); -Deno.test({ - name: "Integration: Script with empty envs is handled correctly", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Script with empty envs is handled correctly", async () => { await withTestBackend(async (backend, tempDir) => { const uniqueId = Date.now(); const scriptPath = `f/test/empty_envs_${uniqueId}`; // Create folder - await ensureDir(`${tempDir}/f/test`); + await mkdir(`${tempDir}/f/test`, { recursive: true }); await backend.apiRequest!(`/api/w/${backend.workspace}/folders/create`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -246,32 +209,34 @@ Deno.test({ kind: "script", }), }); - assertEquals(createResp.ok, true, `Failed to create script: ${await createResp.text()}`); + expect(createResp.ok).toEqual(true); // Setup wmill.yaml - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "f/test/empty_envs_${uniqueId}**" excludes: [] `, + "utf-8", ); // Pull const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pullResult.code, 0, `Pull failed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Modify content const scriptFilePath = `${tempDir}/f/test/empty_envs_${uniqueId}.ts`; - await Deno.writeTextFile( + await writeFile( scriptFilePath, `export async function main() {\n return "Modified no envs";\n}`, + "utf-8", ); // Push const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); - assertEquals(pushResult.code, 0, `Push failed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Verify script was updated and envs is still null/empty const getResp = await backend.apiRequest!( @@ -279,16 +244,13 @@ excludes: [] ); const script = await getResp.json(); - assert( + expect( script.content.includes("Modified no envs"), - "Script content should be updated", - ); + ).toBeTruthy(); // envs should be null, empty, or undefined - assert( + expect( !script.envs || script.envs.length === 0, - `Script envs should remain empty. Got: ${JSON.stringify(script.envs)}`, - ); + ).toBeTruthy(); }); - }, }); diff --git a/cli/test/settings_unit.test.ts b/cli/test/settings_unit.test.ts new file mode 100644 index 0000000000..2d6a5249ef --- /dev/null +++ b/cli/test/settings_unit.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for settings.ts pure functions. + * Tests migrateToGroupedFormat which converts legacy flat settings to grouped format. + */ + +import { expect, test, describe } from "bun:test"; +import { migrateToGroupedFormat } from "../src/core/settings.ts"; + +// ============================================================================= +// migrateToGroupedFormat +// ============================================================================= + +describe("migrateToGroupedFormat", () => { + test("migrates legacy auto_invite fields to grouped format", () => { + const legacy = { + name: "my-workspace", + auto_invite_enabled: true, + auto_invite_as: "operator", + auto_invite_mode: "add", + }; + const result = migrateToGroupedFormat(legacy); + expect(result.auto_invite).toEqual({ + enabled: true, + operator: true, + mode: "add", + }); + }); + + test("migrates legacy auto_invite with non-operator role", () => { + const legacy = { + name: "ws", + auto_invite_enabled: true, + auto_invite_as: "developer", + auto_invite_mode: "invite", + }; + const result = migrateToGroupedFormat(legacy); + expect(result.auto_invite).toEqual({ + enabled: true, + operator: false, + mode: "invite", + }); + }); + + test("migrates legacy auto_invite when disabled", () => { + const legacy = { + name: "ws", + auto_invite_enabled: false, + auto_invite_as: "operator", + }; + const result = migrateToGroupedFormat(legacy); + expect(result.auto_invite!.enabled).toBe(false); + }); + + test("preserves already-grouped auto_invite", () => { + const grouped = { + name: "ws", + auto_invite: { enabled: true, operator: false, mode: "invite" as const }, + }; + const result = migrateToGroupedFormat(grouped); + expect(result.auto_invite).toEqual({ + enabled: true, + operator: false, + mode: "invite", + }); + }); + + test("migrates legacy error_handler string to grouped format", () => { + const legacy = { + name: "ws", + error_handler: "u/admin/error_handler", + error_handler_extra_args: { notify: true }, + error_handler_muted_on_cancel: true, + }; + const result = migrateToGroupedFormat(legacy); + expect(result.error_handler).toEqual({ + path: "u/admin/error_handler", + extra_args: { notify: true }, + muted_on_cancel: true, + }); + }); + + test("preserves already-grouped error_handler", () => { + const grouped = { + name: "ws", + error_handler: { + path: "u/admin/handler", + extra_args: {}, + muted_on_cancel: false, + }, + }; + const result = migrateToGroupedFormat(grouped); + expect(result.error_handler).toEqual({ + path: "u/admin/handler", + extra_args: {}, + muted_on_cancel: false, + }); + }); + + test("migrates legacy success_handler string to grouped format", () => { + const legacy = { + name: "ws", + success_handler: "u/admin/on_success", + success_handler_extra_args: { channel: "#deploys" }, + }; + const result = migrateToGroupedFormat(legacy); + expect(result.success_handler).toEqual({ + path: "u/admin/on_success", + extra_args: { channel: "#deploys" }, + }); + }); + + test("preserves already-grouped success_handler", () => { + const grouped = { + name: "ws", + success_handler: { path: "u/admin/handler", extra_args: {} }, + }; + const result = migrateToGroupedFormat(grouped); + expect(result.success_handler).toEqual({ + path: "u/admin/handler", + extra_args: {}, + }); + }); + + test("copies non-legacy fields through", () => { + const settings = { + name: "my-workspace", + webhook: "https://example.com/hook", + deploy_to: "staging", + default_app: "u/admin/dashboard", + mute_critical_alerts: true, + color: "#ff0000", + }; + const result = migrateToGroupedFormat(settings); + expect(result.name).toBe("my-workspace"); + expect(result.webhook).toBe("https://example.com/hook"); + expect(result.deploy_to).toBe("staging"); + expect(result.default_app).toBe("u/admin/dashboard"); + expect(result.mute_critical_alerts).toBe(true); + expect(result.color).toBe("#ff0000"); + }); + + test("handles minimal settings with only name", () => { + const result = migrateToGroupedFormat({ name: "ws" }); + expect(result.name).toBe("ws"); + expect(result.auto_invite).toBeUndefined(); + expect(result.error_handler).toBeUndefined(); + expect(result.success_handler).toBeUndefined(); + }); + + test("defaults name to empty string when missing", () => { + const result = migrateToGroupedFormat({}); + expect(result.name).toBe(""); + }); + + test("defaults auto_invite_mode to invite when missing", () => { + const legacy = { + name: "ws", + auto_invite_enabled: true, + auto_invite_as: "operator", + }; + const result = migrateToGroupedFormat(legacy); + expect(result.auto_invite!.mode).toBe("invite"); + }); + + test("defaults error_handler_muted_on_cancel to false when missing", () => { + const legacy = { + name: "ws", + error_handler: "u/admin/handler", + }; + const result = migrateToGroupedFormat(legacy); + expect(result.error_handler!.muted_on_cancel).toBe(false); + }); + + test("preserves ai_config, large_file_storage, git_sync, default_scripts, operator_settings", () => { + const settings = { + name: "ws", + ai_config: { provider: "openai" }, + large_file_storage: { type: "s3" }, + git_sync: { enabled: true }, + default_scripts: { python: "template.py" }, + operator_settings: { hideCode: true }, + }; + const result = migrateToGroupedFormat(settings); + expect(result.ai_config).toEqual({ provider: "openai" }); + expect(result.large_file_storage).toEqual({ type: "s3" }); + expect(result.git_sync).toEqual({ enabled: true }); + expect(result.default_scripts).toEqual({ python: "template.py" }); + expect(result.operator_settings).toEqual({ hideCode: true }); + }); + + test("does not include undefined fields in result", () => { + const result = migrateToGroupedFormat({ name: "ws" }); + expect("webhook" in result).toBe(false); + expect("deploy_to" in result).toBe(false); + expect("color" in result).toBe(false); + }); +}); diff --git a/cli/test/setup.ts b/cli/test/setup.ts new file mode 100644 index 0000000000..7eecd8bf2d --- /dev/null +++ b/cli/test/setup.ts @@ -0,0 +1,94 @@ +/** + * Global test setup — preloaded before all test files. + * + * 1. Builds the backend binary so `cargo run` starts instantly. + * 2. Starts a shared backend instance so integration tests don't + * bear the startup cost inside their per-test timeout window. + */ + +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { statSync } from "node:fs"; + +const __dirname = resolve(fileURLToPath(import.meta.url), ".."); + +function findBackendDir(): string { + const candidates = [ + resolve(__dirname, "..", "..", "backend"), + resolve(__dirname, "..", "..", "..", "backend"), + resolve(".", "backend"), + resolve("..", "backend"), + ]; + + for (const candidate of candidates) { + try { + const cargoPath = resolve(candidate, "Cargo.toml"); + const stat = statSync(cargoPath); + if (stat.isFile()) { + return candidate; + } + } catch { + // Continue searching + } + } + + throw new Error("Could not find backend directory."); +} + +// Build the backend binary so `cargo run` is fast for all tests +const backendDir = findBackendDir(); + +const isCI = process.env["CI_MINIMAL_FEATURES"] === "true"; +const hasLicenseKey = !!process.env["EE_LICENSE_KEY"]; +const features = isCI + ? ["zip"] + : hasLicenseKey + ? ["zip", "private", "enterprise", "license"] + : ["zip"]; + +const cargoArgs = ["build", "--features", features.join(",")]; +console.log(`Pre-building backend: cargo ${cargoArgs.join(" ")}`); + +const proc = Bun.spawn(["cargo", ...cargoArgs], { + cwd: backendDir, + stdout: "inherit", + stderr: "inherit", + env: { + ...process.env as Record, + SQLX_OFFLINE: "true", + }, +}); + +const exitCode = await proc.exited; +if (exitCode !== 0) { + throw new Error(`cargo build failed with exit code ${exitCode}`); +} +console.log("Backend build complete."); + +// Start the shared backend instance so it's ready before any test runs. +// This avoids the first integration test timing out while the backend +// creates its database, starts the process, and waits for the health check. +if (process.env["DATABASE_URL"]) { + const { getTestBackend } = await import("./test_backend.ts"); + console.log("Pre-starting test backend..."); + await getTestBackend(); + console.log("Test backend is ready for all tests."); +} + +// When TEST_CLI_RUNTIME=node, also build the npm package so tests +// can invoke `node npm/esm/main.js` instead of `bun run src/main.ts` +if (process.env["TEST_CLI_RUNTIME"] === "node") { + const cliDir = resolve(__dirname, ".."); + console.log("Building npm package for Node runtime testing..."); + const npmBuild = Bun.spawn(["bun", "run", "build-npm.ts"], { + cwd: cliDir, + stdout: "inherit", + stderr: "inherit", + env: process.env as Record, + }); + const npmExit = await npmBuild.exited; + if (npmExit !== 0) { + throw new Error(`npm build failed with exit code ${npmExit}`); + } + console.log("npm package built — tests will use Node runtime."); +} diff --git a/cli/test/specific_items.test.ts b/cli/test/specific_items.test.ts index c3ddaff990..3b72861f88 100644 --- a/cli/test/specific_items.test.ts +++ b/cli/test/specific_items.test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertExists, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; // ============================================================================= // SPECIFIC ITEMS UNIT TESTS @@ -23,192 +23,192 @@ import type { SpecificItemsConfig } from "../src/core/specific_items.ts"; // toBranchSpecificPath TESTS // ============================================================================= -Deno.test("toBranchSpecificPath: converts variable path to branch-specific", () => { +test("toBranchSpecificPath: converts variable path to branch-specific", () => { const result = toBranchSpecificPath("f/test.variable.yaml", "main"); - assertEquals(result, "f/test.main.variable.yaml"); + expect(result).toEqual("f/test.main.variable.yaml"); }); -Deno.test("toBranchSpecificPath: converts resource path to branch-specific", () => { +test("toBranchSpecificPath: converts resource path to branch-specific", () => { const result = toBranchSpecificPath("u/admin/db.resource.yaml", "develop"); - assertEquals(result, "u/admin/db.develop.resource.yaml"); + expect(result).toEqual("u/admin/db.develop.resource.yaml"); }); -Deno.test("toBranchSpecificPath: converts trigger path to branch-specific", () => { +test("toBranchSpecificPath: converts trigger path to branch-specific", () => { const result = toBranchSpecificPath("f/my_trigger.http_trigger.yaml", "feature-x"); - assertEquals(result, "f/my_trigger.feature-x.http_trigger.yaml"); + expect(result).toEqual("f/my_trigger.feature-x.http_trigger.yaml"); }); -Deno.test("toBranchSpecificPath: sanitizes branch names with slashes", () => { +test("toBranchSpecificPath: sanitizes branch names with slashes", () => { const result = toBranchSpecificPath("f/test.variable.yaml", "feature/my-feature"); - assertEquals(result, "f/test.feature_my-feature.variable.yaml"); + expect(result).toEqual("f/test.feature_my-feature.variable.yaml"); }); -Deno.test("toBranchSpecificPath: sanitizes branch names with dots", () => { +test("toBranchSpecificPath: sanitizes branch names with dots", () => { const result = toBranchSpecificPath("f/test.variable.yaml", "release.1.0"); - assertEquals(result, "f/test.release_1_0.variable.yaml"); + expect(result).toEqual("f/test.release_1_0.variable.yaml"); }); -Deno.test("toBranchSpecificPath: leaves non-specific files unchanged", () => { +test("toBranchSpecificPath: leaves non-specific files unchanged", () => { const result = toBranchSpecificPath("f/script.ts", "main"); - assertEquals(result, "f/script.ts"); + expect(result).toEqual("f/script.ts"); }); -Deno.test("toBranchSpecificPath: handles resource files with extensions", () => { +test("toBranchSpecificPath: handles resource files with extensions", () => { const result = toBranchSpecificPath("f/config.resource.file.json", "main"); - assertEquals(result, "f/config.main.resource.file.json"); + expect(result).toEqual("f/config.main.resource.file.json"); }); // ============================================================================= // fromBranchSpecificPath TESTS // ============================================================================= -Deno.test("fromBranchSpecificPath: converts branch-specific variable back to base", () => { +test("fromBranchSpecificPath: converts branch-specific variable back to base", () => { const result = fromBranchSpecificPath("f/test.main.variable.yaml", "main"); - assertEquals(result, "f/test.variable.yaml"); + expect(result).toEqual("f/test.variable.yaml"); }); -Deno.test("fromBranchSpecificPath: converts branch-specific resource back to base", () => { +test("fromBranchSpecificPath: converts branch-specific resource back to base", () => { const result = fromBranchSpecificPath("u/admin/db.develop.resource.yaml", "develop"); - assertEquals(result, "u/admin/db.resource.yaml"); + expect(result).toEqual("u/admin/db.resource.yaml"); }); -Deno.test("fromBranchSpecificPath: converts branch-specific trigger back to base", () => { +test("fromBranchSpecificPath: converts branch-specific trigger back to base", () => { const result = fromBranchSpecificPath("f/my_trigger.feature-x.http_trigger.yaml", "feature-x"); - assertEquals(result, "f/my_trigger.http_trigger.yaml"); + expect(result).toEqual("f/my_trigger.http_trigger.yaml"); }); -Deno.test("fromBranchSpecificPath: handles sanitized branch names", () => { +test("fromBranchSpecificPath: handles sanitized branch names", () => { const result = fromBranchSpecificPath("f/test.feature_my-feature.variable.yaml", "feature/my-feature"); - assertEquals(result, "f/test.variable.yaml"); + expect(result).toEqual("f/test.variable.yaml"); }); -Deno.test("fromBranchSpecificPath: returns unchanged if not branch-specific", () => { +test("fromBranchSpecificPath: returns unchanged if not branch-specific", () => { const result = fromBranchSpecificPath("f/test.variable.yaml", "main"); - assertEquals(result, "f/test.variable.yaml"); + expect(result).toEqual("f/test.variable.yaml"); }); -Deno.test("fromBranchSpecificPath: handles resource files with extensions", () => { +test("fromBranchSpecificPath: handles resource files with extensions", () => { const result = fromBranchSpecificPath("f/config.main.resource.file.json", "main"); - assertEquals(result, "f/config.resource.file.json"); + expect(result).toEqual("f/config.resource.file.json"); }); // ============================================================================= // isSpecificItem TESTS // ============================================================================= -Deno.test("isSpecificItem: returns false when specificItems is undefined", () => { +test("isSpecificItem: returns false when specificItems is undefined", () => { const result = isSpecificItem("f/test.variable.yaml", undefined); - assertEquals(result, false); + expect(result).toEqual(false); }); -Deno.test("isSpecificItem: matches variable paths with glob pattern", () => { +test("isSpecificItem: matches variable paths with glob pattern", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; - assertEquals(isSpecificItem("f/test.variable.yaml", config), true); - assertEquals(isSpecificItem("u/admin/test.variable.yaml", config), false); + expect(isSpecificItem("f/test.variable.yaml", config)).toEqual(true); + expect(isSpecificItem("u/admin/test.variable.yaml", config)).toEqual(false); }); -Deno.test("isSpecificItem: matches resource paths with glob pattern", () => { +test("isSpecificItem: matches resource paths with glob pattern", () => { const config: SpecificItemsConfig = { resources: ["u/admin/**"], }; - assertEquals(isSpecificItem("u/admin/db.resource.yaml", config), true); - assertEquals(isSpecificItem("f/db.resource.yaml", config), false); + expect(isSpecificItem("u/admin/db.resource.yaml", config)).toEqual(true); + expect(isSpecificItem("f/db.resource.yaml", config)).toEqual(false); }); -Deno.test("isSpecificItem: matches trigger paths with glob pattern", () => { +test("isSpecificItem: matches trigger paths with glob pattern", () => { const config: SpecificItemsConfig = { triggers: ["f/triggers/**"], }; - assertEquals(isSpecificItem("f/triggers/my.http_trigger.yaml", config), true); - assertEquals(isSpecificItem("u/admin/my.http_trigger.yaml", config), false); + expect(isSpecificItem("f/triggers/my.http_trigger.yaml", config)).toEqual(true); + expect(isSpecificItem("u/admin/my.http_trigger.yaml", config)).toEqual(false); }); -Deno.test("isSpecificItem: matches multiple patterns", () => { +test("isSpecificItem: matches multiple patterns", () => { const config: SpecificItemsConfig = { variables: ["f/**", "g/**"], }; - assertEquals(isSpecificItem("f/test.variable.yaml", config), true); - assertEquals(isSpecificItem("g/test.variable.yaml", config), true); - assertEquals(isSpecificItem("u/admin/test.variable.yaml", config), false); + expect(isSpecificItem("f/test.variable.yaml", config)).toEqual(true); + expect(isSpecificItem("g/test.variable.yaml", config)).toEqual(true); + expect(isSpecificItem("u/admin/test.variable.yaml", config)).toEqual(false); }); -Deno.test("isSpecificItem: handles exact path patterns", () => { +test("isSpecificItem: handles exact path patterns", () => { const config: SpecificItemsConfig = { variables: ["f/specific.variable.yaml"], }; - assertEquals(isSpecificItem("f/specific.variable.yaml", config), true); - assertEquals(isSpecificItem("f/other.variable.yaml", config), false); + expect(isSpecificItem("f/specific.variable.yaml", config)).toEqual(true); + expect(isSpecificItem("f/other.variable.yaml", config)).toEqual(false); }); // ============================================================================= // isBranchSpecificFile TESTS // ============================================================================= -Deno.test("isBranchSpecificFile: detects branch-specific variable files", () => { - assertEquals(isBranchSpecificFile("f/test.main.variable.yaml"), true); - assertEquals(isBranchSpecificFile("f/test.develop.variable.yaml"), true); - assertEquals(isBranchSpecificFile("f/test.feature_branch.variable.yaml"), true); +test("isBranchSpecificFile: detects branch-specific variable files", () => { + expect(isBranchSpecificFile("f/test.main.variable.yaml")).toEqual(true); + expect(isBranchSpecificFile("f/test.develop.variable.yaml")).toEqual(true); + expect(isBranchSpecificFile("f/test.feature_branch.variable.yaml")).toEqual(true); }); -Deno.test("isBranchSpecificFile: detects branch-specific resource files", () => { - assertEquals(isBranchSpecificFile("u/admin/db.main.resource.yaml"), true); - assertEquals(isBranchSpecificFile("u/admin/db.staging.resource.yaml"), true); +test("isBranchSpecificFile: detects branch-specific resource files", () => { + expect(isBranchSpecificFile("u/admin/db.main.resource.yaml")).toEqual(true); + expect(isBranchSpecificFile("u/admin/db.staging.resource.yaml")).toEqual(true); }); -Deno.test("isBranchSpecificFile: detects branch-specific trigger files", () => { - assertEquals(isBranchSpecificFile("f/my.main.http_trigger.yaml"), true); - assertEquals(isBranchSpecificFile("f/my.develop.kafka_trigger.yaml"), true); - assertEquals(isBranchSpecificFile("f/my.main.websocket_trigger.yaml"), true); +test("isBranchSpecificFile: detects branch-specific trigger files", () => { + expect(isBranchSpecificFile("f/my.main.http_trigger.yaml")).toEqual(true); + expect(isBranchSpecificFile("f/my.develop.kafka_trigger.yaml")).toEqual(true); + expect(isBranchSpecificFile("f/my.main.websocket_trigger.yaml")).toEqual(true); }); -Deno.test("isBranchSpecificFile: returns false for non-branch-specific files", () => { - assertEquals(isBranchSpecificFile("f/test.variable.yaml"), false); - assertEquals(isBranchSpecificFile("u/admin/db.resource.yaml"), false); - assertEquals(isBranchSpecificFile("f/my.http_trigger.yaml"), false); - assertEquals(isBranchSpecificFile("f/script.ts"), false); +test("isBranchSpecificFile: returns false for non-branch-specific files", () => { + expect(isBranchSpecificFile("f/test.variable.yaml")).toEqual(false); + expect(isBranchSpecificFile("u/admin/db.resource.yaml")).toEqual(false); + expect(isBranchSpecificFile("f/my.http_trigger.yaml")).toEqual(false); + expect(isBranchSpecificFile("f/script.ts")).toEqual(false); }); -Deno.test("isBranchSpecificFile: handles resource files with extensions", () => { - assertEquals(isBranchSpecificFile("f/config.main.resource.file.json"), true); - assertEquals(isBranchSpecificFile("f/config.resource.file.json"), false); +test("isBranchSpecificFile: handles resource files with extensions", () => { + expect(isBranchSpecificFile("f/config.main.resource.file.json")).toEqual(true); + expect(isBranchSpecificFile("f/config.resource.file.json")).toEqual(false); }); // ============================================================================= // ROUND-TRIP TESTS // ============================================================================= -Deno.test("round-trip: variable file path conversion", () => { +test("round-trip: variable file path conversion", () => { const original = "f/my/nested/config.variable.yaml"; const branch = "feature/test-branch"; const branchSpecific = toBranchSpecificPath(original, branch); const restored = fromBranchSpecificPath(branchSpecific, branch); - assertEquals(restored, original); + expect(restored).toEqual(original); }); -Deno.test("round-trip: resource file path conversion", () => { +test("round-trip: resource file path conversion", () => { const original = "u/admin/database.resource.yaml"; const branch = "develop"; const branchSpecific = toBranchSpecificPath(original, branch); const restored = fromBranchSpecificPath(branchSpecific, branch); - assertEquals(restored, original); + expect(restored).toEqual(original); }); -Deno.test("round-trip: trigger file path conversion", () => { +test("round-trip: trigger file path conversion", () => { const original = "f/webhooks/handler.http_trigger.yaml"; const branch = "main"; const branchSpecific = toBranchSpecificPath(original, branch); const restored = fromBranchSpecificPath(branchSpecific, branch); - assertEquals(restored, original); + expect(restored).toEqual(original); }); -Deno.test("round-trip: resource file with extension", () => { +test("round-trip: resource file with extension", () => { const original = "f/configs/settings.resource.file.ini"; const branch = "release/v1.0"; const branchSpecific = toBranchSpecificPath(original, branch); const restored = fromBranchSpecificPath(branchSpecific, branch); - assertEquals(restored, original); + expect(restored).toEqual(original); }); // ============================================================================= @@ -216,7 +216,7 @@ Deno.test("round-trip: resource file with extension", () => { // These tests validate that functions work correctly with explicit branch override // ============================================================================= -Deno.test("branchOverride: getBranchSpecificPath with override returns branch-specific path", () => { +test("branchOverride: getBranchSpecificPath with override returns branch-specific path", () => { // This test verifies that when branchOverride is provided, the function uses it // instead of detecting the current git branch const config: SpecificItemsConfig = { @@ -225,10 +225,10 @@ Deno.test("branchOverride: getBranchSpecificPath with override returns branch-sp // When override is provided, it should return the branch-specific path even outside git repo const result = getBranchSpecificPath("f/test.variable.yaml", config, "staging"); - assertEquals(result, "f/test.staging.variable.yaml"); + expect(result).toEqual("f/test.staging.variable.yaml"); }); -Deno.test("branchOverride: getBranchSpecificPath without override and not in git repo returns undefined", () => { +test("branchOverride: getBranchSpecificPath without override and not in git repo returns undefined", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; @@ -240,31 +240,31 @@ Deno.test("branchOverride: getBranchSpecificPath without override and not in git // We test the override case above which is deterministic }); -Deno.test("branchOverride: isCurrentBranchFile with override uses provided branch", () => { +test("branchOverride: isCurrentBranchFile with override uses provided branch", () => { // Test that isCurrentBranchFile uses the override branch instead of git detection const result = isCurrentBranchFile("f/test.staging.variable.yaml", "staging"); - assertEquals(result, true); + expect(result).toEqual(true); // Should return false for different branch const resultOther = isCurrentBranchFile("f/test.staging.variable.yaml", "production"); - assertEquals(resultOther, false); + expect(resultOther).toEqual(false); // Should return false for non-branch-specific file const resultNonSpecific = isCurrentBranchFile("f/test.variable.yaml", "staging"); - assertEquals(resultNonSpecific, false); + expect(resultNonSpecific).toEqual(false); }); -Deno.test("branchOverride: isCurrentBranchFile with override handles sanitized branch names", () => { +test("branchOverride: isCurrentBranchFile with override handles sanitized branch names", () => { // Test with branch names that get sanitized const result = isCurrentBranchFile("f/test.feature_my-branch.variable.yaml", "feature/my-branch"); - assertEquals(result, true); + expect(result).toEqual(true); // Different sanitized branch should return false const resultOther = isCurrentBranchFile("f/test.feature_my-branch.variable.yaml", "feature/other-branch"); - assertEquals(resultOther, false); + expect(resultOther).toEqual(false); }); -Deno.test("branchOverride: getSpecificItemsForCurrentBranch with override returns correct config", () => { +test("branchOverride: getSpecificItemsForCurrentBranch with override returns correct config", () => { // Test that getSpecificItemsForCurrentBranch uses the override branch const config = { gitBranches: { @@ -286,17 +286,17 @@ Deno.test("branchOverride: getSpecificItemsForCurrentBranch with override return }; const stagingItems = getSpecificItemsForCurrentBranch(config as any, "staging"); - assertEquals(stagingItems?.variables, ["f/**"]); - assertEquals(stagingItems?.resources, ["u/admin/**"]); - assertEquals(stagingItems?.triggers, ["f/webhooks/**"]); // From common + expect(stagingItems?.variables).toEqual(["f/**"]); + expect(stagingItems?.resources).toEqual(["u/admin/**"]); + expect(stagingItems?.triggers).toEqual(["f/webhooks/**"]); // From common const productionItems = getSpecificItemsForCurrentBranch(config as any, "production"); - assertEquals(productionItems?.variables, ["g/**"]); - assertEquals(productionItems?.resources, undefined); - assertEquals(productionItems?.triggers, ["f/webhooks/**"]); // From common + expect(productionItems?.variables).toEqual(["g/**"]); + expect(productionItems?.resources).toEqual(undefined); + expect(productionItems?.triggers).toEqual(["f/webhooks/**"]); // From common }); -Deno.test("branchOverride: getSpecificItemsForCurrentBranch with non-existent branch returns undefined", () => { +test("branchOverride: getSpecificItemsForCurrentBranch with non-existent branch returns undefined", () => { const config = { gitBranches: { staging: { @@ -309,10 +309,10 @@ Deno.test("branchOverride: getSpecificItemsForCurrentBranch with non-existent br // When the branch doesn't have specific items (and there's no common), should return undefined const result = getSpecificItemsForCurrentBranch(config as any, "nonexistent"); - assertEquals(result, undefined); + expect(result).toEqual(undefined); }); -Deno.test("branchOverride: getSpecificItemsForCurrentBranch merges common and branch items", () => { +test("branchOverride: getSpecificItemsForCurrentBranch merges common and branch items", () => { const config = { gitBranches: { commonSpecificItems: { @@ -330,9 +330,9 @@ Deno.test("branchOverride: getSpecificItemsForCurrentBranch merges common and br const result = getSpecificItemsForCurrentBranch(config as any, "develop"); // Should merge common and branch-specific - assertEquals(result?.variables, ["common/**", "dev/**"]); - assertEquals(result?.resources, ["shared/**"]); - assertEquals(result?.triggers, ["dev/triggers/**"]); + expect(result?.variables).toEqual(["common/**", "dev/**"]); + expect(result?.resources).toEqual(["shared/**"]); + expect(result?.triggers).toEqual(["dev/triggers/**"]); }); // ============================================================================= @@ -340,176 +340,176 @@ Deno.test("branchOverride: getSpecificItemsForCurrentBranch merges common and br // Format: f/folder/folder.branchName.meta.yaml // ============================================================================= -Deno.test("toBranchSpecificPath: converts folder meta path to branch-specific", () => { +test("toBranchSpecificPath: converts folder meta path to branch-specific", () => { // f/my_folder/folder.meta.yaml -> f/my_folder/folder.main.meta.yaml const result = toBranchSpecificPath("f/my_folder/folder.meta.yaml", "main"); - assertEquals(result, "f/my_folder/folder.main.meta.yaml"); + expect(result).toEqual("f/my_folder/folder.main.meta.yaml"); }); -Deno.test("toBranchSpecificPath: converts nested folder meta path to branch-specific", () => { +test("toBranchSpecificPath: converts nested folder meta path to branch-specific", () => { const result = toBranchSpecificPath("f/parent/child/folder.meta.yaml", "develop"); - assertEquals(result, "f/parent/child/folder.develop.meta.yaml"); + expect(result).toEqual("f/parent/child/folder.develop.meta.yaml"); }); -Deno.test("toBranchSpecificPath: sanitizes branch name in folder path", () => { +test("toBranchSpecificPath: sanitizes branch name in folder path", () => { const result = toBranchSpecificPath("f/env/folder.meta.yaml", "feature/test"); - assertEquals(result, "f/env/folder.feature_test.meta.yaml"); + expect(result).toEqual("f/env/folder.feature_test.meta.yaml"); }); -Deno.test("fromBranchSpecificPath: converts branch-specific folder back to base", () => { +test("fromBranchSpecificPath: converts branch-specific folder back to base", () => { const result = fromBranchSpecificPath("f/my_folder/folder.main.meta.yaml", "main"); - assertEquals(result, "f/my_folder/folder.meta.yaml"); + expect(result).toEqual("f/my_folder/folder.meta.yaml"); }); -Deno.test("fromBranchSpecificPath: handles nested branch-specific folder", () => { +test("fromBranchSpecificPath: handles nested branch-specific folder", () => { const result = fromBranchSpecificPath("f/parent/child/folder.develop.meta.yaml", "develop"); - assertEquals(result, "f/parent/child/folder.meta.yaml"); + expect(result).toEqual("f/parent/child/folder.meta.yaml"); }); -Deno.test("fromBranchSpecificPath: handles sanitized branch names for folders", () => { +test("fromBranchSpecificPath: handles sanitized branch names for folders", () => { const result = fromBranchSpecificPath("f/env/folder.feature_test.meta.yaml", "feature/test"); - assertEquals(result, "f/env/folder.meta.yaml"); + expect(result).toEqual("f/env/folder.meta.yaml"); }); -Deno.test("isSpecificItem: matches folder paths with glob pattern", () => { +test("isSpecificItem: matches folder paths with glob pattern", () => { const config: SpecificItemsConfig = { folders: ["f/env_*"], }; - assertEquals(isSpecificItem("f/env_staging/folder.meta.yaml", config), true); - assertEquals(isSpecificItem("f/env_production/folder.meta.yaml", config), true); - assertEquals(isSpecificItem("f/other/folder.meta.yaml", config), false); + expect(isSpecificItem("f/env_staging/folder.meta.yaml", config)).toEqual(true); + expect(isSpecificItem("f/env_production/folder.meta.yaml", config)).toEqual(true); + expect(isSpecificItem("f/other/folder.meta.yaml", config)).toEqual(false); }); -Deno.test("isSpecificItem: matches folder paths with exact pattern", () => { +test("isSpecificItem: matches folder paths with exact pattern", () => { const config: SpecificItemsConfig = { folders: ["f/config"], }; - assertEquals(isSpecificItem("f/config/folder.meta.yaml", config), true); - assertEquals(isSpecificItem("f/other/folder.meta.yaml", config), false); + expect(isSpecificItem("f/config/folder.meta.yaml", config)).toEqual(true); + expect(isSpecificItem("f/other/folder.meta.yaml", config)).toEqual(false); }); -Deno.test("isBranchSpecificFile: detects branch-specific folder files", () => { - assertEquals(isBranchSpecificFile("f/my_folder/folder.main.meta.yaml"), true); - assertEquals(isBranchSpecificFile("f/my_folder/folder.develop.meta.yaml"), true); - assertEquals(isBranchSpecificFile("f/nested/path/folder.staging.meta.yaml"), true); +test("isBranchSpecificFile: detects branch-specific folder files", () => { + expect(isBranchSpecificFile("f/my_folder/folder.main.meta.yaml")).toEqual(true); + expect(isBranchSpecificFile("f/my_folder/folder.develop.meta.yaml")).toEqual(true); + expect(isBranchSpecificFile("f/nested/path/folder.staging.meta.yaml")).toEqual(true); }); -Deno.test("isBranchSpecificFile: returns false for non-branch-specific folder files", () => { - assertEquals(isBranchSpecificFile("f/my_folder/folder.meta.yaml"), false); - assertEquals(isBranchSpecificFile("f/nested/path/folder.meta.yaml"), false); +test("isBranchSpecificFile: returns false for non-branch-specific folder files", () => { + expect(isBranchSpecificFile("f/my_folder/folder.meta.yaml")).toEqual(false); + expect(isBranchSpecificFile("f/nested/path/folder.meta.yaml")).toEqual(false); }); -Deno.test("isCurrentBranchFile: detects branch-specific folder for current branch", () => { - assertEquals(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "staging"), true); - assertEquals(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "production"), false); - assertEquals(isCurrentBranchFile("f/my_folder/folder.meta.yaml", "staging"), false); +test("isCurrentBranchFile: detects branch-specific folder for current branch", () => { + expect(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "staging")).toEqual(true); + expect(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "production")).toEqual(false); + expect(isCurrentBranchFile("f/my_folder/folder.meta.yaml", "staging")).toEqual(false); }); -Deno.test("isCurrentBranchFile: handles sanitized branch for folders", () => { - assertEquals(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/test"), true); - assertEquals(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/other"), false); +test("isCurrentBranchFile: handles sanitized branch for folders", () => { + expect(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/test")).toEqual(true); + expect(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/other")).toEqual(false); }); -Deno.test("round-trip: folder meta path conversion", () => { +test("round-trip: folder meta path conversion", () => { const original = "f/configs/env_folder/folder.meta.yaml"; const branch = "main"; const branchSpecific = toBranchSpecificPath(original, branch); - assertEquals(branchSpecific, "f/configs/env_folder/folder.main.meta.yaml"); + expect(branchSpecific).toEqual("f/configs/env_folder/folder.main.meta.yaml"); const restored = fromBranchSpecificPath(branchSpecific, branch); - assertEquals(restored, original); + expect(restored).toEqual(original); }); -Deno.test("round-trip: folder meta with sanitized branch", () => { +test("round-trip: folder meta with sanitized branch", () => { const original = "f/env/folder.meta.yaml"; const branch = "feature/new-env"; const branchSpecific = toBranchSpecificPath(original, branch); - assertEquals(branchSpecific, "f/env/folder.feature_new-env.meta.yaml"); + expect(branchSpecific).toEqual("f/env/folder.feature_new-env.meta.yaml"); const restored = fromBranchSpecificPath(branchSpecific, branch); - assertEquals(restored, original); + expect(restored).toEqual(original); }); // ============================================================================= // SETTINGS BRANCH-SPECIFIC TESTS // ============================================================================= -Deno.test("toBranchSpecificPath: converts settings.yaml to branch-specific", () => { +test("toBranchSpecificPath: converts settings.yaml to branch-specific", () => { const result = toBranchSpecificPath("settings.yaml", "main"); - assertEquals(result, "settings.main.yaml"); + expect(result).toEqual("settings.main.yaml"); }); -Deno.test("toBranchSpecificPath: sanitizes branch name in settings path", () => { +test("toBranchSpecificPath: sanitizes branch name in settings path", () => { const result = toBranchSpecificPath("settings.yaml", "feature/test"); - assertEquals(result, "settings.feature_test.yaml"); + expect(result).toEqual("settings.feature_test.yaml"); }); -Deno.test("fromBranchSpecificPath: converts branch-specific settings back to base", () => { +test("fromBranchSpecificPath: converts branch-specific settings back to base", () => { const result = fromBranchSpecificPath("settings.main.yaml", "main"); - assertEquals(result, "settings.yaml"); + expect(result).toEqual("settings.yaml"); }); -Deno.test("fromBranchSpecificPath: handles sanitized branch names for settings", () => { +test("fromBranchSpecificPath: handles sanitized branch names for settings", () => { const result = fromBranchSpecificPath("settings.feature_test.yaml", "feature/test"); - assertEquals(result, "settings.yaml"); + expect(result).toEqual("settings.yaml"); }); -Deno.test("isSpecificItem: matches settings.yaml when settings is true", () => { +test("isSpecificItem: matches settings.yaml when settings is true", () => { const config: SpecificItemsConfig = { settings: true, }; - assertEquals(isSpecificItem("settings.yaml", config), true); + expect(isSpecificItem("settings.yaml", config)).toEqual(true); }); -Deno.test("isSpecificItem: does not match settings.yaml when settings is false", () => { +test("isSpecificItem: does not match settings.yaml when settings is false", () => { const config: SpecificItemsConfig = { settings: false, }; - assertEquals(isSpecificItem("settings.yaml", config), false); + expect(isSpecificItem("settings.yaml", config)).toEqual(false); }); -Deno.test("isSpecificItem: does not match settings.yaml when settings is undefined", () => { +test("isSpecificItem: does not match settings.yaml when settings is undefined", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; - assertEquals(isSpecificItem("settings.yaml", config), false); + expect(isSpecificItem("settings.yaml", config)).toEqual(false); }); -Deno.test("isBranchSpecificFile: detects branch-specific settings files", () => { - assertEquals(isBranchSpecificFile("settings.main.yaml"), true); - assertEquals(isBranchSpecificFile("settings.develop.yaml"), true); - assertEquals(isBranchSpecificFile("settings.feature_test.yaml"), true); +test("isBranchSpecificFile: detects branch-specific settings files", () => { + expect(isBranchSpecificFile("settings.main.yaml")).toEqual(true); + expect(isBranchSpecificFile("settings.develop.yaml")).toEqual(true); + expect(isBranchSpecificFile("settings.feature_test.yaml")).toEqual(true); }); -Deno.test("isBranchSpecificFile: returns false for non-branch-specific settings", () => { - assertEquals(isBranchSpecificFile("settings.yaml"), false); +test("isBranchSpecificFile: returns false for non-branch-specific settings", () => { + expect(isBranchSpecificFile("settings.yaml")).toEqual(false); }); -Deno.test("isCurrentBranchFile: detects branch-specific settings for current branch", () => { - assertEquals(isCurrentBranchFile("settings.staging.yaml", "staging"), true); - assertEquals(isCurrentBranchFile("settings.staging.yaml", "production"), false); - assertEquals(isCurrentBranchFile("settings.yaml", "staging"), false); +test("isCurrentBranchFile: detects branch-specific settings for current branch", () => { + expect(isCurrentBranchFile("settings.staging.yaml", "staging")).toEqual(true); + expect(isCurrentBranchFile("settings.staging.yaml", "production")).toEqual(false); + expect(isCurrentBranchFile("settings.yaml", "staging")).toEqual(false); }); -Deno.test("isCurrentBranchFile: handles sanitized branch for settings", () => { - assertEquals(isCurrentBranchFile("settings.feature_test.yaml", "feature/test"), true); - assertEquals(isCurrentBranchFile("settings.feature_test.yaml", "feature/other"), false); +test("isCurrentBranchFile: handles sanitized branch for settings", () => { + expect(isCurrentBranchFile("settings.feature_test.yaml", "feature/test")).toEqual(true); + expect(isCurrentBranchFile("settings.feature_test.yaml", "feature/other")).toEqual(false); }); -Deno.test("round-trip: settings path conversion", () => { +test("round-trip: settings path conversion", () => { const original = "settings.yaml"; const branch = "main"; const branchSpecific = toBranchSpecificPath(original, branch); - assertEquals(branchSpecific, "settings.main.yaml"); + expect(branchSpecific).toEqual("settings.main.yaml"); const restored = fromBranchSpecificPath(branchSpecific, branch); - assertEquals(restored, original); + expect(restored).toEqual(original); }); -Deno.test("round-trip: settings with sanitized branch", () => { +test("round-trip: settings with sanitized branch", () => { const original = "settings.yaml"; const branch = "release/v1.0"; const branchSpecific = toBranchSpecificPath(original, branch); - assertEquals(branchSpecific, "settings.release_v1_0.yaml"); + expect(branchSpecific).toEqual("settings.release_v1_0.yaml"); const restored = fromBranchSpecificPath(branchSpecific, branch); - assertEquals(restored, original); + expect(restored).toEqual(original); }); // ============================================================================= @@ -518,111 +518,111 @@ Deno.test("round-trip: settings with sanitized branch", () => { // Used to determine if branch-specific files should be used for this type. // ============================================================================= -Deno.test("isItemTypeConfigured: returns false when specificItems is undefined", () => { - assertEquals(isItemTypeConfigured("f/test.variable.yaml", undefined), false); - assertEquals(isItemTypeConfigured("f/test.resource.yaml", undefined), false); - assertEquals(isItemTypeConfigured("f/folder/folder.meta.yaml", undefined), false); - assertEquals(isItemTypeConfigured("settings.yaml", undefined), false); +test("isItemTypeConfigured: returns false when specificItems is undefined", () => { + expect(isItemTypeConfigured("f/test.variable.yaml", undefined)).toEqual(false); + expect(isItemTypeConfigured("f/test.resource.yaml", undefined)).toEqual(false); + expect(isItemTypeConfigured("f/folder/folder.meta.yaml", undefined)).toEqual(false); + expect(isItemTypeConfigured("settings.yaml", undefined)).toEqual(false); }); -Deno.test("isItemTypeConfigured: returns true for variables when variables is configured", () => { +test("isItemTypeConfigured: returns true for variables when variables is configured", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; // Type is configured (even if path doesn't match the pattern) - assertEquals(isItemTypeConfigured("f/test.variable.yaml", config), true); - assertEquals(isItemTypeConfigured("g/other.variable.yaml", config), true); + expect(isItemTypeConfigured("f/test.variable.yaml", config)).toEqual(true); + expect(isItemTypeConfigured("g/other.variable.yaml", config)).toEqual(true); }); -Deno.test("isItemTypeConfigured: returns false for variables when variables is NOT configured", () => { +test("isItemTypeConfigured: returns false for variables when variables is NOT configured", () => { const config: SpecificItemsConfig = { resources: ["f/**"], }; - assertEquals(isItemTypeConfigured("f/test.variable.yaml", config), false); + expect(isItemTypeConfigured("f/test.variable.yaml", config)).toEqual(false); }); -Deno.test("isItemTypeConfigured: returns true for resources when resources is configured", () => { +test("isItemTypeConfigured: returns true for resources when resources is configured", () => { const config: SpecificItemsConfig = { resources: ["f/**"], }; - assertEquals(isItemTypeConfigured("f/test.resource.yaml", config), true); - assertEquals(isItemTypeConfigured("g/other.resource.yaml", config), true); + expect(isItemTypeConfigured("f/test.resource.yaml", config)).toEqual(true); + expect(isItemTypeConfigured("g/other.resource.yaml", config)).toEqual(true); }); -Deno.test("isItemTypeConfigured: returns false for resources when resources is NOT configured", () => { +test("isItemTypeConfigured: returns false for resources when resources is NOT configured", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; - assertEquals(isItemTypeConfigured("f/test.resource.yaml", config), false); + expect(isItemTypeConfigured("f/test.resource.yaml", config)).toEqual(false); }); -Deno.test("isItemTypeConfigured: returns true for triggers when triggers is configured", () => { +test("isItemTypeConfigured: returns true for triggers when triggers is configured", () => { const config: SpecificItemsConfig = { triggers: ["f/**"], }; - assertEquals(isItemTypeConfigured("f/my.http_trigger.yaml", config), true); - assertEquals(isItemTypeConfigured("f/my.kafka_trigger.yaml", config), true); - assertEquals(isItemTypeConfigured("g/other.websocket_trigger.yaml", config), true); + expect(isItemTypeConfigured("f/my.http_trigger.yaml", config)).toEqual(true); + expect(isItemTypeConfigured("f/my.kafka_trigger.yaml", config)).toEqual(true); + expect(isItemTypeConfigured("g/other.websocket_trigger.yaml", config)).toEqual(true); }); -Deno.test("isItemTypeConfigured: returns false for triggers when triggers is NOT configured", () => { +test("isItemTypeConfigured: returns false for triggers when triggers is NOT configured", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; - assertEquals(isItemTypeConfigured("f/my.http_trigger.yaml", config), false); + expect(isItemTypeConfigured("f/my.http_trigger.yaml", config)).toEqual(false); }); -Deno.test("isItemTypeConfigured: returns true for folders when folders is configured", () => { +test("isItemTypeConfigured: returns true for folders when folders is configured", () => { const config: SpecificItemsConfig = { folders: ["f/env_*"], }; // Type is configured (even if path doesn't match the pattern) - assertEquals(isItemTypeConfigured("f/env_staging/folder.meta.yaml", config), true); - assertEquals(isItemTypeConfigured("f/other/folder.meta.yaml", config), true); + expect(isItemTypeConfigured("f/env_staging/folder.meta.yaml", config)).toEqual(true); + expect(isItemTypeConfigured("f/other/folder.meta.yaml", config)).toEqual(true); }); -Deno.test("isItemTypeConfigured: returns false for folders when folders is NOT configured", () => { +test("isItemTypeConfigured: returns false for folders when folders is NOT configured", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; - assertEquals(isItemTypeConfigured("f/my_folder/folder.meta.yaml", config), false); + expect(isItemTypeConfigured("f/my_folder/folder.meta.yaml", config)).toEqual(false); }); -Deno.test("isItemTypeConfigured: returns true for settings when settings is configured (true)", () => { +test("isItemTypeConfigured: returns true for settings when settings is configured (true)", () => { const config: SpecificItemsConfig = { settings: true, }; - assertEquals(isItemTypeConfigured("settings.yaml", config), true); + expect(isItemTypeConfigured("settings.yaml", config)).toEqual(true); }); -Deno.test("isItemTypeConfigured: returns true for settings when settings is configured (false)", () => { +test("isItemTypeConfigured: returns true for settings when settings is configured (false)", () => { // settings: false still means the type is "configured" (explicitly disabled) const config: SpecificItemsConfig = { settings: false, }; - assertEquals(isItemTypeConfigured("settings.yaml", config), true); + expect(isItemTypeConfigured("settings.yaml", config)).toEqual(true); }); -Deno.test("isItemTypeConfigured: returns false for settings when settings is NOT configured", () => { +test("isItemTypeConfigured: returns false for settings when settings is NOT configured", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; - assertEquals(isItemTypeConfigured("settings.yaml", config), false); + expect(isItemTypeConfigured("settings.yaml", config)).toEqual(false); }); -Deno.test("isItemTypeConfigured: returns true for resource files (with extension) when resources is configured", () => { +test("isItemTypeConfigured: returns true for resource files (with extension) when resources is configured", () => { const config: SpecificItemsConfig = { resources: ["f/**"], }; - assertEquals(isItemTypeConfigured("f/config.resource.file.json", config), true); - assertEquals(isItemTypeConfigured("f/data.resource.file.ini", config), true); + expect(isItemTypeConfigured("f/config.resource.file.json", config)).toEqual(true); + expect(isItemTypeConfigured("f/data.resource.file.ini", config)).toEqual(true); }); -Deno.test("isItemTypeConfigured: returns false for resource files when resources is NOT configured", () => { +test("isItemTypeConfigured: returns false for resource files when resources is NOT configured", () => { const config: SpecificItemsConfig = { variables: ["f/**"], }; - assertEquals(isItemTypeConfigured("f/config.resource.file.json", config), false); + expect(isItemTypeConfigured("f/config.resource.file.json", config)).toEqual(false); }); // ============================================================================= @@ -632,7 +632,7 @@ Deno.test("isItemTypeConfigured: returns false for resource files when resources // - When type is NOT configured: skip branch-specific files, use base files // ============================================================================= -Deno.test("filtering logic: folders - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: folders - when NOT configured, branch-specific should be ignored", () => { // Config has variables but NOT folders const config: SpecificItemsConfig = { variables: ["f/**"], @@ -642,17 +642,17 @@ Deno.test("filtering logic: folders - when NOT configured, branch-specific shoul const branchSpecificPath = "f/my_folder/folder.main.meta.yaml"; // Folder type is NOT configured - assertEquals(isItemTypeConfigured(basePath, config), false); + expect(isItemTypeConfigured(basePath, config)).toEqual(false); // Therefore, branch-specific file detection should not apply to this type // The sync logic should: // 1. Skip branch-specific folder files (isBranchSpecificFile returns true) // 2. Use the base file - assertEquals(isBranchSpecificFile(branchSpecificPath), true); - assertEquals(isBranchSpecificFile(basePath), false); + expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); + expect(isBranchSpecificFile(basePath)).toEqual(false); }); -Deno.test("filtering logic: folders - when IS configured and matches, use branch-specific", () => { +test("filtering logic: folders - when IS configured and matches, use branch-specific", () => { const config: SpecificItemsConfig = { folders: ["f/my_folder"], }; @@ -661,19 +661,19 @@ Deno.test("filtering logic: folders - when IS configured and matches, use branch const branchSpecificPath = "f/my_folder/folder.main.meta.yaml"; // Folder type IS configured - assertEquals(isItemTypeConfigured(basePath, config), true); + expect(isItemTypeConfigured(basePath, config)).toEqual(true); // And path matches the pattern - assertEquals(isSpecificItem(basePath, config), true); + expect(isSpecificItem(basePath, config)).toEqual(true); // The sync logic should: // 1. Use branch-specific folder file (map to base path) // 2. Skip the base file - assertEquals(isBranchSpecificFile(branchSpecificPath), true); - assertEquals(fromBranchSpecificPath(branchSpecificPath, "main"), basePath); + expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); + expect(fromBranchSpecificPath(branchSpecificPath, "main")).toEqual(basePath); }); -Deno.test("filtering logic: folders - when IS configured but doesn't match, skip branch-specific", () => { +test("filtering logic: folders - when IS configured but doesn't match, skip branch-specific", () => { const config: SpecificItemsConfig = { folders: ["f/env_*"], // Only env_ folders are branch-specific }; @@ -682,17 +682,17 @@ Deno.test("filtering logic: folders - when IS configured but doesn't match, skip const branchSpecificPath = "f/other_folder/folder.main.meta.yaml"; // Folder type IS configured - assertEquals(isItemTypeConfigured(basePath, config), true); + expect(isItemTypeConfigured(basePath, config)).toEqual(true); // But this path doesn't match the pattern - assertEquals(isSpecificItem(basePath, config), false); + expect(isSpecificItem(basePath, config)).toEqual(false); // The sync logic should: // 1. Skip the branch-specific file (type configured but doesn't match) // 2. Use the base file }); -Deno.test("filtering logic: settings - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: settings - when NOT configured, branch-specific should be ignored", () => { // Config has variables but NOT settings const config: SpecificItemsConfig = { variables: ["f/**"], @@ -702,14 +702,14 @@ Deno.test("filtering logic: settings - when NOT configured, branch-specific shou const branchSpecificPath = "settings.main.yaml"; // Settings type is NOT configured - assertEquals(isItemTypeConfigured(basePath, config), false); + expect(isItemTypeConfigured(basePath, config)).toEqual(false); // Therefore, branch-specific file detection should not apply to this type - assertEquals(isBranchSpecificFile(branchSpecificPath), true); - assertEquals(isBranchSpecificFile(basePath), false); + expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); + expect(isBranchSpecificFile(basePath)).toEqual(false); }); -Deno.test("filtering logic: settings - when IS configured (true), use branch-specific", () => { +test("filtering logic: settings - when IS configured (true), use branch-specific", () => { const config: SpecificItemsConfig = { settings: true, }; @@ -718,17 +718,17 @@ Deno.test("filtering logic: settings - when IS configured (true), use branch-spe const branchSpecificPath = "settings.main.yaml"; // Settings type IS configured - assertEquals(isItemTypeConfigured(basePath, config), true); + expect(isItemTypeConfigured(basePath, config)).toEqual(true); // And settings: true means it matches - assertEquals(isSpecificItem(basePath, config), true); + expect(isSpecificItem(basePath, config)).toEqual(true); // The sync logic should use branch-specific file - assertEquals(isBranchSpecificFile(branchSpecificPath), true); - assertEquals(fromBranchSpecificPath(branchSpecificPath, "main"), basePath); + expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); + expect(fromBranchSpecificPath(branchSpecificPath, "main")).toEqual(basePath); }); -Deno.test("filtering logic: settings - when IS configured (false), skip branch-specific", () => { +test("filtering logic: settings - when IS configured (false), skip branch-specific", () => { // settings: false means type is configured but explicitly disabled const config: SpecificItemsConfig = { settings: false, @@ -738,15 +738,15 @@ Deno.test("filtering logic: settings - when IS configured (false), skip branch-s const branchSpecificPath = "settings.main.yaml"; // Settings type IS configured (even though value is false) - assertEquals(isItemTypeConfigured(basePath, config), true); + expect(isItemTypeConfigured(basePath, config)).toEqual(true); // But settings: false means it doesn't match (not a specific item) - assertEquals(isSpecificItem(basePath, config), false); + expect(isSpecificItem(basePath, config)).toEqual(false); // The sync logic should skip branch-specific file and use base }); -Deno.test("filtering logic: variables - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: variables - when NOT configured, branch-specific should be ignored", () => { // Config has folders but NOT variables const config: SpecificItemsConfig = { folders: ["f/env_*"], @@ -756,14 +756,14 @@ Deno.test("filtering logic: variables - when NOT configured, branch-specific sho const branchSpecificPath = "f/test.main.variable.yaml"; // Variable type is NOT configured - assertEquals(isItemTypeConfigured(basePath, config), false); + expect(isItemTypeConfigured(basePath, config)).toEqual(false); // Branch-specific variable files should be ignored - assertEquals(isBranchSpecificFile(branchSpecificPath), true); - assertEquals(isBranchSpecificFile(basePath), false); + expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); + expect(isBranchSpecificFile(basePath)).toEqual(false); }); -Deno.test("filtering logic: resources - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: resources - when NOT configured, branch-specific should be ignored", () => { // Config has folders but NOT resources const config: SpecificItemsConfig = { folders: ["f/env_*"], @@ -773,13 +773,13 @@ Deno.test("filtering logic: resources - when NOT configured, branch-specific sho const branchSpecificPath = "f/db.main.resource.yaml"; // Resource type is NOT configured - assertEquals(isItemTypeConfigured(basePath, config), false); + expect(isItemTypeConfigured(basePath, config)).toEqual(false); - assertEquals(isBranchSpecificFile(branchSpecificPath), true); - assertEquals(isBranchSpecificFile(basePath), false); + expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); + expect(isBranchSpecificFile(basePath)).toEqual(false); }); -Deno.test("filtering logic: triggers - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: triggers - when NOT configured, branch-specific should be ignored", () => { // Config has folders but NOT triggers const config: SpecificItemsConfig = { folders: ["f/env_*"], @@ -789,10 +789,10 @@ Deno.test("filtering logic: triggers - when NOT configured, branch-specific shou const branchSpecificPath = "f/webhook.main.http_trigger.yaml"; // Trigger type is NOT configured - assertEquals(isItemTypeConfigured(basePath, config), false); + expect(isItemTypeConfigured(basePath, config)).toEqual(false); - assertEquals(isBranchSpecificFile(branchSpecificPath), true); - assertEquals(isBranchSpecificFile(basePath), false); + expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); + expect(isBranchSpecificFile(basePath)).toEqual(false); }); // ============================================================================= @@ -800,58 +800,58 @@ Deno.test("filtering logic: triggers - when NOT configured, branch-specific shou // Tests for configs that have some types configured but not others // ============================================================================= -Deno.test("mixed config: only folders configured - other types use base files", () => { +test("mixed config: only folders configured - other types use base files", () => { const config: SpecificItemsConfig = { folders: ["f/env_*"], }; // Folders IS configured - assertEquals(isItemTypeConfigured("f/env_staging/folder.meta.yaml", config), true); - assertEquals(isSpecificItem("f/env_staging/folder.meta.yaml", config), true); + expect(isItemTypeConfigured("f/env_staging/folder.meta.yaml", config)).toEqual(true); + expect(isSpecificItem("f/env_staging/folder.meta.yaml", config)).toEqual(true); // Variables, resources, triggers, settings are NOT configured - assertEquals(isItemTypeConfigured("f/test.variable.yaml", config), false); - assertEquals(isItemTypeConfigured("f/db.resource.yaml", config), false); - assertEquals(isItemTypeConfigured("f/hook.http_trigger.yaml", config), false); - assertEquals(isItemTypeConfigured("settings.yaml", config), false); + expect(isItemTypeConfigured("f/test.variable.yaml", config)).toEqual(false); + expect(isItemTypeConfigured("f/db.resource.yaml", config)).toEqual(false); + expect(isItemTypeConfigured("f/hook.http_trigger.yaml", config)).toEqual(false); + expect(isItemTypeConfigured("settings.yaml", config)).toEqual(false); }); -Deno.test("mixed config: only settings configured - other types use base files", () => { +test("mixed config: only settings configured - other types use base files", () => { const config: SpecificItemsConfig = { settings: true, }; // Settings IS configured - assertEquals(isItemTypeConfigured("settings.yaml", config), true); - assertEquals(isSpecificItem("settings.yaml", config), true); + expect(isItemTypeConfigured("settings.yaml", config)).toEqual(true); + expect(isSpecificItem("settings.yaml", config)).toEqual(true); // Other types are NOT configured - assertEquals(isItemTypeConfigured("f/test.variable.yaml", config), false); - assertEquals(isItemTypeConfigured("f/db.resource.yaml", config), false); - assertEquals(isItemTypeConfigured("f/hook.http_trigger.yaml", config), false); - assertEquals(isItemTypeConfigured("f/my_folder/folder.meta.yaml", config), false); + expect(isItemTypeConfigured("f/test.variable.yaml", config)).toEqual(false); + expect(isItemTypeConfigured("f/db.resource.yaml", config)).toEqual(false); + expect(isItemTypeConfigured("f/hook.http_trigger.yaml", config)).toEqual(false); + expect(isItemTypeConfigured("f/my_folder/folder.meta.yaml", config)).toEqual(false); }); -Deno.test("mixed config: variables and folders configured - resources and triggers use base", () => { +test("mixed config: variables and folders configured - resources and triggers use base", () => { const config: SpecificItemsConfig = { variables: ["f/**"], folders: ["f/env_*"], }; // Variables IS configured - assertEquals(isItemTypeConfigured("f/test.variable.yaml", config), true); - assertEquals(isSpecificItem("f/test.variable.yaml", config), true); + expect(isItemTypeConfigured("f/test.variable.yaml", config)).toEqual(true); + expect(isSpecificItem("f/test.variable.yaml", config)).toEqual(true); // Folders IS configured (path matches) - assertEquals(isItemTypeConfigured("f/env_staging/folder.meta.yaml", config), true); - assertEquals(isSpecificItem("f/env_staging/folder.meta.yaml", config), true); + expect(isItemTypeConfigured("f/env_staging/folder.meta.yaml", config)).toEqual(true); + expect(isSpecificItem("f/env_staging/folder.meta.yaml", config)).toEqual(true); // Folders IS configured but path doesn't match - assertEquals(isItemTypeConfigured("f/other/folder.meta.yaml", config), true); - assertEquals(isSpecificItem("f/other/folder.meta.yaml", config), false); + expect(isItemTypeConfigured("f/other/folder.meta.yaml", config)).toEqual(true); + expect(isSpecificItem("f/other/folder.meta.yaml", config)).toEqual(false); // Resources and triggers are NOT configured - assertEquals(isItemTypeConfigured("f/db.resource.yaml", config), false); - assertEquals(isItemTypeConfigured("f/hook.http_trigger.yaml", config), false); - assertEquals(isItemTypeConfigured("settings.yaml", config), false); + expect(isItemTypeConfigured("f/db.resource.yaml", config)).toEqual(false); + expect(isItemTypeConfigured("f/hook.http_trigger.yaml", config)).toEqual(false); + expect(isItemTypeConfigured("settings.yaml", config)).toEqual(false); }); diff --git a/cli/test/standalone_commands.test.ts b/cli/test/standalone_commands.test.ts new file mode 100644 index 0000000000..106e4aaeb1 --- /dev/null +++ b/cli/test/standalone_commands.test.ts @@ -0,0 +1,514 @@ +/** + * Integration tests for standalone CLI commands that previously had zero coverage. + * + * Tests: + * - `wmill folder` (list) + * - `wmill schedule` (list with data) + * - `wmill resource-type list` and `wmill resource-type push` + * - `wmill script show`, `wmill script run`, `wmill script bootstrap` + * - `wmill user` (list, add, remove) + */ + +import { expect, test, describe } from "bun:test"; +import { writeFile, mkdir, stat, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withTestBackend, type TestBackend } from "./test_backend.ts"; +import { shouldSkipOnCI } from "./cargo_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +async function setupWorkspaceProfile(backend: TestBackend): Promise { + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "localhost_test", + token: backend.token!, + }, + { force: true, configDir: backend.testConfigDir } + ); +} + +/** Create a script on the remote via API and return its path */ +async function createRemoteScript( + backend: TestBackend, + scriptPath: string, + content: string = 'export async function main() { return "hello"; }' +): Promise { + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content, + language: "bun", + summary: "Test script", + description: "Created by integration test", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + }), + } + ); + expect(resp.status).toBeLessThan(300); + await resp.text(); +} + +// ============================================================================= +// Folder List +// ============================================================================= + +describe("folder list command", () => { + test("lists seeded folders", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand(["folder"], tempDir); + + expect(result.code).toEqual(0); + // seedTestData creates a "test" folder + expect(result.stdout).toContain("test"); + // Table headers should be present + expect(result.stdout).toContain("Name"); + }); + }); +}); + +// ============================================================================= +// Schedule List +// ============================================================================= + +describe("schedule list command", () => { + test("lists a schedule created via API", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const scriptPath = `f/test/sched_list_target_${uniqueId}`; + const schedulePath = `f/test/sched_list_${uniqueId}`; + + // Create target script + await createRemoteScript(backend, scriptPath); + + // Create schedule via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/schedules/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: schedulePath, + schedule: "0 0 12 * * *", + script_path: scriptPath, + is_flow: false, + args: {}, + enabled: false, + timezone: "UTC", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // List schedules via CLI + const result = await backend.runCLICommand(["schedule"], tempDir); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain(schedulePath); + expect(result.stdout).toContain("0 0 12 * * *"); + }); + }); +}); + +// ============================================================================= +// Resource Type List & Push +// ============================================================================= + +describe("resource-type commands", () => { + test("list returns exit code 0", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand( + ["resource-type", "list"], + tempDir + ); + + expect(result.code).toEqual(0); + // Table headers should be present + expect(result.stdout).toContain("Name"); + }); + }); + + test("push creates a new resource type", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const rtName = `test_rt_${uniqueId}`; + + // Create a resource type JSON file + const rtFile = join(tempDir, `${rtName}.resource-type.json`); + await writeFile( + rtFile, + JSON.stringify({ + schema: { + type: "object", + properties: { + host: { type: "string" }, + port: { type: "integer" }, + }, + }, + description: "Test resource type from integration test", + }), + "utf-8" + ); + + // Push via CLI — the name argument must include the .resource-type.json suffix + const pushResult = await backend.runCLICommand( + ["resource-type", "push", rtFile, `${rtName}.resource-type.json`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/resources/type/get/${rtName}` + ); + expect(apiResp.status).toEqual(200); + const rtData = await apiResp.json(); + expect(rtData.name).toBe(rtName); + expect(rtData.schema).toBeDefined(); + expect(rtData.schema.properties.host.type).toBe("string"); + }); + }); + + test("push updates an existing resource type", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const rtName = `test_rt_upd_${uniqueId}`; + + // Create resource type via API first + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/resources/type/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: rtName, + schema: { + type: "object", + properties: { old_field: { type: "string" } }, + }, + description: "Original", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Create updated resource type file + const rtFile = join(tempDir, `${rtName}.resource-type.json`); + await writeFile( + rtFile, + JSON.stringify({ + schema: { + type: "object", + properties: { + new_field: { type: "number" }, + }, + }, + description: "Updated description", + }), + "utf-8" + ); + + // Push update via CLI — the name argument must include the .resource-type.json suffix + const pushResult = await backend.runCLICommand( + ["resource-type", "push", rtFile, `${rtName}.resource-type.json`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify the update via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/resources/type/get/${rtName}` + ); + expect(apiResp.status).toEqual(200); + const rtData = await apiResp.json(); + expect(rtData.description).toBe("Updated description"); + expect(rtData.schema.properties.new_field.type).toBe("number"); + }); + }); +}); + +// ============================================================================= +// Script Show +// ============================================================================= + +describe("script show command", () => { + test("shows script content", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const scriptPath = `f/test/show_script_${uniqueId}`; + const scriptContent = `export async function main() { return "show_test_${uniqueId}"; }`; + + await createRemoteScript(backend, scriptPath, scriptContent); + + const result = await backend.runCLICommand( + ["script", "show", scriptPath], + tempDir + ); + + expect(result.code).toEqual(0); + // Should display the script content + const output = result.stdout + result.stderr; + expect(output).toContain(`show_test_${uniqueId}`); + expect(output).toContain(scriptPath); + }); + }); +}); + +// ============================================================================= +// Script Run +// ============================================================================= + +describe("script run command", () => { + test("runs a script and returns result", { timeout: 60000 }, async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const scriptPath = `f/test/run_script_${uniqueId}`; + const scriptContent = `export async function main() { return { value: "run_result_${uniqueId}" }; }`; + + await createRemoteScript(backend, scriptPath, scriptContent); + + const result = await backend.runCLICommand( + ["script", "run", scriptPath, "--silent"], + tempDir + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain(`run_result_${uniqueId}`); + }); + }); +}); + +// ============================================================================= +// Script Bootstrap +// ============================================================================= + +describe("script bootstrap command", () => { + test("creates TypeScript script files", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + // Create a wmill.yaml so bootstrap can read config + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + [ + "script", + "bootstrap", + "f/test/new_script", + "bun", + "--summary", + "My new script", + ], + tempDir + ); + + expect(result.code).toEqual(0); + + // Verify the code file was created + const codeStat = await stat(join(tempDir, "f/test/new_script.ts")); + expect(codeStat.isFile()).toBe(true); + + // Verify the metadata file was created + const metaStat = await stat( + join(tempDir, "f/test/new_script.script.yaml") + ); + expect(metaStat.isFile()).toBe(true); + + // Verify metadata content + const metaContent = await readFile( + join(tempDir, "f/test/new_script.script.yaml"), + "utf-8" + ); + expect(metaContent).toContain("My new script"); + }); + }); + + test("creates Python script files", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["script", "bootstrap", "f/test/py_script", "python3"], + tempDir + ); + + expect(result.code).toEqual(0); + + const codeStat = await stat(join(tempDir, "f/test/py_script.py")); + expect(codeStat.isFile()).toBe(true); + + const metaStat = await stat( + join(tempDir, "f/test/py_script.script.yaml") + ); + expect(metaStat.isFile()).toBe(true); + }); + }); + + test("creates Bash script files", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["script", "bootstrap", "f/test/bash_script", "bash"], + tempDir + ); + + expect(result.code).toEqual(0); + + const codeStat = await stat(join(tempDir, "f/test/bash_script.sh")); + expect(codeStat.isFile()).toBe(true); + }); + }); + + test("creates Go script files", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + + const result = await backend.runCLICommand( + ["script", "bootstrap", "f/test/go_script", "go"], + tempDir + ); + + expect(result.code).toEqual(0); + + const codeStat = await stat(join(tempDir, "f/test/go_script.go")); + expect(codeStat.isFile()).toBe(true); + }); + }); +}); + +// ============================================================================= +// User List, Add, Remove +// ============================================================================= + +describe("user commands", () => { + test("list shows existing admin user", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand(["user"], tempDir); + + expect(result.code).toEqual(0); + // The admin user is always created by the test backend + expect(result.stdout).toContain("admin@windmill.dev"); + // Table headers + expect(result.stdout).toContain("email"); + }); + }); + + test.skipIf(shouldSkipOnCI())("add creates a new user and remove deletes it", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const email = `testuser_${uniqueId}@example.com`; + const password = "testpass123"; + + // Add user + const addResult = await backend.runCLICommand( + ["user", "add", email, password], + tempDir + ); + expect(addResult.code).toEqual(0); + + // Verify the user appears in the list + const listResult = await backend.runCLICommand(["user"], tempDir); + expect(listResult.code).toEqual(0); + expect(listResult.stdout).toContain(email); + + // Remove user + const removeResult = await backend.runCLICommand( + ["user", "remove", email], + tempDir + ); + expect(removeResult.code).toEqual(0); + + // Verify the user no longer appears + const listAfterResult = await backend.runCLICommand(["user"], tempDir); + expect(listAfterResult.code).toEqual(0); + expect(listAfterResult.stdout).not.toContain(email); + }); + }); + + test.skipIf(shouldSkipOnCI())("add with --superadmin flag creates superadmin user", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const email = `superuser_${uniqueId}@example.com`; + const password = "superpass123"; + + // Add superadmin user + const addResult = await backend.runCLICommand( + ["user", "add", email, password, "--superadmin"], + tempDir + ); + expect(addResult.code).toEqual(0); + + // Verify user exists and is superadmin + const listResult = await backend.runCLICommand(["user"], tempDir); + expect(listResult.code).toEqual(0); + expect(listResult.stdout).toContain(email); + + // Clean up + await backend.runCLICommand(["user", "remove", email], tempDir); + }); + }); +}); diff --git a/cli/test/sync_config_resolution.test.ts b/cli/test/sync_config_resolution.test.ts index a24d3e7c15..d5583b010b 100644 --- a/cli/test/sync_config_resolution.test.ts +++ b/cli/test/sync_config_resolution.test.ts @@ -1,4 +1,5 @@ -import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; import { readConfigFile, getEffectiveSettings } from "../src/core/conf.ts"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; @@ -26,17 +27,13 @@ async function setupWorkspaceProfile(backend: any): Promise { // INTEGRATION TESTS WITH REAL BACKEND // ============================================================================= -Deno.test({ - name: "Integration: wmill.yaml configuration produces expected results", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Integration: wmill.yaml configuration produces expected results", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace profile with name "localhost_test" await setupWorkspaceProfile(backend); // Create wmill.yaml with settings - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - f/** - settings.yaml @@ -46,7 +43,7 @@ skipVariables: true skipResources: true includeSettings: true includeSchedules: true -includeTriggers: true`); +includeTriggers: true`, "utf-8"); // Test pull with wmill.yaml configuration const yamlResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); @@ -56,7 +53,7 @@ includeTriggers: true`); console.log("Stdout:", yamlResult.stdout); console.log("Stderr:", yamlResult.stderr); } - assertEquals(yamlResult.code, 0); + expect(yamlResult.code).toEqual(0); // Extract JSON from CLI output (skip log messages) const yamlData = parseJsonFromCLIOutput(yamlResult.stdout); @@ -65,7 +62,7 @@ includeTriggers: true`); const hasSettings = (yamlData.changes || []).some((change: any) => change.type === 'added' && change.path === 'settings.yaml' ); - assertEquals(hasSettings, true); + expect(hasSettings).toEqual(true); // Should NOT include resources or variables (due to skip flags) const hasResources = (yamlData.changes || []).some((change: any) => @@ -74,72 +71,64 @@ includeTriggers: true`); const hasVariables = (yamlData.changes || []).some((change: any) => change.type === 'added' && change.path?.includes('.variable.yaml') ); - assertEquals(hasResources, false); - assertEquals(hasVariables, false); + expect(hasResources).toEqual(false); + expect(hasVariables).toEqual(false); }); -}}); +}); -Deno.test({ - name: "Integration: settings.yaml inclusion respects includeSettings flag", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Integration: settings.yaml inclusion respects includeSettings flag", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace profile with name "localhost_test" await setupWorkspaceProfile(backend); // Test 1: includeSettings: true should include settings.yaml - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" -includeSettings: true`); +includeSettings: true`, "utf-8"); const includeResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); - assertEquals(includeResult.code, 0); + expect(includeResult.code).toEqual(0); // Extract JSON from CLI output (skip log messages) const includeData = parseJsonFromCLIOutput(includeResult.stdout); const hasSettingsInclude = (includeData.changes || []).some((change: any) => change.type === 'added' && change.path === 'settings.yaml' ); - assertEquals(hasSettingsInclude, true); + expect(hasSettingsInclude).toEqual(true); // Test 2: includeSettings: false should NOT include settings.yaml - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" -includeSettings: false`); +includeSettings: false`, "utf-8"); const excludeResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); - assertEquals(excludeResult.code, 0); + expect(excludeResult.code).toEqual(0); // Extract JSON from CLI output (skip log messages) const excludeData = parseJsonFromCLIOutput(excludeResult.stdout); const hasSettingsExclude = (excludeData.changes || []).some((change: any) => change.type === 'added' && change.path === 'settings.yaml' ); - assertEquals(hasSettingsExclude, false); + expect(hasSettingsExclude).toEqual(false); }); -}}); +}); -Deno.test({ - name: "Integration: resource/variable filtering respects skip flags", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Integration: resource/variable filtering respects skip flags", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace profile with name "localhost_test" await setupWorkspaceProfile(backend); // Test skipResources: true - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" skipResources: true -skipVariables: false`); +skipVariables: false`, "utf-8"); const result = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); - assertEquals(result.code, 0); + expect(result.code).toEqual(0); // Extract JSON from CLI output (skip log messages) const data = parseJsonFromCLIOutput(result.stdout); @@ -148,42 +137,38 @@ skipVariables: false`); const hasResources = (data.changes || []).some((change: any) => change.type === 'added' && change.path?.includes('.resource.yaml') ); - assertEquals(hasResources, false); + expect(hasResources).toEqual(false); // Should include variables (not skipped) const hasVariables = (data.changes || []).some((change: any) => change.type === 'added' && change.path?.includes('.variable.yaml') ); - assertEquals(hasVariables, true); + expect(hasVariables).toEqual(true); }); -}}); +}); // ============================================================================= // CLI FLAG OVERRIDE TESTS // Tests for CLI flags overriding configuration file settings // ============================================================================= -Deno.test({ - name: "CLI skip flags override wmill.yaml configuration", - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("CLI skip flags override wmill.yaml configuration", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace profile with name "localhost_test" await setupWorkspaceProfile(backend); // Create wmill.yaml that INCLUDES resources by default (skipResources: false) - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - f/** - u/** skipResources: false skipResourceTypes: false -includeSettings: true`); +includeSettings: true`, "utf-8"); // Test 1: Without CLI flags - should respect wmill.yaml (include resources) const configResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); - assertEquals(configResult.code, 0); + expect(configResult.code).toEqual(0); const configData = parseJsonFromCLIOutput(configResult.stdout); @@ -192,7 +177,7 @@ includeSettings: true`); const hasResources = (configData.changes || []).some((change: any) => change.type === 'added' && change.path?.includes('.resource.yaml') ); - assertEquals(hasResources, true, "Resources should be included by wmill.yaml config"); + expect(hasResources).toEqual(true); // Test 2: With CLI --skip-resources flag - should override wmill.yaml to skip resources const overrideResult = await backend.runCLICommand([ @@ -200,7 +185,7 @@ includeSettings: true`); '--skip-resources', // CLI flag should override config to skip resources '--skip-resource-types' // CLI flag should override config to skip resource types ], tempDir); - assertEquals(overrideResult.code, 0); + expect(overrideResult.code).toEqual(0); const overrideData = parseJsonFromCLIOutput(overrideResult.stdout); @@ -208,12 +193,12 @@ includeSettings: true`); const hasResourcesOverride = (overrideData.changes || []).some((change: any) => change.type === 'added' && change.path?.includes('.resource.yaml') ); - assertEquals(hasResourcesOverride, false, "CLI --skip-resources flag should override wmill.yaml to exclude resources"); + expect(hasResourcesOverride).toEqual(false); // Should NOT include resource types (CLI flag overrides config) const hasResourceTypesOverride = (overrideData.changes || []).some((change: any) => change.type === 'added' && change.path?.includes('.resource-type.yaml') ); - assertEquals(hasResourceTypesOverride, false, "CLI --skip-resource-types flag should override wmill.yaml to exclude resource types"); + expect(hasResourceTypesOverride).toEqual(false); }); -}}); +}); diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index fdbb65cccc..2b062eb157 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -5,11 +5,13 @@ * containing every kind of Windmill resource type. */ -import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { ensureDir } from "https://deno.land/std@0.224.0/fs/mod.ts"; -import * as path from "https://deno.land/std@0.224.0/path/mod.ts"; -import { SEPARATOR as SEP } from "https://deno.land/std@0.224.0/path/mod.ts"; -import { JSZip } from "../deps.ts"; +import { expect, test, describe } from "bun:test"; +import * as path from "node:path"; +import { sep as SEP } from "node:path"; +import { writeFile, readFile, readdir, rm, mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import JSZip from "jszip"; import { getFolderSuffix, getMetadataFileName, @@ -334,7 +336,7 @@ async function createLocalFilesystem(baseDir: string): Promise { // Create folder structure const folders = ["f/scripts", "f/flows", "f/apps", "f/resources"]; for (const folder of folders) { - await ensureDir(path.join(baseDir, folder)); + await mkdir(path.join(baseDir, folder), { recursive: true }); } // Create scripts @@ -347,35 +349,37 @@ async function createLocalFilesystem(baseDir: string): Promise { ]; for (const script of scripts) { - await Deno.writeTextFile( + await writeFile( path.join(baseDir, script.contentFile.path), script.contentFile.content, + "utf-8", ); - await Deno.writeTextFile( + await writeFile( path.join(baseDir, script.metadataFile.path), script.metadataFile.content, + "utf-8", ); } // Create flows const flowFixture = createFlowFixture("f/flows/test_flow"); - await ensureDir(path.join(baseDir, `f/flows/test_flow${getFolderSuffix("flow")}`)); + await mkdir(path.join(baseDir, `f/flows/test_flow${getFolderSuffix("flow")}`), { recursive: true }); for (const file of Object.values(flowFixture)) { - await Deno.writeTextFile(path.join(baseDir, file.path), file.content); + await writeFile(path.join(baseDir, file.path), file.content, "utf-8"); } // Create apps const appFixture = createAppFixture("f/apps/test_app"); - await ensureDir(path.join(baseDir, `f/apps/test_app${getFolderSuffix("app")}`)); + await mkdir(path.join(baseDir, `f/apps/test_app${getFolderSuffix("app")}`), { recursive: true }); for (const file of Object.values(appFixture)) { - await Deno.writeTextFile(path.join(baseDir, file.path), file.content); + await writeFile(path.join(baseDir, file.path), file.content, "utf-8"); } // Create raw apps const rawAppFixture = createRawAppFixture("f/apps/test_raw_app"); - await ensureDir(path.join(baseDir, `f/apps/test_raw_app${getFolderSuffix("raw_app")}`)); + await mkdir(path.join(baseDir, `f/apps/test_raw_app${getFolderSuffix("raw_app")}`), { recursive: true }); for (const file of Object.values(rawAppFixture)) { - await Deno.writeTextFile(path.join(baseDir, file.path), file.content); + await writeFile(path.join(baseDir, file.path), file.content, "utf-8"); } // Create resources @@ -393,7 +397,7 @@ async function createLocalFilesystem(baseDir: string): Promise { ]; for (const resource of resources) { - await Deno.writeTextFile(path.join(baseDir, resource.path), resource.content); + await writeFile(path.join(baseDir, resource.path), resource.content, "utf-8"); } // Create variables @@ -403,13 +407,13 @@ async function createLocalFilesystem(baseDir: string): Promise { ]; for (const variable of variables) { - await Deno.writeTextFile(path.join(baseDir, variable.path), variable.content); + await writeFile(path.join(baseDir, variable.path), variable.content, "utf-8"); } // Create folder metadata - await ensureDir(path.join(baseDir, "f")); + await mkdir(path.join(baseDir, "f"), { recursive: true }); const folderMeta = createFolderFixture("f"); - await Deno.writeTextFile(path.join(baseDir, folderMeta.path), folderMeta.content); + await writeFile(path.join(baseDir, folderMeta.path), folderMeta.content, "utf-8"); } /** @@ -435,16 +439,17 @@ async function readDirRecursive( ): Promise> { const files: Record = {}; - for await (const entry of Deno.readDir(dir)) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { const fullPath = path.join(dir, entry.name); // Normalize path separators to forward slashes for cross-platform compatibility const relativePath = fullPath.substring(baseDir.length + 1).replaceAll("\\", "/"); - if (entry.isDirectory) { + if (entry.isDirectory()) { const subFiles = await readDirRecursive(fullPath, baseDir); Object.assign(files, subFiles); } else { - files[relativePath] = await Deno.readTextFile(fullPath); + files[relativePath] = await readFile(fullPath, "utf-8"); } } @@ -455,7 +460,7 @@ async function readDirRecursive( * Creates a temporary directory for testing */ async function createTempDir(): Promise { - return await Deno.makeTempDir({ prefix: "wmill_sync_test_" }); + return await mkdtemp(join(tmpdir(), "wmill_sync_test_")); } /** @@ -463,7 +468,7 @@ async function createTempDir(): Promise { */ async function cleanupTempDir(dir: string): Promise { try { - await Deno.remove(dir, { recursive: true }); + await rm(dir, { recursive: true }); } catch { // Ignore cleanup errors } @@ -473,47 +478,47 @@ async function cleanupTempDir(dir: string): Promise { // Tests // ============================================================================= -Deno.test("Resource folder suffixes are correct", () => { - assertEquals(getFolderSuffix("flow"), ".flow"); - assertEquals(getFolderSuffix("app"), ".app"); - assertEquals(getFolderSuffix("raw_app"), ".raw_app"); +test("Resource folder suffixes are correct", () => { + expect(getFolderSuffix("flow")).toEqual(".flow"); + expect(getFolderSuffix("app")).toEqual(".app"); + expect(getFolderSuffix("raw_app")).toEqual(".raw_app"); }); -Deno.test("Metadata file names are correct", () => { - assertEquals(getMetadataFileName("flow", "yaml"), "flow.yaml"); - assertEquals(getMetadataFileName("flow", "json"), "flow.json"); - assertEquals(getMetadataFileName("app", "yaml"), "app.yaml"); - assertEquals(getMetadataFileName("raw_app", "yaml"), "raw_app.yaml"); +test("Metadata file names are correct", () => { + expect(getMetadataFileName("flow", "yaml")).toEqual("flow.yaml"); + expect(getMetadataFileName("flow", "json")).toEqual("flow.json"); + expect(getMetadataFileName("app", "yaml")).toEqual("app.yaml"); + expect(getMetadataFileName("raw_app", "yaml")).toEqual("raw_app.yaml"); }); -Deno.test("buildFolderPath creates correct paths", () => { - assertEquals(buildFolderPath("my_flow", "flow"), "my_flow.flow"); - assertEquals(buildFolderPath("f/test/my_app", "app"), "f/test/my_app.app"); - assertEquals(buildFolderPath("u/admin/raw_app", "raw_app"), "u/admin/raw_app.raw_app"); +test("buildFolderPath creates correct paths", () => { + expect(buildFolderPath("my_flow", "flow")).toEqual("my_flow.flow"); + expect(buildFolderPath("f/test/my_app", "app")).toEqual("f/test/my_app.app"); + expect(buildFolderPath("u/admin/raw_app", "raw_app")).toEqual("u/admin/raw_app.raw_app"); }); // ============================================================================= // nonDottedPaths Tests - API format detection and transformation // ============================================================================= -Deno.test("Metadata file detection works with dotted format (default)", () => { +test("Metadata file detection works with dotted format (default)", () => { // Ensure we're in default mode setNonDottedPaths(false); // API always returns dotted format - assert(isFlowMetadataFile("f/my_flow.flow.json"), "Should detect .flow.json"); - assert(isFlowMetadataFile("f/my_flow.flow.yaml"), "Should detect .flow.yaml"); - assert(isAppMetadataFile("f/my_app.app.json"), "Should detect .app.json"); - assert(isAppMetadataFile("f/my_app.app.yaml"), "Should detect .app.yaml"); - assert(isRawAppMetadataFile("f/my_raw.raw_app.json"), "Should detect .raw_app.json"); - assert(isRawAppMetadataFile("f/my_raw.raw_app.yaml"), "Should detect .raw_app.yaml"); + expect(isFlowMetadataFile("f/my_flow.flow.json")).toBeTruthy(); + expect(isFlowMetadataFile("f/my_flow.flow.yaml")).toBeTruthy(); + expect(isAppMetadataFile("f/my_app.app.json")).toBeTruthy(); + expect(isAppMetadataFile("f/my_app.app.yaml")).toBeTruthy(); + expect(isRawAppMetadataFile("f/my_raw.raw_app.json")).toBeTruthy(); + expect(isRawAppMetadataFile("f/my_raw.raw_app.yaml")).toBeTruthy(); // Non-matching should return false - assert(!isFlowMetadataFile("f/my_script.ts"), "Should not detect script file"); - assert(!isAppMetadataFile("f/my_script.ts"), "Should not detect script file"); + expect(!isFlowMetadataFile("f/my_script.ts")).toBeTruthy(); + expect(!isAppMetadataFile("f/my_script.ts")).toBeTruthy(); }); -Deno.test("Metadata file detection works with nonDottedPaths=true", () => { +test("Metadata file detection works with nonDottedPaths=true", () => { // Store original value const wasNonDotted = getNonDottedPaths(); @@ -521,309 +526,281 @@ Deno.test("Metadata file detection works with nonDottedPaths=true", () => { setNonDottedPaths(true); // API format (dotted) should still be detected - assert(isFlowMetadataFile("f/my_flow.flow.json"), "Should detect API format .flow.json"); - assert(isAppMetadataFile("f/my_app.app.json"), "Should detect API format .app.json"); - assert(isRawAppMetadataFile("f/my_raw.raw_app.json"), "Should detect API format .raw_app.json"); + expect(isFlowMetadataFile("f/my_flow.flow.json")).toBeTruthy(); + expect(isAppMetadataFile("f/my_app.app.json")).toBeTruthy(); + expect(isRawAppMetadataFile("f/my_raw.raw_app.json")).toBeTruthy(); // Local format (non-dotted) should also be detected - assert(isFlowMetadataFile("f/my_flow__flow.json"), "Should detect local format __flow.json"); - assert(isFlowMetadataFile("f/my_flow__flow.yaml"), "Should detect local format __flow.yaml"); - assert(isAppMetadataFile("f/my_app__app.json"), "Should detect local format __app.json"); - assert(isRawAppMetadataFile("f/my_raw__raw_app.json"), "Should detect local format __raw_app.json"); + expect(isFlowMetadataFile("f/my_flow__flow.json")).toBeTruthy(); + expect(isFlowMetadataFile("f/my_flow__flow.yaml")).toBeTruthy(); + expect(isAppMetadataFile("f/my_app__app.json")).toBeTruthy(); + expect(isRawAppMetadataFile("f/my_raw__raw_app.json")).toBeTruthy(); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); -Deno.test("transformJsonPathToDir transforms API format to local format", () => { +test("transformJsonPathToDir transforms API format to local format", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { // Test with dotted paths (default) setNonDottedPaths(false); - assertEquals( - transformJsonPathToDir("f/my_flow.flow.json", "flow"), - "f/my_flow.flow", - "Should transform dotted API format to dotted local format" - ); - assertEquals( - transformJsonPathToDir("f/my_app.app.json", "app"), - "f/my_app.app", - "Should transform app correctly" - ); - assertEquals( - transformJsonPathToDir("f/my_raw.raw_app.json", "raw_app"), - "f/my_raw.raw_app", - "Should transform raw_app correctly" - ); + expect(transformJsonPathToDir("f/my_flow.flow.json", "flow")).toEqual("f/my_flow.flow"); + expect(transformJsonPathToDir("f/my_app.app.json", "app")).toEqual("f/my_app.app"); + expect(transformJsonPathToDir("f/my_raw.raw_app.json", "raw_app")).toEqual("f/my_raw.raw_app"); // Test with non-dotted paths setNonDottedPaths(true); - assertEquals( - transformJsonPathToDir("f/my_flow.flow.json", "flow"), - "f/my_flow__flow", - "Should transform dotted API format to non-dotted local format" - ); - assertEquals( - transformJsonPathToDir("f/my_app.app.json", "app"), - "f/my_app__app", - "Should transform app to non-dotted format" - ); - assertEquals( - transformJsonPathToDir("f/my_raw.raw_app.json", "raw_app"), - "f/my_raw__raw_app", - "Should transform raw_app to non-dotted format" - ); + expect(transformJsonPathToDir("f/my_flow.flow.json", "flow")).toEqual("f/my_flow__flow"); + expect(transformJsonPathToDir("f/my_app.app.json", "app")).toEqual("f/my_app__app"); + expect(transformJsonPathToDir("f/my_raw.raw_app.json", "raw_app")).toEqual("f/my_raw__raw_app"); // Non-matching paths should be returned unchanged - assertEquals( - transformJsonPathToDir("f/my_script.ts", "flow"), - "f/my_script.ts", - "Should return non-matching path unchanged" - ); + expect(transformJsonPathToDir("f/my_script.ts", "flow")).toEqual("f/my_script.ts"); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); -Deno.test("getFolderSuffix returns correct suffix based on nonDottedPaths setting", () => { +test("getFolderSuffix returns correct suffix based on nonDottedPaths setting", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { setNonDottedPaths(false); - assertEquals(getFolderSuffix("flow"), ".flow"); - assertEquals(getFolderSuffix("app"), ".app"); - assertEquals(getFolderSuffix("raw_app"), ".raw_app"); + expect(getFolderSuffix("flow")).toEqual(".flow"); + expect(getFolderSuffix("app")).toEqual(".app"); + expect(getFolderSuffix("raw_app")).toEqual(".raw_app"); setNonDottedPaths(true); - assertEquals(getFolderSuffix("flow"), "__flow"); - assertEquals(getFolderSuffix("app"), "__app"); - assertEquals(getFolderSuffix("raw_app"), "__raw_app"); + expect(getFolderSuffix("flow")).toEqual("__flow"); + expect(getFolderSuffix("app")).toEqual("__app"); + expect(getFolderSuffix("raw_app")).toEqual("__raw_app"); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); -Deno.test("newPathAssigner with skipInlineScriptSuffix removes .inline_script. from paths", () => { +test("newPathAssigner with skipInlineScriptSuffix removes .inline_script. from paths", () => { // Test default behavior (with .inline_script. suffix) const defaultAssigner = newPathAssigner("bun"); const [defaultPath, defaultExt] = defaultAssigner.assignPath("my_script", "bun"); - assertEquals(defaultPath, "my_script.inline_script."); - assertEquals(defaultExt, "ts"); + expect(defaultPath).toEqual("my_script.inline_script."); + expect(defaultExt).toEqual("ts"); // Test with skipInlineScriptSuffix = false (explicit) const withSuffixAssigner = newPathAssigner("bun", { skipInlineScriptSuffix: false }); const [withSuffixPath, withSuffixExt] = withSuffixAssigner.assignPath("another_script", "python3"); - assertEquals(withSuffixPath, "another_script.inline_script."); - assertEquals(withSuffixExt, "py"); + expect(withSuffixPath).toEqual("another_script.inline_script."); + expect(withSuffixExt).toEqual("py"); // Test with skipInlineScriptSuffix = true (no .inline_script. suffix) const noSuffixAssigner = newPathAssigner("bun", { skipInlineScriptSuffix: true }); const [noSuffixPath, noSuffixExt] = noSuffixAssigner.assignPath("clean_script", "bun"); - assertEquals(noSuffixPath, "clean_script."); - assertEquals(noSuffixExt, "ts"); + expect(noSuffixPath).toEqual("clean_script."); + expect(noSuffixExt).toEqual("ts"); // Test with skipInlineScriptSuffix = true and different language const noSuffixPyAssigner = newPathAssigner("bun", { skipInlineScriptSuffix: true }); const [noSuffixPyPath, noSuffixPyExt] = noSuffixPyAssigner.assignPath("python_script", "python3"); - assertEquals(noSuffixPyPath, "python_script."); - assertEquals(noSuffixPyExt, "py"); + expect(noSuffixPyPath).toEqual("python_script."); + expect(noSuffixPyExt).toEqual("py"); }); -Deno.test("newPathAssigner generates unique paths for duplicate names", () => { +test("newPathAssigner generates unique paths for duplicate names", () => { const assigner = newPathAssigner("bun", { skipInlineScriptSuffix: true }); // First script const [path1, ext1] = assigner.assignPath("my_script", "bun"); - assertEquals(path1, "my_script."); - assertEquals(ext1, "ts"); + expect(path1).toEqual("my_script."); + expect(ext1).toEqual("ts"); // Second script with same name should get counter const [path2, ext2] = assigner.assignPath("my_script", "bun"); - assertEquals(path2, "my_script_1."); - assertEquals(ext2, "ts"); + expect(path2).toEqual("my_script_1."); + expect(ext2).toEqual("ts"); // Third script with same name should get incremented counter const [path3, ext3] = assigner.assignPath("my_script", "python3"); - assertEquals(path3, "my_script_2."); - assertEquals(ext3, "py"); + expect(path3).toEqual("my_script_2."); + expect(ext3).toEqual("py"); }); -Deno.test("isAppInlineScriptPath detects app inline scripts correctly", () => { +test("isAppInlineScriptPath detects app inline scripts correctly", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { // Test with dotted paths (default) setNonDottedPaths(false); - assert(isAppInlineScriptPath("f/my_app.app/my_script.ts"), "Should detect script in .app folder"); - assert(isAppInlineScriptPath("f/my_app.app/app.yaml"), "Should detect metadata in .app folder"); - assert(!isAppInlineScriptPath("f/my_script.ts"), "Should not detect standalone script"); - assert(!isAppInlineScriptPath("f/my_flow.flow/flow.yaml"), "Should not detect flow files"); + expect(isAppInlineScriptPath("f/my_app.app/my_script.ts")).toBeTruthy(); + expect(isAppInlineScriptPath("f/my_app.app/app.yaml")).toBeTruthy(); + expect(!isAppInlineScriptPath("f/my_script.ts")).toBeTruthy(); + expect(!isAppInlineScriptPath("f/my_flow.flow/flow.yaml")).toBeTruthy(); // Test with non-dotted paths setNonDottedPaths(true); - assert(isAppInlineScriptPath("f/my_app__app/my_script.ts"), "Should detect script in __app folder"); - assert(isAppInlineScriptPath("f/my_app__app/app.yaml"), "Should detect metadata in __app folder"); - assert(!isAppInlineScriptPath("f/my_script.ts"), "Should not detect standalone script"); - assert(!isAppInlineScriptPath("f/my_flow__flow/flow.yaml"), "Should not detect flow files"); + expect(isAppInlineScriptPath("f/my_app__app/my_script.ts")).toBeTruthy(); + expect(isAppInlineScriptPath("f/my_app__app/app.yaml")).toBeTruthy(); + expect(!isAppInlineScriptPath("f/my_script.ts")).toBeTruthy(); + expect(!isAppInlineScriptPath("f/my_flow__flow/flow.yaml")).toBeTruthy(); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); -Deno.test("isFlowInlineScriptPath detects flow inline scripts correctly", () => { +test("isFlowInlineScriptPath detects flow inline scripts correctly", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { // Test with dotted paths (default) setNonDottedPaths(false); - assert(isFlowInlineScriptPath("f/my_flow.flow/my_script.ts"), "Should detect script in .flow folder"); - assert(isFlowInlineScriptPath("f/my_flow.flow/flow.yaml"), "Should detect metadata in .flow folder"); - assert(!isFlowInlineScriptPath("f/my_script.ts"), "Should not detect standalone script"); - assert(!isFlowInlineScriptPath("f/my_app.app/app.yaml"), "Should not detect app files"); + expect(isFlowInlineScriptPath("f/my_flow.flow/my_script.ts")).toBeTruthy(); + expect(isFlowInlineScriptPath("f/my_flow.flow/flow.yaml")).toBeTruthy(); + expect(!isFlowInlineScriptPath("f/my_script.ts")).toBeTruthy(); + expect(!isFlowInlineScriptPath("f/my_app.app/app.yaml")).toBeTruthy(); // Test with non-dotted paths setNonDottedPaths(true); - assert(isFlowInlineScriptPath("f/my_flow__flow/my_script.ts"), "Should detect script in __flow folder"); - assert(isFlowInlineScriptPath("f/my_flow__flow/flow.yaml"), "Should detect metadata in __flow folder"); - assert(!isFlowInlineScriptPath("f/my_script.ts"), "Should not detect standalone script"); - assert(!isFlowInlineScriptPath("f/my_app__app/app.yaml"), "Should not detect app files"); + expect(isFlowInlineScriptPath("f/my_flow__flow/my_script.ts")).toBeTruthy(); + expect(isFlowInlineScriptPath("f/my_flow__flow/flow.yaml")).toBeTruthy(); + expect(!isFlowInlineScriptPath("f/my_script.ts")).toBeTruthy(); + expect(!isFlowInlineScriptPath("f/my_app__app/app.yaml")).toBeTruthy(); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); -Deno.test("isRawAppBackendPath detects raw app backend paths correctly", () => { +test("isRawAppBackendPath detects raw app backend paths correctly", () => { // Store original value const wasNonDotted = getNonDottedPaths(); try { // Test with dotted paths (default) setNonDottedPaths(false); - assert(isRawAppBackendPath("f/my_app.raw_app/backend/script.ts"), "Should detect script in .raw_app/backend"); - assert(!isRawAppBackendPath("f/my_app.raw_app/index.html"), "Should not detect root files in raw_app"); - assert(!isRawAppBackendPath("f/my_script.ts"), "Should not detect standalone script"); + expect(isRawAppBackendPath("f/my_app.raw_app/backend/script.ts")).toBeTruthy(); + expect(!isRawAppBackendPath("f/my_app.raw_app/index.html")).toBeTruthy(); + expect(!isRawAppBackendPath("f/my_script.ts")).toBeTruthy(); // Test with non-dotted paths setNonDottedPaths(true); - assert(isRawAppBackendPath("f/my_app__raw_app/backend/script.ts"), "Should detect script in __raw_app/backend"); - assert(!isRawAppBackendPath("f/my_app__raw_app/index.html"), "Should not detect root files in raw_app"); - assert(!isRawAppBackendPath("f/my_script.ts"), "Should not detect standalone script"); + expect(isRawAppBackendPath("f/my_app__raw_app/backend/script.ts")).toBeTruthy(); + expect(!isRawAppBackendPath("f/my_app__raw_app/index.html")).toBeTruthy(); + expect(!isRawAppBackendPath("f/my_script.ts")).toBeTruthy(); } finally { // Restore original value setNonDottedPaths(wasNonDotted); } }); -Deno.test("Script fixture creates valid structure", () => { +test("Script fixture creates valid structure", () => { const pythonScript = createScriptFixture("test_script", "python3"); - assertEquals(pythonScript.contentFile.path, "test_script.py"); - assertEquals(pythonScript.metadataFile.path, "test_script.script.yaml"); - assertStringIncludes(pythonScript.contentFile.content, "def main()"); - assertStringIncludes(pythonScript.metadataFile.content, "summary:"); - assertStringIncludes(pythonScript.metadataFile.content, "kind: script"); + expect(pythonScript.contentFile.path).toEqual("test_script.py"); + expect(pythonScript.metadataFile.path).toEqual("test_script.script.yaml"); + expect(pythonScript.contentFile.content).toContain("def main()"); + expect(pythonScript.metadataFile.content).toContain("summary:"); + expect(pythonScript.metadataFile.content).toContain("kind: script"); }); -Deno.test("Flow fixture creates valid structure", () => { +test("Flow fixture creates valid structure", () => { const flow = createFlowFixture("test_flow"); - assertEquals(flow.metadata.path, "test_flow.flow/flow.yaml"); - assertEquals(flow.inlineScript.path, "test_flow.flow/a.ts"); - assertStringIncludes(flow.metadata.content, "summary:"); - assertStringIncludes(flow.metadata.content, "modules:"); - assertStringIncludes(flow.inlineScript.content, "export async function main"); + expect(flow.metadata.path).toEqual("test_flow.flow/flow.yaml"); + expect(flow.inlineScript.path).toEqual("test_flow.flow/a.ts"); + expect(flow.metadata.content).toContain("summary:"); + expect(flow.metadata.content).toContain("modules:"); + expect(flow.inlineScript.content).toContain("export async function main"); }); -Deno.test("App fixture creates valid structure", () => { +test("App fixture creates valid structure", () => { const app = createAppFixture("test_app"); - assertEquals(app.metadata.path, "test_app.app/app.yaml"); - assertStringIncludes(app.metadata.content, "summary:"); - assertStringIncludes(app.metadata.content, "grid:"); - assertStringIncludes(app.metadata.content, "policy:"); + expect(app.metadata.path).toEqual("test_app.app/app.yaml"); + expect(app.metadata.content).toContain("summary:"); + expect(app.metadata.content).toContain("grid:"); + expect(app.metadata.content).toContain("policy:"); }); -Deno.test("Raw app fixture creates valid structure", () => { +test("Raw app fixture creates valid structure", () => { const rawApp = createRawAppFixture("test_raw_app"); - assertEquals(rawApp.metadata.path, "test_raw_app.raw_app/raw_app.yaml"); - assertEquals(rawApp.indexHtml.path, "test_raw_app.raw_app/index.html"); - assertEquals(rawApp.indexJs.path, "test_raw_app.raw_app/index.js"); - assertStringIncludes(rawApp.metadata.content, "summary:"); - assertStringIncludes(rawApp.metadata.content, "runnables:"); + expect(rawApp.metadata.path).toEqual("test_raw_app.raw_app/raw_app.yaml"); + expect(rawApp.indexHtml.path).toEqual("test_raw_app.raw_app/index.html"); + expect(rawApp.indexJs.path).toEqual("test_raw_app.raw_app/index.js"); + expect(rawApp.metadata.content).toContain("summary:"); + expect(rawApp.metadata.content).toContain("runnables:"); }); -Deno.test("Resource fixture creates valid YAML", () => { +test("Resource fixture creates valid YAML", () => { const resource = createResourceFixture("postgres", "postgresql", { host: "localhost", port: 5432, }); - assertEquals(resource.path, "postgres.resource.yaml"); - assertStringIncludes(resource.content, 'resource_type: "postgresql"'); - assertStringIncludes(resource.content, "value:"); + expect(resource.path).toEqual("postgres.resource.yaml"); + expect(resource.content).toContain('resource_type: "postgresql"'); + expect(resource.content).toContain("value:"); }); -Deno.test("Variable fixture creates valid YAML", () => { +test("Variable fixture creates valid YAML", () => { const variable = createVariableFixture("my_var", "test_value", false); - assertEquals(variable.path, "my_var.variable.yaml"); - assertStringIncludes(variable.content, 'value: "test_value"'); - assertStringIncludes(variable.content, "is_secret: false"); + expect(variable.path).toEqual("my_var.variable.yaml"); + expect(variable.content).toContain('value: "test_value"'); + expect(variable.content).toContain("is_secret: false"); }); -Deno.test("Schedule fixture creates valid YAML", () => { +test("Schedule fixture creates valid YAML", () => { const schedule = createScheduleFixture("hourly_job", "u/admin/my_script", "0 * * * *"); - assertEquals(schedule.path, "hourly_job.schedule.yaml"); - assertStringIncludes(schedule.content, 'schedule: "0 * * * *"'); - assertStringIncludes(schedule.content, 'script_path: "u/admin/my_script"'); + expect(schedule.path).toEqual("hourly_job.schedule.yaml"); + expect(schedule.content).toContain('schedule: "0 * * * *"'); + expect(schedule.content).toContain('script_path: "u/admin/my_script"'); }); -Deno.test("HTTP trigger fixture creates valid YAML", () => { +test("HTTP trigger fixture creates valid YAML", () => { const trigger = createHttpTriggerFixture("webhook", "/api/webhook", "u/admin/handler"); - assertEquals(trigger.path, "webhook.http_trigger.yaml"); - assertStringIncludes(trigger.content, 'route_path: "/api/webhook"'); - assertStringIncludes(trigger.content, "http_method: post"); + expect(trigger.path).toEqual("webhook.http_trigger.yaml"); + expect(trigger.content).toContain('route_path: "/api/webhook"'); + expect(trigger.content).toContain("http_method: post"); }); -Deno.test("Folder fixture creates valid YAML", () => { +test("Folder fixture creates valid YAML", () => { const folder = createFolderFixture("my_folder"); - assertEquals(folder.path, "my_folder/folder.meta.yaml"); - assertStringIncludes(folder.content, 'display_name: "my_folder"'); + expect(folder.path).toEqual("my_folder/folder.meta.yaml"); + expect(folder.content).toContain('display_name: "my_folder"'); }); -Deno.test("User fixture creates valid YAML", () => { +test("User fixture creates valid YAML", () => { const user = createUserFixture("test_user", "test@example.com", true); - assertEquals(user.path, "test_user.user.yaml"); - assertStringIncludes(user.content, 'username: "test_user"'); - assertStringIncludes(user.content, 'email: "test@example.com"'); - assertStringIncludes(user.content, "is_admin: true"); + expect(user.path).toEqual("test_user.user.yaml"); + expect(user.content).toContain('username: "test_user"'); + expect(user.content).toContain('email: "test@example.com"'); + expect(user.content).toContain("is_admin: true"); }); -Deno.test("Group fixture creates valid YAML", () => { +test("Group fixture creates valid YAML", () => { const group = createGroupFixture("developers", ["user1", "user2"]); - assertEquals(group.path, "developers.group.yaml"); - assertStringIncludes(group.content, 'name: "developers"'); - assertStringIncludes(group.content, "- user1"); - assertStringIncludes(group.content, "- user2"); + expect(group.path).toEqual("developers.group.yaml"); + expect(group.content).toContain('name: "developers"'); + expect(group.content).toContain("- user1"); + expect(group.content).toContain("- user2"); }); -Deno.test("Local filesystem creation creates all expected files", async () => { +test("Local filesystem creation creates all expected files", async () => { const tempDir = await createTempDir(); try { @@ -831,41 +808,41 @@ Deno.test("Local filesystem creation creates all expected files", async () => { const files = await readDirRecursive(tempDir); // Check scripts exist - assert("f/scripts/python_script.py" in files, "Python script content should exist"); - assert("f/scripts/python_script.script.yaml" in files, "Python script metadata should exist"); - assert("f/scripts/deno_script.ts" in files, "Deno script content should exist"); - assert("f/scripts/bash_script.sh" in files, "Bash script content should exist"); - assert("f/scripts/go_script.go" in files, "Go script content should exist"); - assert("f/scripts/sql_script.sql" in files, "SQL script content should exist"); + expect("f/scripts/python_script.py" in files).toBeTruthy(); + expect("f/scripts/python_script.script.yaml" in files).toBeTruthy(); + expect("f/scripts/deno_script.ts" in files).toBeTruthy(); + expect("f/scripts/bash_script.sh" in files).toBeTruthy(); + expect("f/scripts/go_script.go" in files).toBeTruthy(); + expect("f/scripts/sql_script.sql" in files).toBeTruthy(); // Check flows exist - assert("f/flows/test_flow.flow/flow.yaml" in files, "Flow metadata should exist"); - assert("f/flows/test_flow.flow/a.ts" in files, "Flow inline script should exist"); + expect("f/flows/test_flow.flow/flow.yaml" in files).toBeTruthy(); + expect("f/flows/test_flow.flow/a.ts" in files).toBeTruthy(); // Check apps exist - assert("f/apps/test_app.app/app.yaml" in files, "App metadata should exist"); + expect("f/apps/test_app.app/app.yaml" in files).toBeTruthy(); // Check raw apps exist - assert("f/apps/test_raw_app.raw_app/raw_app.yaml" in files, "Raw app metadata should exist"); - assert("f/apps/test_raw_app.raw_app/index.html" in files, "Raw app HTML should exist"); - assert("f/apps/test_raw_app.raw_app/index.js" in files, "Raw app JS should exist"); + expect("f/apps/test_raw_app.raw_app/raw_app.yaml" in files).toBeTruthy(); + expect("f/apps/test_raw_app.raw_app/index.html" in files).toBeTruthy(); + expect("f/apps/test_raw_app.raw_app/index.js" in files).toBeTruthy(); // Check resources exist - assert("f/resources/postgres_db.resource.yaml" in files, "PostgreSQL resource should exist"); - assert("f/resources/api_config.resource.yaml" in files, "API config resource should exist"); + expect("f/resources/postgres_db.resource.yaml" in files).toBeTruthy(); + expect("f/resources/api_config.resource.yaml" in files).toBeTruthy(); // Check variables exist - assert("f/resources/config_value.variable.yaml" in files, "Config variable should exist"); - assert("f/resources/secret_key.variable.yaml" in files, "Secret variable should exist"); + expect("f/resources/config_value.variable.yaml" in files).toBeTruthy(); + expect("f/resources/secret_key.variable.yaml" in files).toBeTruthy(); // Check folder metadata - assert("f/folder.meta.yaml" in files, "Folder metadata should exist"); + expect("f/folder.meta.yaml" in files).toBeTruthy(); } finally { await cleanupTempDir(tempDir); } }); -Deno.test("Mock remote zip can be created and read", async () => { +test("Mock remote zip can be created and read", async () => { const items = { "test_script.py": 'def main():\n return "hello"', "test_script.script.json": '{"summary":"test","schema":{}}', @@ -877,26 +854,26 @@ Deno.test("Mock remote zip can be created and read", async () => { // Verify files exist in zip const scriptContent = await zip.file("test_script.py")?.async("text"); - assertEquals(scriptContent, 'def main():\n return "hello"'); + expect(scriptContent).toEqual('def main():\n return "hello"'); const flowContent = await zip.file("test_flow.flow.json")?.async("text"); - assertStringIncludes(flowContent!, '"summary":"flow"'); + expect(flowContent!).toContain('"summary":"flow"'); }); -Deno.test("readDirRecursive reads all files correctly", async () => { +test("readDirRecursive reads all files correctly", async () => { const tempDir = await createTempDir(); try { // Create a simple structure - await ensureDir(path.join(tempDir, "subdir")); - await Deno.writeTextFile(path.join(tempDir, "file1.txt"), "content1"); - await Deno.writeTextFile(path.join(tempDir, "subdir", "file2.txt"), "content2"); + await mkdir(path.join(tempDir, "subdir"), { recursive: true }); + await writeFile(path.join(tempDir, "file1.txt"), "content1", "utf-8"); + await writeFile(path.join(tempDir, "subdir", "file2.txt"), "content2", "utf-8"); const files = await readDirRecursive(tempDir); - assertEquals(files["file1.txt"], "content1"); - assertEquals(files["subdir/file2.txt"], "content2"); - assertEquals(Object.keys(files).length, 2); + expect(files["file1.txt"]).toEqual("content1"); + expect(files["subdir/file2.txt"]).toEqual("content2"); + expect(Object.keys(files).length).toEqual(2); } finally { await cleanupTempDir(tempDir); } @@ -906,67 +883,56 @@ Deno.test("readDirRecursive reads all files correctly", async () => { // Integration Tests (use withTestBackend for automated backend setup) // ============================================================================= -import { yamlParseFile } from "../deps.ts"; +import { yamlParseFile } from "../src/utils/yaml.ts"; import { withTestBackend } from "./test_backend.ts"; import { shouldSkipOnCI } from "./cargo_backend.ts"; -Deno.test({ - name: "Integration: Pull creates correct local structure", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Pull creates correct local structure", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, + "utf-8", ); // Run sync pull const result = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals( - result.code, - 0, - `Pull should succeed.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, - ); + expect(result.code).toEqual(0); // Verify files were created const files = await readDirRecursive(tempDir); const hasYamlFiles = Object.keys(files).some((f) => f.endsWith(".yaml") && f !== "wmill.yaml"); - assert(hasYamlFiles || Object.keys(files).length > 1, "Should have pulled files from server"); + expect(hasYamlFiles || Object.keys(files).length > 1).toBeTruthy(); }); - }, -}); + }); -Deno.test({ - name: "Integration: Push uploads local changes correctly", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Push uploads local changes correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, + "utf-8", ); // Create a test script locally with a unique name // Path must have at least 2 segments after prefix (e.g., f/folder/name) const uniqueId = Date.now(); - await ensureDir(`${tempDir}/f/test`); + await mkdir(`${tempDir}/f/test`, { recursive: true }); const script = createScriptFixture(`f/test/push_script_${uniqueId}`, "deno"); - await Deno.writeTextFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content); - await Deno.writeTextFile(`${tempDir}/${script.metadataFile.path}`, script.metadataFile.content); + await writeFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content, "utf-8"); + await writeFile(`${tempDir}/${script.metadataFile.path}`, script.metadataFile.content, "utf-8"); // Run sync push with dry-run first (only push our test script, not everything) const dryRunResult = await backend.runCLICommand( @@ -974,16 +940,8 @@ excludes: [] tempDir, ); - assertEquals( - dryRunResult.code, - 0, - `Dry run should succeed.\nstdout: ${dryRunResult.stdout}\nstderr: ${dryRunResult.stderr}`, - ); - assertStringIncludes( - dryRunResult.stdout + dryRunResult.stderr, - `push_script_${uniqueId}`, - "Should detect the new script", - ); + expect(dryRunResult.code).toEqual(0); + expect(dryRunResult.stdout + dryRunResult.stderr).toContain(`push_script_${uniqueId}`); // Run actual push (only push our test script) const pushResult = await backend.runCLICommand( @@ -991,61 +949,41 @@ excludes: [] tempDir, ); - assertEquals( - pushResult.code, - 0, - `Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`, - ); + expect(pushResult.code).toEqual(0); }); - }, -}); + }); -Deno.test({ - name: "Integration: Pull then Push is idempotent", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Pull then Push is idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, + "utf-8", ); // Pull from remote const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals( - pullResult.code, - 0, - `Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`, - ); + expect(pullResult.code).toEqual(0); // Push back without changes (should be no-op) const pushResult = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); - assertEquals(pushResult.code, 0, `Push dry-run should succeed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Should report 0 changes (check both stdout and stderr) const output = (pushResult.stdout + pushResult.stderr).toLowerCase(); - assert( - output.includes("0 change") || output.includes("no change") || output.includes("nothing"), - `Should have no changes after pull without modifications. Output: ${output}`, - ); + expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); - }, -}); + }); -Deno.test({ - name: "Integration: Include/exclude filters work correctly", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Include/exclude filters work correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with restrictive filters - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: @@ -1055,16 +993,13 @@ excludes: skipVariables: true skipResources: true `, + "utf-8", ); // Run sync pull const result = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals( - result.code, - 0, - `Pull should succeed.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, - ); + expect(result.code).toEqual(0); // Verify only scripts in f/scripts/ were pulled (if any exist) const files = await readDirRecursive(tempDir); @@ -1073,26 +1008,22 @@ skipResources: true const hasVariables = Object.keys(files).some((f) => f.includes(".variable.")); const hasResources = Object.keys(files).some((f) => f.includes(".resource.")); - assert(!hasVariables, "Should not have pulled variables (skipVariables: true)"); - assert(!hasResources, "Should not have pulled resources (skipResources: true)"); + expect(!hasVariables).toBeTruthy(); + expect(!hasResources).toBeTruthy(); }); - }, -}); + }); -Deno.test({ - name: "Integration: Flow folder structure is created correctly", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Flow folder structure is created correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, + "utf-8", ); // Create a local flow with unique name @@ -1100,9 +1031,9 @@ excludes: [] const uniqueId = Date.now(); const flowName = `f/test/flow_${uniqueId}`; const flowFixture = createFlowFixture(flowName); - await ensureDir(`${tempDir}/f/test/flow_${uniqueId}${getFolderSuffix("flow")}`); + await mkdir(`${tempDir}/f/test/flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const file of Object.values(flowFixture)) { - await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content); + await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } // Push the flow (only push our test flow, not everything) @@ -1112,14 +1043,10 @@ excludes: [] tempDir, ); - assertEquals( - pushResult.code, - 0, - `Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`, - ); + expect(pushResult.code).toEqual(0); // Pull back and verify structure is preserved - const tempDir2 = await Deno.makeTempDir({ prefix: "wmill_flow_verify_" }); + const tempDir2 = await mkdtemp(join(tmpdir(), "wmill_flow_verify_")); try { // Use template literal properly for the includes pattern const wmillConfig = `defaultTs: bun @@ -1127,56 +1054,45 @@ includes: - "f/test/flow_${uniqueId}*/**" excludes: [] `; - await Deno.writeTextFile(`${tempDir2}/wmill.yaml`, wmillConfig); + await writeFile(`${tempDir2}/wmill.yaml`, wmillConfig, "utf-8"); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir2); - assertEquals( - pullResult.code, - 0, - `Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`, - ); + expect(pullResult.code).toEqual(0); // Verify flow folder structure const files = await readDirRecursive(tempDir2); const allFiles = Object.keys(files); const flowFiles = allFiles.filter((f) => f.includes(`flow_${uniqueId}`)); - assert(flowFiles.length > 0, `Should have pulled the flow. Files found: ${allFiles.join(", ")}`); - assert( - flowFiles.some((f) => f.includes(".flow/")), - "Flow should be in a .flow folder", - ); + expect(flowFiles.length > 0).toBeTruthy(); + expect(flowFiles.some((f) => f.includes(".flow/"))).toBeTruthy(); } finally { await cleanupTempDir(tempDir2); } }); - }, -}); + }); -Deno.test({ - name: "Integration: Raw app folder structure is handled correctly", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Raw app folder structure is handled correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, + "utf-8", ); // Create a local raw app with unique name // Path must have at least 2 segments after prefix (e.g., f/folder/name) const uniqueId = Date.now(); const rawAppFixture = createRawAppFixture(`f/test/raw_app_${uniqueId}`); - await ensureDir(`${tempDir}/f/test/raw_app_${uniqueId}${getFolderSuffix("raw_app")}`); + await mkdir(`${tempDir}/f/test/raw_app_${uniqueId}${getFolderSuffix("raw_app")}`, { recursive: true }); for (const file of Object.values(rawAppFixture)) { - await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content); + await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } // Push the raw app (only push our test raw app, not everything) @@ -1188,140 +1104,125 @@ excludes: [] // Note: This may fail if raw apps require specific validation // The test verifies the CLI handles the folder structure correctly if (pushResult.code === 0) { - assertStringIncludes( - pushResult.stdout + pushResult.stderr, - "", - "Push completed", - ); + expect(pushResult.stdout + pushResult.stderr).toContain(""); } }); - }, -}); + }); // ============================================================================= // nonDottedPaths Unit Tests // ============================================================================= -Deno.test("getFolderSuffixes returns correct suffixes for dotted paths (default)", () => { +test("getFolderSuffixes returns correct suffixes for dotted paths (default)", () => { setNonDottedPaths(false); const suffixes = getFolderSuffixes(); - assertEquals(suffixes.flow, ".flow"); - assertEquals(suffixes.app, ".app"); - assertEquals(suffixes.raw_app, ".raw_app"); + expect(suffixes.flow).toEqual(".flow"); + expect(suffixes.app).toEqual(".app"); + expect(suffixes.raw_app).toEqual(".raw_app"); }); -Deno.test("getFolderSuffixes returns correct suffixes for non-dotted paths", () => { +test("getFolderSuffixes returns correct suffixes for non-dotted paths", () => { setNonDottedPaths(true); const suffixes = getFolderSuffixes(); - assertEquals(suffixes.flow, "__flow"); - assertEquals(suffixes.app, "__app"); - assertEquals(suffixes.raw_app, "__raw_app"); + expect(suffixes.flow).toEqual("__flow"); + expect(suffixes.app).toEqual("__app"); + expect(suffixes.raw_app).toEqual("__raw_app"); setNonDottedPaths(false); // Reset }); -Deno.test("getFolderSuffix with nonDottedPaths returns dunder suffixes", () => { +test("getFolderSuffix with nonDottedPaths returns dunder suffixes", () => { setNonDottedPaths(true); - assertEquals(getFolderSuffix("flow"), "__flow"); - assertEquals(getFolderSuffix("app"), "__app"); - assertEquals(getFolderSuffix("raw_app"), "__raw_app"); + expect(getFolderSuffix("flow")).toEqual("__flow"); + expect(getFolderSuffix("app")).toEqual("__app"); + expect(getFolderSuffix("raw_app")).toEqual("__raw_app"); setNonDottedPaths(false); // Reset }); -Deno.test("buildFolderPath with nonDottedPaths creates correct paths", () => { +test("buildFolderPath with nonDottedPaths creates correct paths", () => { setNonDottedPaths(true); - assertEquals(buildFolderPath("my_flow", "flow"), "my_flow__flow"); - assertEquals(buildFolderPath("f/test/my_app", "app"), "f/test/my_app__app"); - assertEquals(buildFolderPath("u/admin/raw_app", "raw_app"), "u/admin/raw_app__raw_app"); + expect(buildFolderPath("my_flow", "flow")).toEqual("my_flow__flow"); + expect(buildFolderPath("f/test/my_app", "app")).toEqual("f/test/my_app__app"); + expect(buildFolderPath("u/admin/raw_app", "raw_app")).toEqual("u/admin/raw_app__raw_app"); setNonDottedPaths(false); // Reset }); -Deno.test("buildMetadataPath with nonDottedPaths creates correct paths", () => { +test("buildMetadataPath with nonDottedPaths creates correct paths", () => { setNonDottedPaths(true); - assertEquals( - buildMetadataPath("my_flow", "flow", "yaml"), - `my_flow__flow${SEP}flow.yaml` - ); - assertEquals( - buildMetadataPath(`f${SEP}test${SEP}my_app`, "app", "yaml"), - `f${SEP}test${SEP}my_app__app${SEP}app.yaml` - ); + // buildMetadataPath always uses forward slashes internally + expect(buildMetadataPath("my_flow", "flow", "yaml")).toEqual("my_flow__flow/flow.yaml"); + expect(buildMetadataPath("f/test/my_app", "app", "yaml")).toEqual("f/test/my_app__app/app.yaml"); setNonDottedPaths(false); // Reset }); -Deno.test("isFlowPath detects non-dotted paths when configured", () => { +test("isFlowPath detects non-dotted paths when configured", () => { // Default (dotted) paths setNonDottedPaths(false); - assert(isFlowPath(`f${SEP}test${SEP}my_flow.flow${SEP}flow.yaml`)); - assert(!isFlowPath(`f${SEP}test${SEP}my_flow__flow${SEP}flow.yaml`)); + expect(isFlowPath(`f${SEP}test${SEP}my_flow.flow${SEP}flow.yaml`)).toBeTruthy(); + expect(!isFlowPath(`f${SEP}test${SEP}my_flow__flow${SEP}flow.yaml`)).toBeTruthy(); // Non-dotted paths setNonDottedPaths(true); - assert(isFlowPath(`f${SEP}test${SEP}my_flow__flow${SEP}flow.yaml`)); - assert(!isFlowPath(`f${SEP}test${SEP}my_flow.flow${SEP}flow.yaml`)); + expect(isFlowPath(`f${SEP}test${SEP}my_flow__flow${SEP}flow.yaml`)).toBeTruthy(); + expect(!isFlowPath(`f${SEP}test${SEP}my_flow.flow${SEP}flow.yaml`)).toBeTruthy(); setNonDottedPaths(false); // Reset }); -Deno.test("isAppPath detects non-dotted paths when configured", () => { +test("isAppPath detects non-dotted paths when configured", () => { // Default (dotted) paths setNonDottedPaths(false); - assert(isAppPath(`f${SEP}test${SEP}my_app.app${SEP}app.yaml`)); - assert(!isAppPath(`f${SEP}test${SEP}my_app__app${SEP}app.yaml`)); + expect(isAppPath(`f${SEP}test${SEP}my_app.app${SEP}app.yaml`)).toBeTruthy(); + expect(!isAppPath(`f${SEP}test${SEP}my_app__app${SEP}app.yaml`)).toBeTruthy(); // Non-dotted paths setNonDottedPaths(true); - assert(isAppPath(`f${SEP}test${SEP}my_app__app${SEP}app.yaml`)); - assert(!isAppPath(`f${SEP}test${SEP}my_app.app${SEP}app.yaml`)); + expect(isAppPath(`f${SEP}test${SEP}my_app__app${SEP}app.yaml`)).toBeTruthy(); + expect(!isAppPath(`f${SEP}test${SEP}my_app.app${SEP}app.yaml`)).toBeTruthy(); setNonDottedPaths(false); // Reset }); -Deno.test("isRawAppPath detects non-dotted paths when configured", () => { +test("isRawAppPath detects non-dotted paths when configured", () => { // Default (dotted) paths setNonDottedPaths(false); - assert(isRawAppPath(`f${SEP}test${SEP}my_raw_app.raw_app${SEP}raw_app.yaml`)); - assert(!isRawAppPath(`f${SEP}test${SEP}my_raw_app__raw_app${SEP}raw_app.yaml`)); + expect(isRawAppPath(`f${SEP}test${SEP}my_raw_app.raw_app${SEP}raw_app.yaml`)).toBeTruthy(); + expect(!isRawAppPath(`f${SEP}test${SEP}my_raw_app__raw_app${SEP}raw_app.yaml`)).toBeTruthy(); // Non-dotted paths setNonDottedPaths(true); - assert(isRawAppPath(`f${SEP}test${SEP}my_raw_app__raw_app${SEP}raw_app.yaml`)); - assert(!isRawAppPath(`f${SEP}test${SEP}my_raw_app.raw_app${SEP}raw_app.yaml`)); + expect(isRawAppPath(`f${SEP}test${SEP}my_raw_app__raw_app${SEP}raw_app.yaml`)).toBeTruthy(); + expect(!isRawAppPath(`f${SEP}test${SEP}my_raw_app.raw_app${SEP}raw_app.yaml`)).toBeTruthy(); setNonDottedPaths(false); // Reset }); -Deno.test("extractResourceName works with non-dotted paths", () => { +test("extractResourceName works with non-dotted paths", () => { setNonDottedPaths(true); - assertEquals( - extractResourceName(`f${SEP}test${SEP}my_flow__flow${SEP}flow.yaml`, "flow"), - `f${SEP}test${SEP}my_flow` - ); - assertEquals( - extractResourceName(`f${SEP}test${SEP}my_app__app${SEP}app.yaml`, "app"), - `f${SEP}test${SEP}my_app` - ); + // extractResourceName normalizes separators to forward slashes + expect(extractResourceName(`f${SEP}test${SEP}my_flow__flow${SEP}flow.yaml`, "flow")).toEqual("f/test/my_flow"); + expect(extractResourceName(`f${SEP}test${SEP}my_app__app${SEP}app.yaml`, "app")).toEqual("f/test/my_app"); setNonDottedPaths(false); // Reset }); -Deno.test("hasFolderSuffix works with non-dotted paths", () => { +test("hasFolderSuffix works with non-dotted paths", () => { setNonDottedPaths(true); - assert(hasFolderSuffix("my_flow__flow", "flow")); - assert(!hasFolderSuffix("my_flow.flow", "flow")); + expect(hasFolderSuffix("my_flow__flow", "flow")).toBeTruthy(); + expect(!hasFolderSuffix("my_flow.flow", "flow")).toBeTruthy(); - assert(hasFolderSuffix("my_app__app", "app")); - assert(!hasFolderSuffix("my_app.app", "app")); + expect(hasFolderSuffix("my_app__app", "app")).toBeTruthy(); + expect(!hasFolderSuffix("my_app.app", "app")).toBeTruthy(); setNonDottedPaths(false); // Reset }); -Deno.test("setNonDottedPaths and getNonDottedPaths work correctly", () => { +test("setNonDottedPaths and getNonDottedPaths work correctly", () => { // Default should be false setNonDottedPaths(false); - assertEquals(getNonDottedPaths(), false); + expect(getNonDottedPaths()).toEqual(false); // Set to true setNonDottedPaths(true); - assertEquals(getNonDottedPaths(), true); + expect(getNonDottedPaths()).toEqual(true); // Set back to false setNonDottedPaths(false); - assertEquals(getNonDottedPaths(), false); + expect(getNonDottedPaths()).toEqual(false); }); // ============================================================================= @@ -1390,62 +1291,62 @@ policy: }; } -Deno.test("Flow fixture with nonDottedPaths creates __flow structure", () => { +test("Flow fixture with nonDottedPaths creates __flow structure", () => { setNonDottedPaths(true); const flow = createFlowFixtureWithCurrentConfig("test_flow"); - assertEquals(flow.metadata.path, "test_flow__flow/flow.yaml"); - assertEquals(flow.inlineScript.path, "test_flow__flow/a.ts"); - assertStringIncludes(flow.metadata.content, "summary:"); - assertStringIncludes(flow.metadata.content, "modules:"); + expect(flow.metadata.path).toEqual("test_flow__flow/flow.yaml"); + expect(flow.inlineScript.path).toEqual("test_flow__flow/a.ts"); + expect(flow.metadata.content).toContain("summary:"); + expect(flow.metadata.content).toContain("modules:"); setNonDottedPaths(false); // Reset }); -Deno.test("App fixture with nonDottedPaths creates __app structure", () => { +test("App fixture with nonDottedPaths creates __app structure", () => { setNonDottedPaths(true); const app = createAppFixtureWithCurrentConfig("test_app"); - assertEquals(app.metadata.path, "test_app__app/app.yaml"); - assertStringIncludes(app.metadata.content, "summary:"); - assertStringIncludes(app.metadata.content, "grid:"); + expect(app.metadata.path).toEqual("test_app__app/app.yaml"); + expect(app.metadata.content).toContain("summary:"); + expect(app.metadata.content).toContain("grid:"); setNonDottedPaths(false); // Reset }); -Deno.test("Local filesystem with nonDottedPaths creates correct folder structure", async () => { +test("Local filesystem with nonDottedPaths creates correct folder structure", async () => { setNonDottedPaths(true); const tempDir = await createTempDir(); try { // Create folder structure - await ensureDir(path.join(tempDir, "f/flows")); - await ensureDir(path.join(tempDir, "f/apps")); + await mkdir(path.join(tempDir, "f/flows"), { recursive: true }); + await mkdir(path.join(tempDir, "f/apps"), { recursive: true }); // Create flows with non-dotted paths const flowFixture = createFlowFixtureWithCurrentConfig("f/flows/test_flow"); - await ensureDir(path.join(tempDir, `f/flows/test_flow${getFolderSuffix("flow")}`)); + await mkdir(path.join(tempDir, `f/flows/test_flow${getFolderSuffix("flow")}`), { recursive: true }); for (const file of Object.values(flowFixture)) { - await Deno.writeTextFile(path.join(tempDir, file.path), file.content); + await writeFile(path.join(tempDir, file.path), file.content, "utf-8"); } // Create apps with non-dotted paths const appFixture = createAppFixtureWithCurrentConfig("f/apps/test_app"); - await ensureDir(path.join(tempDir, `f/apps/test_app${getFolderSuffix("app")}`)); + await mkdir(path.join(tempDir, `f/apps/test_app${getFolderSuffix("app")}`), { recursive: true }); for (const file of Object.values(appFixture)) { - await Deno.writeTextFile(path.join(tempDir, file.path), file.content); + await writeFile(path.join(tempDir, file.path), file.content, "utf-8"); } const files = await readDirRecursive(tempDir); // Check flows exist with __flow suffix - assert("f/flows/test_flow__flow/flow.yaml" in files, "Flow metadata should exist with __flow suffix"); - assert("f/flows/test_flow__flow/a.ts" in files, "Flow inline script should exist with __flow suffix"); + expect("f/flows/test_flow__flow/flow.yaml" in files).toBeTruthy(); + expect("f/flows/test_flow__flow/a.ts" in files).toBeTruthy(); // Check apps exist with __app suffix - assert("f/apps/test_app__app/app.yaml" in files, "App metadata should exist with __app suffix"); + expect("f/apps/test_app__app/app.yaml" in files).toBeTruthy(); // Verify old-style paths don't exist - assert(!("f/flows/test_flow.flow/flow.yaml" in files), "Old .flow suffix should not exist"); - assert(!("f/apps/test_app.app/app.yaml" in files), "Old .app suffix should not exist"); + expect(!("f/flows/test_flow.flow/flow.yaml" in files)).toBeTruthy(); + expect(!("f/apps/test_app.app/app.yaml" in files)).toBeTruthy(); } finally { await cleanupTempDir(tempDir); setNonDottedPaths(false); // Reset @@ -1456,14 +1357,10 @@ Deno.test("Local filesystem with nonDottedPaths creates correct folder structure // nonDottedPaths Integration Tests // ============================================================================= -Deno.test({ - name: "Integration: wmill.yaml with nonDottedPaths is read correctly", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: wmill.yaml with nonDottedPaths is read correctly", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths option - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true @@ -1471,6 +1368,7 @@ includes: - "f/**" excludes: [] `, + "utf-8", ); // Create a test script with non-dotted flow folder @@ -1478,9 +1376,9 @@ excludes: [] const uniqueId = Date.now(); const flowName = `f/test/nondot_flow_${uniqueId}`; const flowFixture = createFlowFixtureWithCurrentConfig(flowName); - await ensureDir(`${tempDir}/f/test/nondot_flow_${uniqueId}${getFolderSuffix("flow")}`); + await mkdir(`${tempDir}/f/test/nondot_flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const file of Object.values(flowFixture)) { - await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content); + await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset @@ -1490,23 +1388,14 @@ excludes: [] tempDir, ); - assertEquals( - dryRunResult.code, - 0, - `Dry run should succeed with nonDottedPaths config.\nstdout: ${dryRunResult.stdout}\nstderr: ${dryRunResult.stderr}`, - ); + expect(dryRunResult.code).toEqual(0); }); - }, -}); + }); -Deno.test({ - name: "Integration: Pull then Push with nonDottedPaths is idempotent", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Pull then Push with nonDottedPaths is idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true @@ -1514,15 +1403,12 @@ includes: - "**" excludes: [] `, + "utf-8", ); // Pull from remote with nonDottedPaths enabled const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals( - pullResult.code, - 0, - `Pull should succeed with nonDottedPaths.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`, - ); + expect(pullResult.code).toEqual(0); // Verify that pulled files use __flow/__app/__raw_app suffixes const filesAfterPull = await readDirRecursive(tempDir); @@ -1536,40 +1422,26 @@ excludes: [] // Only check if there are actually flows/apps in the workspace // If there are flows, they should use __flow not .flow if (flowFiles.length > 0 || dottedFlowFiles.length > 0) { - assert( - dottedFlowFiles.length === 0, - `Flows should use __flow suffix with nonDottedPaths, found .flow files: ${dottedFlowFiles.join(", ")}`, - ); + expect(dottedFlowFiles.length === 0).toBeTruthy(); } if (appFiles.length > 0 || dottedAppFiles.length > 0) { - assert( - dottedAppFiles.length === 0, - `Apps should use __app suffix with nonDottedPaths, found .app files: ${dottedAppFiles.join(", ")}`, - ); + expect(dottedAppFiles.length === 0).toBeTruthy(); } // Push back without changes (should be no-op / idempotent) const pushResult = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); - assertEquals(pushResult.code, 0, `Push dry-run should succeed: ${pushResult.stderr}`); + expect(pushResult.code).toEqual(0); // Should report 0 changes (check both stdout and stderr) const output = (pushResult.stdout + pushResult.stderr).toLowerCase(); - assert( - output.includes("0 change") || output.includes("no change") || output.includes("nothing"), - `Should have no changes after pull with nonDottedPaths without modifications. Output: ${output}`, - ); + expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); - }, -}); + }); -Deno.test({ - name: "Integration: Push flow with nonDottedPaths creates __flow structure on server", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Push flow with nonDottedPaths creates __flow structure on server", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true @@ -1577,6 +1449,7 @@ includes: - "**" excludes: [] `, + "utf-8", ); // Create a local flow with __flow suffix @@ -1584,9 +1457,9 @@ excludes: [] const uniqueId = Date.now(); const flowName = `f/test/nondot_idem_flow_${uniqueId}`; const flowFixture = createFlowFixtureWithCurrentConfig(flowName); - await ensureDir(`${tempDir}/f/test/nondot_idem_flow_${uniqueId}${getFolderSuffix("flow")}`); + await mkdir(`${tempDir}/f/test/nondot_idem_flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const file of Object.values(flowFixture)) { - await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content); + await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset global state @@ -1596,11 +1469,7 @@ excludes: [] tempDir, ); - assertEquals( - pushResult.code, - 0, - `Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`, - ); + expect(pushResult.code).toEqual(0); // Pull back to same directory to verify round-trip (idempotency) const pullResult = await backend.runCLICommand( @@ -1608,26 +1477,16 @@ excludes: [] tempDir, ); - assertEquals( - pullResult.code, - 0, - `Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`, - ); + expect(pullResult.code).toEqual(0); // Verify flow still has __flow suffix after round-trip const filesAfterPull = await readDirRecursive(tempDir); const allFiles = Object.keys(filesAfterPull); const flowFiles = allFiles.filter((f) => f.includes(`nondot_idem_flow_${uniqueId}`)); - assert(flowFiles.length > 0, `Should have the flow files after pull. Files found: ${allFiles.join(", ")}`); - assert( - flowFiles.some((f) => f.includes("__flow/")), - `Flow should be in a __flow folder with nonDottedPaths. Found: ${flowFiles.join(", ")}`, - ); - assert( - !flowFiles.some((f) => f.includes(".flow/")), - `Flow should NOT use .flow suffix with nonDottedPaths. Found: ${flowFiles.join(", ")}`, - ); + expect(flowFiles.length > 0).toBeTruthy(); + expect(flowFiles.some((f) => f.includes("__flow/"))).toBeTruthy(); + expect(!flowFiles.some((f) => f.includes(".flow/"))).toBeTruthy(); // Push again (should be idempotent - no changes) const push2 = await backend.runCLICommand( @@ -1635,25 +1494,17 @@ excludes: [] tempDir, ); - assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`); + expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); - assert( - output.includes("0 change") || output.includes("no change") || output.includes("nothing"), - `Should have no changes after push-pull cycle for flow. Output: ${output}`, - ); + expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); - }, -}); + }); -Deno.test({ - name: "Integration: Multiple pull/push cycles with nonDottedPaths remain idempotent", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Multiple pull/push cycles with nonDottedPaths remain idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true @@ -1661,56 +1512,46 @@ includes: - "**" excludes: [] `, + "utf-8", ); // First pull const pull1 = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pull1.code, 0, `First pull should succeed: ${pull1.stderr}`); + expect(pull1.code).toEqual(0); // First push (should be no-op) const push1 = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); - assertEquals(push1.code, 0, `First push dry-run should succeed: ${push1.stderr}`); + expect(push1.code).toEqual(0); // Second pull (should have no changes) const pull2 = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pull2.code, 0, `Second pull should succeed: ${pull2.stderr}`); + expect(pull2.code).toEqual(0); // Second push (should still be no-op) const push2 = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); - assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`); + expect(push2.code).toEqual(0); // Verify no changes after multiple cycles const output = (push2.stdout + push2.stderr).toLowerCase(); - assert( - output.includes("0 change") || output.includes("no change") || output.includes("nothing"), - `Should have no changes after multiple pull/push cycles with nonDottedPaths. Output: ${output}`, - ); + expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); // Third pull to verify consistency const pull3 = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); - assertEquals(pull3.code, 0, `Third pull should succeed: ${pull3.stderr}`); + expect(pull3.code).toEqual(0); // Final push check const push3 = await backend.runCLICommand(["sync", "push", "--dry-run"], tempDir); - assertEquals(push3.code, 0, `Final push dry-run should succeed: ${push3.stderr}`); + expect(push3.code).toEqual(0); const finalOutput = (push3.stdout + push3.stderr).toLowerCase(); - assert( - finalOutput.includes("0 change") || finalOutput.includes("no change") || finalOutput.includes("nothing"), - `Should still have no changes after 3 cycles. Output: ${finalOutput}`, - ); + expect(finalOutput.includes("0 change") || finalOutput.includes("no change") || finalOutput.includes("nothing")).toBeTruthy(); }); - }, -}); + }); -Deno.test({ - name: "Integration: App with nonDottedPaths creates __app structure and is idempotent", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: App with nonDottedPaths creates __app structure and is idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true @@ -1718,6 +1559,7 @@ includes: - "**" excludes: [] `, + "utf-8", ); // Create a local app with __app suffix @@ -1725,9 +1567,9 @@ excludes: [] const uniqueId = Date.now(); const appName = `f/test/nondot_app_${uniqueId}`; const appFixture = createAppFixtureWithCurrentConfig(appName); - await ensureDir(`${tempDir}/f/test/nondot_app_${uniqueId}${getFolderSuffix("app")}`); + await mkdir(`${tempDir}/f/test/nondot_app_${uniqueId}${getFolderSuffix("app")}`, { recursive: true }); for (const file of Object.values(appFixture)) { - await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content); + await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset global state @@ -1737,11 +1579,7 @@ excludes: [] tempDir, ); - assertEquals( - pushResult.code, - 0, - `Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`, - ); + expect(pushResult.code).toEqual(0); // Pull back to same directory const pullResult = await backend.runCLICommand( @@ -1749,21 +1587,14 @@ excludes: [] tempDir, ); - assertEquals( - pullResult.code, - 0, - `Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`, - ); + expect(pullResult.code).toEqual(0); // Verify app structure uses __app const files = await readDirRecursive(tempDir); const appFiles = Object.keys(files).filter((f) => f.includes(`nondot_app_${uniqueId}`)); - assert(appFiles.length > 0, `Should have the app files. Found: ${Object.keys(files).join(", ")}`); - assert( - appFiles.some((f) => f.includes("__app/")), - `App should be in a __app folder with nonDottedPaths. Found: ${appFiles.join(", ")}`, - ); + expect(appFiles.length > 0).toBeTruthy(); + expect(appFiles.some((f) => f.includes("__app/"))).toBeTruthy(); // Push again (should be idempotent) const push2 = await backend.runCLICommand( @@ -1771,16 +1602,12 @@ excludes: [] tempDir, ); - assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`); + expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); - assert( - output.includes("0 change") || output.includes("no change") || output.includes("nothing"), - `Should have no changes after push-pull cycle for app. Output: ${output}`, - ); + expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); - }, -}); + }); /** * Creates a mock raw_app file structure using the current global nonDottedPaths setting @@ -1814,14 +1641,10 @@ runnables: }; } -Deno.test({ - name: "Integration: Raw app with nonDottedPaths creates __raw_app structure", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Raw app with nonDottedPaths creates __raw_app structure", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true @@ -1829,6 +1652,7 @@ includes: - "**" excludes: [] `, + "utf-8", ); // Create a local raw app with __raw_app suffix @@ -1836,9 +1660,9 @@ excludes: [] const uniqueId = Date.now(); const rawAppName = `f/test/nondot_rawapp_${uniqueId}`; const rawAppFixture = createRawAppFixtureWithCurrentConfig(rawAppName); - await ensureDir(`${tempDir}/f/test/nondot_rawapp_${uniqueId}${getFolderSuffix("raw_app")}`); + await mkdir(`${tempDir}/f/test/nondot_rawapp_${uniqueId}${getFolderSuffix("raw_app")}`, { recursive: true }); for (const file of Object.values(rawAppFixture)) { - await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content); + await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset global state @@ -1846,15 +1670,9 @@ excludes: [] const files = await readDirRecursive(tempDir); const rawAppFiles = Object.keys(files).filter((f) => f.includes(`nondot_rawapp_${uniqueId}`)); - assert(rawAppFiles.length > 0, `Should have created raw app files. Found: ${Object.keys(files).join(", ")}`); - assert( - rawAppFiles.some((f) => f.includes("__raw_app/")), - `Raw app should be in a __raw_app folder with nonDottedPaths. Found: ${rawAppFiles.join(", ")}`, - ); - assert( - !rawAppFiles.some((f) => f.includes(".raw_app/")), - `Raw app should NOT use .raw_app suffix with nonDottedPaths. Found: ${rawAppFiles.join(", ")}`, - ); + expect(rawAppFiles.length > 0).toBeTruthy(); + expect(rawAppFiles.some((f) => f.includes("__raw_app/"))).toBeTruthy(); + expect(!rawAppFiles.some((f) => f.includes(".raw_app/"))).toBeTruthy(); // Push the raw app (may fail if raw apps require specific validation) const pushResult = await backend.runCLICommand( @@ -1871,20 +1689,15 @@ excludes: [] tempDir, ); - assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`); + expect(push2.code).toEqual(0); } }); - }, -}); + }); -Deno.test({ - name: "Integration: Mixed scripts and flows with nonDottedPaths are idempotent", - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test("Integration: Mixed scripts and flows with nonDottedPaths are idempotent", async () => { await withTestBackend(async (backend, tempDir) => { // Create wmill.yaml with nonDottedPaths enabled - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun nonDottedPaths: true @@ -1892,23 +1705,24 @@ includes: - "**" excludes: [] `, + "utf-8", ); const uniqueId = Date.now(); - await ensureDir(`${tempDir}/f/test`); + await mkdir(`${tempDir}/f/test`, { recursive: true }); // Create a script (scripts don't use folder suffixes, so they're unaffected) const script = createScriptFixture(`f/test/mixed_script_${uniqueId}`, "deno"); - await Deno.writeTextFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content); - await Deno.writeTextFile(`${tempDir}/${script.metadataFile.path}`, script.metadataFile.content); + await writeFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content, "utf-8"); + await writeFile(`${tempDir}/${script.metadataFile.path}`, script.metadataFile.content, "utf-8"); // Create a flow with __flow suffix setNonDottedPaths(true); const flowName = `f/test/mixed_flow_${uniqueId}`; const flowFixture = createFlowFixtureWithCurrentConfig(flowName); - await ensureDir(`${tempDir}/f/test/mixed_flow_${uniqueId}${getFolderSuffix("flow")}`); + await mkdir(`${tempDir}/f/test/mixed_flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const file of Object.values(flowFixture)) { - await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content); + await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } setNonDottedPaths(false); // Reset global state @@ -1918,11 +1732,7 @@ excludes: [] tempDir, ); - assertEquals( - pushResult.code, - 0, - `Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`, - ); + expect(pushResult.code).toEqual(0); // Pull back const pullResult = await backend.runCLICommand( @@ -1930,11 +1740,7 @@ excludes: [] tempDir, ); - assertEquals( - pullResult.code, - 0, - `Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`, - ); + expect(pullResult.code).toEqual(0); // Verify idempotency const push2 = await backend.runCLICommand( @@ -1942,130 +1748,99 @@ excludes: [] tempDir, ); - assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`); + expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); - assert( - output.includes("0 change") || output.includes("no change") || output.includes("nothing"), - `Should have no changes after push-pull cycle for mixed content. Output: ${output}`, - ); + expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); }); - }, -}); + }); // ============================================================================= // ws_error_handler_muted Persistence Tests // ============================================================================= -Deno.test({ - name: "Integration: Script ws_error_handler_muted is persisted through push/pull", - ignore: shouldSkipOnCI(), // Requires EE features - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test.skipIf(shouldSkipOnCI())("Integration: Script ws_error_handler_muted is persisted through push/pull", async () => { await withTestBackend(async (backend, tempDir) => { - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, + "utf-8", ); const uniqueId = Date.now(); - await ensureDir(`${tempDir}/f/test`); + await mkdir(`${tempDir}/f/test`, { recursive: true }); // Create a script with ws_error_handler_muted: true const scriptName = `f/test/muted_script_${uniqueId}`; const script = createScriptFixture(scriptName, "deno"); - await Deno.writeTextFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content); + await writeFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content, "utf-8"); // Add ws_error_handler_muted to the metadata const metadataWithMuted = script.metadataFile.content + `ws_error_handler_muted: true\n`; - await Deno.writeTextFile(`${tempDir}/${script.metadataFile.path}`, metadataWithMuted); + await writeFile(`${tempDir}/${script.metadataFile.path}`, metadataWithMuted, "utf-8"); // Push const pushResult = await backend.runCLICommand( ["sync", "push", "--yes", "--includes", `f/test/muted_script_${uniqueId}**`], tempDir, ); - assertEquals( - pushResult.code, - 0, - `Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`, - ); + expect(pushResult.code).toEqual(0); // Verify via API that ws_error_handler_muted was persisted const apiResp = await backend.apiRequest!( `/api/w/${backend.workspace}/scripts/get/p/${scriptName}`, ); - assertEquals(apiResp.status, 200, "API should return the script"); + expect(apiResp.status).toEqual(200); const scriptData = await apiResp.json(); - assertEquals( - scriptData.ws_error_handler_muted, - true, - "API should return ws_error_handler_muted: true for the pushed script", - ); + expect(scriptData.ws_error_handler_muted).toEqual(true); // Pull into a fresh directory and verify the field round-trips - const pullDir = await Deno.makeTempDir({ prefix: "wmill_muted_script_pull_" }); + const pullDir = await mkdtemp(join(tmpdir(), "wmill_muted_script_pull_")); try { - await Deno.writeTextFile( + await writeFile( `${pullDir}/wmill.yaml`, `defaultTs: bun includes: - "f/test/muted_script_${uniqueId}**" excludes: [] `, + "utf-8", ); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir); - assertEquals( - pullResult.code, - 0, - `Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`, - ); + expect(pullResult.code).toEqual(0); // Verify ws_error_handler_muted is in the pulled metadata - const pulledMetadata = await Deno.readTextFile(`${pullDir}/${script.metadataFile.path}`); - assertStringIncludes( - pulledMetadata, - "ws_error_handler_muted: true", - "Pulled script metadata should contain ws_error_handler_muted: true", - ); + const pulledMetadata = await readFile(`${pullDir}/${script.metadataFile.path}`, "utf-8"); + expect(pulledMetadata).toContain("ws_error_handler_muted: true"); // Verify push from pulled dir is idempotent (no changes) const push2 = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/muted_script_${uniqueId}**`], pullDir, ); - assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`); + expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); - assert( - output.includes("0 change") || output.includes("no change") || output.includes("nothing"), - `Should have no changes after push-pull cycle for script with ws_error_handler_muted. Output: ${output}`, - ); + expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); } finally { - await Deno.remove(pullDir, { recursive: true }).catch(() => {}); + await rm(pullDir, { recursive: true }).catch(() => {}); } }); - }, -}); + }); -Deno.test({ - name: "Integration: Flow ws_error_handler_muted is persisted through push/pull", - ignore: shouldSkipOnCI(), // Requires EE features - sanitizeResources: false, - sanitizeOps: false, - async fn() { +test.skipIf(shouldSkipOnCI())("Integration: Flow ws_error_handler_muted is persisted through push/pull", async () => { await withTestBackend(async (backend, tempDir) => { - await Deno.writeTextFile( + await writeFile( `${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" excludes: [] `, + "utf-8", ); const uniqueId = Date.now(); @@ -2073,14 +1848,14 @@ excludes: [] const flowFixture = createFlowFixture(flowName); // Create flow directory and files - await ensureDir(`${tempDir}/f/test/muted_flow_${uniqueId}${getFolderSuffix("flow")}`); + await mkdir(`${tempDir}/f/test/muted_flow_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); for (const [key, file] of Object.entries(flowFixture)) { if (key === "metadata") { // Add ws_error_handler_muted to flow metadata const contentWithMuted = file.content + `ws_error_handler_muted: true\n`; - await Deno.writeTextFile(`${tempDir}/${file.path}`, contentWithMuted); + await writeFile(`${tempDir}/${file.path}`, contentWithMuted, "utf-8"); } else { - await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content); + await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); } } @@ -2089,75 +1864,467 @@ excludes: [] ["sync", "push", "--yes", "--includes", `f/test/muted_flow_${uniqueId}*/**`], tempDir, ); - assertEquals( - pushResult.code, - 0, - `Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`, - ); + expect(pushResult.code).toEqual(0); // Verify via API that ws_error_handler_muted was persisted const apiResp = await backend.apiRequest!( `/api/w/${backend.workspace}/flows/get/${flowName}`, ); - assertEquals(apiResp.status, 200, "API should return the flow"); + expect(apiResp.status).toEqual(200); const flowData = await apiResp.json(); - assertEquals( - flowData.ws_error_handler_muted, - true, - "API should return ws_error_handler_muted: true for the pushed flow", - ); + expect(flowData.ws_error_handler_muted).toEqual(true); // Pull into a fresh directory and verify the field round-trips - const pullDir = await Deno.makeTempDir({ prefix: "wmill_muted_flow_pull_" }); + const pullDir = await mkdtemp(join(tmpdir(), "wmill_muted_flow_pull_")); try { - await Deno.writeTextFile( + await writeFile( `${pullDir}/wmill.yaml`, `defaultTs: bun includes: - "f/test/muted_flow_${uniqueId}*/**" excludes: [] `, + "utf-8", ); const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir); - assertEquals( - pullResult.code, - 0, - `Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`, - ); + expect(pullResult.code).toEqual(0); // Verify ws_error_handler_muted is in the pulled flow.yaml const flowYamlPath = `${pullDir}/${flowFixture.metadata.path}`; - const pulledFlowYaml = await Deno.readTextFile(flowYamlPath); - assertStringIncludes( - pulledFlowYaml, - "ws_error_handler_muted: true", - "Pulled flow.yaml should contain ws_error_handler_muted: true", - ); + const pulledFlowYaml = await readFile(flowYamlPath, "utf-8"); + expect(pulledFlowYaml).toContain("ws_error_handler_muted: true"); // Parse the YAML to confirm it's a proper boolean value // deno-lint-ignore no-explicit-any const parsed = await yamlParseFile(flowYamlPath) as any; - assertEquals( - parsed.ws_error_handler_muted, - true, - "ws_error_handler_muted should be boolean true in parsed flow YAML", - ); + expect(parsed.ws_error_handler_muted).toEqual(true); // Verify push from pulled dir is idempotent (no changes) const push2 = await backend.runCLICommand( ["sync", "push", "--dry-run", "--includes", `f/test/muted_flow_${uniqueId}*/**`], pullDir, ); - assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`); + expect(push2.code).toEqual(0); const output = (push2.stdout + push2.stderr).toLowerCase(); - assert( - output.includes("0 change") || output.includes("no change") || output.includes("nothing"), - `Should have no changes after push-pull cycle for flow with ws_error_handler_muted. Output: ${output}`, - ); + expect(output.includes("0 change") || output.includes("no change") || output.includes("nothing")).toBeTruthy(); } finally { - await Deno.remove(pullDir, { recursive: true }).catch(() => {}); + await rm(pullDir, { recursive: true }).catch(() => {}); } }); - }, + }); + +// ============================================================================= +// Sync tests for groups, settings, resource types, schedules, and HTTP triggers +// ============================================================================= + +import type { TestBackend } from "./test_backend.ts"; + +/** Create a script on the remote via API */ +async function createRemoteScript( + backend: TestBackend, + scriptPath: string, + content: string = 'export async function main() { return "hello"; }' +): Promise { + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content, + language: "bun", + summary: "Test script", + description: "Created by integration test", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + }), + } + ); + expect(resp.status).toBeLessThan(300); + await resp.text(); +} + +/** Write a standard wmill.yaml with the given extra flags */ +async function writeWmillYaml( + tempDir: string, + extraFlags: string = "" +): Promise { + await writeFile( + `${tempDir}/wmill.yaml`, + `defaultTs: bun +includes: + - "**" +excludes: [] +${extraFlags}`, + "utf-8" + ); +} + +/** Recursively list all files relative to baseDir, returning forward-slash paths */ +async function listFilesRecursive( + dir: string, + baseDir: string = dir +): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relativePath = fullPath + .substring(baseDir.length + 1) + .replaceAll("\\", "/"); + if (entry.isDirectory()) { + files.push(...(await listFilesRecursive(fullPath, baseDir))); + } else { + files.push(relativePath); + } + } + return files; +} + +describe("group sync", () => { + test("Integration: Group pull/push round-trip", async () => { + await withTestBackend(async (backend, tempDir) => { + await writeWmillYaml(tempDir, "includeGroups: true"); + + // Pull with --include-groups + const pullResult = await backend.runCLICommand( + ["sync", "pull", "--yes", "--include-groups"], + tempDir + ); + expect(pullResult.code).toEqual(0); + + // Verify group file was created (seedTestData creates test_group) + const files = await listFilesRecursive(tempDir); + const groupFiles = files.filter((f) => f.endsWith(".group.yaml")); + expect(groupFiles.length).toBeGreaterThan(0); + + const testGroupFile = groupFiles.find((f) => f.includes("test_group")); + expect(testGroupFile).toBeDefined(); + + // Read the group file and modify + const groupContent = await readFile(`${tempDir}/${testGroupFile!}`, "utf-8"); + expect(groupContent).toContain("summary"); + + const modifiedContent = groupContent.replace( + /summary:.*/, + 'summary: "Modified group summary from test"' + ); + await writeFile(`${tempDir}/${testGroupFile!}`, modifiedContent, "utf-8"); + + // Push the modification + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--include-groups"], + tempDir + ); + expect(pushResult.code).toEqual(0); + + // Verify via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/groups/get/test_group` + ); + expect(apiResp.status).toEqual(200); + const groupData = await apiResp.json(); + expect(groupData.summary).toEqual("Modified group summary from test"); + }); + }); +}); + +describe("settings sync", () => { + test("Integration: Settings pull/push round-trip", async () => { + await withTestBackend(async (backend, tempDir) => { + await writeWmillYaml(tempDir, "includeSettings: true"); + + // Pull with --include-settings + const pullResult = await backend.runCLICommand( + ["sync", "pull", "--yes", "--include-settings"], + tempDir + ); + expect(pullResult.code).toEqual(0); + + // Verify settings.yaml exists + const files = await listFilesRecursive(tempDir); + expect(files).toContain("settings.yaml"); + + // Read and modify a safe setting (webhook URL) + const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); + + let modifiedSettings: string; + if (settingsContent.includes("webhook:")) { + modifiedSettings = settingsContent.replace( + /webhook:.*/, + 'webhook: "https://test-webhook.example.com/hook"' + ); + } else { + modifiedSettings = + settingsContent + '\nwebhook: "https://test-webhook.example.com/hook"\n'; + } + await writeFile(`${tempDir}/settings.yaml`, modifiedSettings, "utf-8"); + + // Push the modification + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--include-settings"], + tempDir + ); + expect(pushResult.code).toEqual(0); + + // Verify via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/get_settings` + ); + expect(apiResp.status).toEqual(200); + const settingsData = await apiResp.json(); + expect(settingsData.webhook).toEqual("https://test-webhook.example.com/hook"); + }); + }); +}); + +describe("resource type sync", () => { + test("Integration: Resource type pull/push round-trip", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const rtName = `test_sync_rt_${uniqueId}`; + + // Create a resource type via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/resources/type/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: rtName, + schema: { + type: "object", + properties: { + host: { type: "string", description: "Hostname" }, + port: { type: "integer", description: "Port number" }, + }, + }, + description: "Test resource type for sync", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Resource types are included by default (not skipped) + await writeWmillYaml(tempDir); + + // Pull + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + // Verify resource type file exists + const files = await listFilesRecursive(tempDir); + const rtFile = files.find((f) => f.includes(`${rtName}.resource-type.yaml`)); + expect(rtFile).toBeDefined(); + + // Read and modify the description + const rtContent = await readFile(`${tempDir}/${rtFile!}`, "utf-8"); + expect(rtContent).toContain("host"); + + const modifiedContent = rtContent.replace( + "Test resource type for sync", + "Updated resource type description" + ); + await writeFile(`${tempDir}/${rtFile!}`, modifiedContent, "utf-8"); + + // Push + const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); + expect(pushResult.code).toEqual(0); + + // Verify via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/resources/type/get/${rtName}` + ); + expect(apiResp.status).toEqual(200); + const rtData = await apiResp.json(); + expect(rtData.description).toEqual("Updated resource type description"); + }); + }); +}); + +describe("schedule sync", () => { + test("Integration: Schedule pull/push round-trip", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/sched_sync_target_${uniqueId}`; + const schedulePath = `f/test/sched_sync_${uniqueId}`; + + // Create target script via API + await createRemoteScript(backend, scriptPath); + + // Create schedule via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/schedules/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: schedulePath, + schedule: "0 0 */6 * * *", + script_path: scriptPath, + is_flow: false, + args: {}, + enabled: false, + timezone: "UTC", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + await writeWmillYaml(tempDir, "includeSchedules: true"); + + // Pull with --include-schedules + const pullResult = await backend.runCLICommand( + ["sync", "pull", "--yes", "--include-schedules"], + tempDir + ); + expect(pullResult.code).toEqual(0); + + // Verify schedule file exists + const files = await listFilesRecursive(tempDir); + const scheduleFile = files.find( + (f) => f.includes(`sched_sync_${uniqueId}`) && f.endsWith(".schedule.yaml") + ); + expect(scheduleFile).toBeDefined(); + + // Read and verify content + const schedContent = await readFile(`${tempDir}/${scheduleFile!}`, "utf-8"); + expect(schedContent).toContain("0 0 */6 * * *"); + + // Modify the cron expression + const modifiedContent = schedContent.replace("0 0 */6 * * *", "0 0 */12 * * *"); + await writeFile(`${tempDir}/${scheduleFile!}`, modifiedContent, "utf-8"); + + // Push + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--include-schedules"], + tempDir + ); + expect(pushResult.code).toEqual(0); + + // Verify via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/schedules/get/${schedulePath}` + ); + expect(apiResp.status).toEqual(200); + const schedData = await apiResp.json(); + expect(schedData.schedule).toEqual("0 0 */12 * * *"); + }); + }); + + test("Integration: Schedule push-only creates from local file", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/sched_pushonly_target_${uniqueId}`; + const schedulePath = `f/test/sched_pushonly_${uniqueId}`; + + // Create target script via API + await createRemoteScript(backend, scriptPath); + + await writeWmillYaml(tempDir, "includeSchedules: true"); + + // Create schedule YAML locally + await mkdir(`${tempDir}/f/test`, { recursive: true }); + await writeFile( + `${tempDir}/${schedulePath}.schedule.yaml`, + `path: "${schedulePath}" +schedule: "0 30 2 * * 1" +script_path: "${scriptPath}" +is_flow: false +args: {} +enabled: false +timezone: "UTC" +`, + "utf-8" + ); + + // Push + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--include-schedules", "--includes", `f/test/sched_pushonly_${uniqueId}**`], + tempDir + ); + expect(pushResult.code).toEqual(0); + + // Verify schedule was created via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/schedules/get/${schedulePath}` + ); + expect(apiResp.status).toEqual(200); + const schedData = await apiResp.json(); + expect(schedData.schedule).toEqual("0 30 2 * * 1"); + expect(schedData.script_path).toEqual(scriptPath); + }); + }); +}); + +describe("http trigger sync", () => { + test.skipIf(shouldSkipOnCI())("Integration: HTTP trigger pull/push is idempotent", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/http_trig_target_${uniqueId}`; + + // Create target script via API + await createRemoteScript(backend, scriptPath); + + // Create HTTP trigger via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/http_triggers/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/http_trig_${uniqueId}`, + script_path: scriptPath, + route_path: `/test/hook_${uniqueId}`, + is_flow: false, + http_method: "post", + is_async: false, + requires_auth: false, + }), + } + ); + // If the feature is not enabled, the create will fail - skip gracefully + if (createResp.status >= 400) { + console.log("HTTP trigger creation failed (feature may not be enabled), skipping"); + return; + } + await createResp.text(); + + await writeWmillYaml(tempDir, "includeTriggers: true"); + + // Pull with --include-triggers + const pullResult = await backend.runCLICommand( + ["sync", "pull", "--yes", "--include-triggers"], + tempDir + ); + expect(pullResult.code).toEqual(0); + + // Verify http_trigger file exists + const files = await listFilesRecursive(tempDir); + const triggerFile = files.find( + (f) => f.includes(`http_trig_${uniqueId}`) && f.endsWith(".http_trigger.yaml") + ); + expect(triggerFile).toBeDefined(); + + // Push back (verify idempotent) + const pushResult = await backend.runCLICommand( + ["sync", "push", "--dry-run", "--include-triggers"], + tempDir + ); + expect(pushResult.code).toEqual(0); + + const output = (pushResult.stdout + pushResult.stderr).toLowerCase(); + expect( + output.includes("0 change") || output.includes("no change") || output.includes("nothing") + ).toBeTruthy(); + }); + }); }); diff --git a/cli/test/tar_creation.test.ts b/cli/test/tar_creation.test.ts new file mode 100644 index 0000000000..accbbb3829 --- /dev/null +++ b/cli/test/tar_creation.test.ts @@ -0,0 +1,140 @@ +/** + * Unit tests for the tar creation utility. + * These tests require no backend — they test standalone tar logic. + */ + +import { expect, test, describe } from "bun:test"; +import { createTarBlob, type TarEntry } from "../src/utils/tar.ts"; +import { extract, type Headers } from "tar-stream"; +import { Readable } from "node:stream"; + +/** Extract all entries from a tarball Blob into a map of name -> content string */ +async function extractTar( + blob: Blob +): Promise> { + const result = new Map(); + const ex = extract(); + const buffer = Buffer.from(await blob.arrayBuffer()); + + return new Promise((resolve, reject) => { + ex.on("entry", (header, stream, next) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => { + result.set(header.name, { + content: Buffer.concat(chunks).toString("utf-8"), + header, + }); + next(); + }); + stream.on("error", reject); + stream.resume(); + }); + ex.on("finish", () => resolve(result)); + ex.on("error", reject); + + Readable.from(buffer).pipe(ex); + }); +} + +describe("createTarBlob", () => { + test("single file tarball", async () => { + const entries: TarEntry[] = [ + { name: "main.js", content: 'console.log("hello");' }, + ]; + + const blob = await createTarBlob(entries); + const extracted = await extractTar(blob); + + expect(extracted.size).toBe(1); + expect(extracted.has("main.js")).toBe(true); + expect(extracted.get("main.js")!.content).toBe('console.log("hello");'); + }); + + test("multiple output files", async () => { + const entries: TarEntry[] = [ + { name: "main.js", content: 'import "./chunk-abc.js";' }, + { name: "chunk-abc.js", content: "export const x = 42;" }, + { name: "chunk-def.js", content: "export const y = 99;" }, + ]; + + const blob = await createTarBlob(entries); + const extracted = await extractTar(blob); + + expect(extracted.size).toBe(3); + expect(extracted.get("main.js")!.content).toBe( + 'import "./chunk-abc.js";' + ); + expect(extracted.get("chunk-abc.js")!.content).toBe( + "export const x = 42;" + ); + expect(extracted.get("chunk-def.js")!.content).toBe( + "export const y = 99;" + ); + }); + + test("single file with assets", async () => { + const entries: TarEntry[] = [ + { name: "main.js", content: "const data = require('./data.json');" }, + { name: "data.json", content: '{"key":"value"}' }, + ]; + + const blob = await createTarBlob(entries); + const extracted = await extractTar(blob); + + expect(extracted.size).toBe(2); + expect(extracted.has("main.js")).toBe(true); + expect(extracted.has("data.json")).toBe(true); + expect(extracted.get("data.json")!.content).toBe('{"key":"value"}'); + }); + + test("produces a valid Blob", async () => { + const entries: TarEntry[] = [ + { name: "main.js", content: "module.exports = {};" }, + ]; + + const blob = await createTarBlob(entries); + + expect(blob).toBeInstanceOf(Blob); + expect(blob.size).toBeGreaterThan(0); + // Tar blocks are 512-byte aligned + expect(blob.size % 512).toBe(0); + }); + + test("file naming — entries have exact names given", async () => { + const entries: TarEntry[] = [ + { name: "main.js", content: "entry point" }, + { name: "lib/utils.js", content: "utils" }, + ]; + + const blob = await createTarBlob(entries); + const extracted = await extractTar(blob); + + // Names should be exactly as provided (no leading slash) + expect(extracted.has("main.js")).toBe(true); + expect(extracted.has("lib/utils.js")).toBe(true); + }); + + test("handles Buffer content", async () => { + const entries: TarEntry[] = [ + { name: "main.js", content: Buffer.from("buffer content") }, + ]; + + const blob = await createTarBlob(entries); + const extracted = await extractTar(blob); + + expect(extracted.get("main.js")!.content).toBe("buffer content"); + }); + + test("handles Uint8Array content", async () => { + const content = new TextEncoder().encode("uint8 content"); + const entries: TarEntry[] = [ + { name: "main.js", content }, + ]; + + const blob = await createTarBlob(entries); + const extracted = await extractTar(blob); + + expect(extracted.get("main.js")!.content).toBe("uint8 content"); + }); +}); diff --git a/cli/test/test_backend.ts b/cli/test/test_backend.ts index b46166a21d..a3091d3a0e 100644 --- a/cli/test/test_backend.ts +++ b/cli/test/test_backend.ts @@ -13,7 +13,7 @@ * Usage: * import { withTestBackend, cleanupTestBackend } from "./test_backend.ts"; * - * Deno.test("my test", async () => { + * test("my test", async () => { * await withTestBackend(async (backend, tempDir) => { * const result = await backend.runCLICommand(["sync", "pull"], tempDir); * // ... @@ -23,6 +23,9 @@ import { CargoBackend, CargoBackendConfig } from "./cargo_backend.ts"; import { ContainerizedBackend, ContainerConfig } from "./containerized_backend.ts"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; /** * Common interface for test backends @@ -37,7 +40,7 @@ export interface TestBackend { stop(): Promise; reset(): Promise; - createCLICommand(args: string[], workingDir: string, workspaceName?: string): Deno.Command; + createCLICommand(args: string[], workingDir: string, workspaceName?: string): any; runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{ stdout: string; stderr: string; @@ -94,7 +97,7 @@ class CargoBackendAdapter implements TestBackend { await this.backend.reset(); } - createCLICommand(args: string[], workingDir: string, workspaceName?: string): Deno.Command { + createCLICommand(args: string[], workingDir: string, workspaceName?: string): any { return this.backend.createCLICommand(args, workingDir, workspaceName); } @@ -366,7 +369,7 @@ class ContainerizedBackendAdapter implements TestBackend { await this.backend.reset(); } - createCLICommand(args: string[], workingDir: string, workspaceName?: string): Deno.Command { + createCLICommand(args: string[], workingDir: string, workspaceName?: string): any { return this.backend.createCLICommand(args, workingDir, workspaceName); } @@ -414,7 +417,7 @@ let globalBackend: TestBackend | null = null; * Get the backend type from environment */ function getBackendType(): "cargo" | "docker" { - const envType = Deno.env.get("TEST_BACKEND")?.toLowerCase(); + const envType = process.env["TEST_BACKEND"]?.toLowerCase(); if (envType === "docker") { return "docker"; } @@ -433,7 +436,7 @@ export function createTestBackend(type?: "cargo" | "docker"): TestBackend { } else { console.log("🦀 Using Cargo-based test backend"); return new CargoBackendAdapter({ - verbose: Deno.env.get("VERBOSE") === "1", + verbose: process.env["VERBOSE"] === "1", }); } } @@ -444,6 +447,7 @@ export function createTestBackend(type?: "cargo" | "docker"): TestBackend { export async function getTestBackend(): Promise { if (!globalBackend) { globalBackend = createTestBackend(); + registerCleanup(); await globalBackend.start(); } return globalBackend; @@ -456,7 +460,7 @@ export async function withTestBackend( testFn: (backend: TestBackend, tempDir: string) => Promise ): Promise { const backend = await getTestBackend(); - const tempDir = await Deno.makeTempDir({ prefix: "windmill_cli_test_" }); + const tempDir = await mkdtemp(join(tmpdir(), "windmill_cli_test_")); try { await backend.reset(); @@ -465,7 +469,7 @@ export async function withTestBackend( } return await testFn(backend, tempDir); } finally { - await Deno.remove(tempDir, { recursive: true }); + await rm(tempDir, { recursive: true }); } } @@ -479,6 +483,30 @@ export async function cleanupTestBackend(): Promise { } } +// Auto-cleanup on process exit +let cleanupRegistered = false; +function registerCleanup() { + if (cleanupRegistered) return; + cleanupRegistered = true; + process.on("exit", () => { + if (globalBackend) { + // Synchronous kill — can't await in exit handler + try { + (globalBackend as any).backend?.process?.kill(); + } catch { + // Best effort + } + } + }); + // Handle graceful shutdown + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, async () => { + await cleanupTestBackend(); + process.exit(0); + }); + } +} + // Re-export for convenience export type { CargoBackendConfig } from "./cargo_backend.ts"; export type { ContainerConfig } from "./containerized_backend.ts"; diff --git a/cli/test/test_config_helpers.ts b/cli/test/test_config_helpers.ts index c4b3816663..6b9ade42c1 100644 --- a/cli/test/test_config_helpers.ts +++ b/cli/test/test_config_helpers.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { getWorkspaceConfigFilePath } from "../windmill-utils-internal/src/config/config.ts"; /** @@ -5,14 +8,14 @@ import { getWorkspaceConfigFilePath } from "../windmill-utils-internal/src/confi */ export async function withTestConfig(callback: (testConfigDir: string) => Promise): Promise { // Create a unique temporary directory for this test - const testDir = await Deno.makeTempDir({ prefix: "wmill_test_config_" }); - + const testDir = await mkdtemp(join(tmpdir(), "wmill_test_config_")); + try { return await callback(testDir); } finally { // Clean up the temporary directory try { - await Deno.remove(testDir, { recursive: true }); + await rm(testDir, { recursive: true }); } catch (error) { console.warn(`Failed to clean up test config directory ${testDir}:`, error); } @@ -24,7 +27,7 @@ export async function withTestConfig(callback: (testConfigDir: string) => Pro */ export async function clearTestRemotes(testConfigDir: string): Promise { const remoteFile = await getWorkspaceConfigFilePath(testConfigDir); - await Deno.writeTextFile(remoteFile, ""); + await writeFile(remoteFile, "", "utf-8"); } /** @@ -36,4 +39,4 @@ export function parseJsonFromCLIOutput(stdout: string): any { throw new Error(`No JSON found in CLI output: ${stdout}`); } return JSON.parse(jsonMatch[0]); -} \ No newline at end of file +} diff --git a/cli/test/utils_unit.test.ts b/cli/test/utils_unit.test.ts new file mode 100644 index 0000000000..f72ba23173 --- /dev/null +++ b/cli/test/utils_unit.test.ts @@ -0,0 +1,567 @@ +/** + * Unit tests for pure utility functions. + * These tests require no backend — they test standalone logic. + */ + +import { expect, test, describe } from "bun:test"; +import { deepEqual, isFileResource, toCamel, capitalize } from "../src/utils/utils.ts"; +import { + getTypeStrFromPath, + removeType, + isSuperset, + extractNativeTriggerInfo, + removePathPrefix, +} from "../src/types.ts"; +import { validatePath } from "../src/core/context.ts"; +import { inferContentTypeFromFilePath } from "../src/utils/script_common.ts"; +import { + filePathExtensionFromContentType, + removeExtensionToPath, +} from "../src/commands/script/script.ts"; + +// ============================================================================= +// deepEqual +// ============================================================================= + +describe("deepEqual", () => { + test("primitives", () => { + expect(deepEqual(1, 1)).toBe(true); + expect(deepEqual(1, 2)).toBe(false); + expect(deepEqual("a", "a")).toBe(true); + expect(deepEqual("a", "b")).toBe(false); + expect(deepEqual(true, true)).toBe(true); + expect(deepEqual(true, false)).toBe(false); + expect(deepEqual(null, null)).toBe(true); + expect(deepEqual(undefined, undefined)).toBe(true); + expect(deepEqual(null, undefined)).toBe(false); + }); + + test("NaN equality", () => { + expect(deepEqual(NaN, NaN)).toBe(true); + expect(deepEqual(NaN, 1)).toBe(false); + }); + + test("arrays", () => { + expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true); + expect(deepEqual([1, 2, 3], [1, 2, 4])).toBe(false); + expect(deepEqual([1, 2], [1, 2, 3])).toBe(false); + expect(deepEqual([], [])).toBe(true); + }); + + test("nested arrays", () => { + expect(deepEqual([[1, 2], [3]], [[1, 2], [3]])).toBe(true); + expect(deepEqual([[1, 2], [3]], [[1, 2], [4]])).toBe(false); + }); + + test("objects", () => { + expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); + expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 3 })).toBe(false); + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(deepEqual({}, {})).toBe(true); + }); + + test("nested objects", () => { + expect(deepEqual({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true); + expect(deepEqual({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false); + }); + + test("mixed nested structures", () => { + const a = { arr: [1, { x: "hello" }], n: null }; + const b = { arr: [1, { x: "hello" }], n: null }; + expect(deepEqual(a, b)).toBe(true); + + const c = { arr: [1, { x: "world" }], n: null }; + expect(deepEqual(a, c)).toBe(false); + }); + + test("Maps", () => { + const m1 = new Map([["a", 1], ["b", 2]]); + const m2 = new Map([["a", 1], ["b", 2]]); + const m3 = new Map([["a", 1], ["b", 3]]); + expect(deepEqual(m1, m2)).toBe(true); + expect(deepEqual(m1, m3)).toBe(false); + }); + + test("Sets", () => { + const s1 = new Set([1, 2, 3]); + const s2 = new Set([1, 2, 3]); + const s3 = new Set([1, 2, 4]); + expect(deepEqual(s1, s2)).toBe(true); + expect(deepEqual(s1, s3)).toBe(false); + }); + + test("RegExp", () => { + expect(deepEqual(/abc/g, /abc/g)).toBe(true); + expect(deepEqual(/abc/g, /abc/i)).toBe(false); + expect(deepEqual(/abc/, /def/)).toBe(false); + }); +}); + +// ============================================================================= +// toCamel & capitalize +// ============================================================================= + +describe("toCamel", () => { + test("converts snake_case to camelCase", () => { + expect(toCamel("hello_world")).toBe("helloWorld"); + expect(toCamel("my_variable_name")).toBe("myVariableName"); + }); + + test("converts kebab-case to camelCase", () => { + expect(toCamel("hello-world")).toBe("helloWorld"); + }); + + test("handles no separators", () => { + expect(toCamel("hello")).toBe("hello"); + }); +}); + +describe("capitalize", () => { + test("capitalizes first character", () => { + expect(capitalize("hello")).toBe("Hello"); + expect(capitalize("world")).toBe("World"); + }); + + test("handles single character", () => { + expect(capitalize("a")).toBe("A"); + }); + + test("handles already capitalized", () => { + expect(capitalize("Hello")).toBe("Hello"); + }); + + test("handles empty string", () => { + expect(capitalize("")).toBe(""); + }); +}); + +// ============================================================================= +// isFileResource +// ============================================================================= + +describe("isFileResource", () => { + test("detects resource file paths", () => { + expect(isFileResource("f/test/my_file.resource.file.txt")).toBe(true); + expect(isFileResource("u/admin/config.resource.file.json")).toBe(true); + }); + + test("rejects non-resource-file paths", () => { + expect(isFileResource("f/test/my_resource.resource.yaml")).toBe(false); + expect(isFileResource("f/test/my_script.ts")).toBe(false); + expect(isFileResource("f/test/my_flow.flow/flow.yaml")).toBe(false); + }); + + test("detects branch-specific resource file paths", () => { + expect(isFileResource("f/test/config.main.resource.file.json")).toBe(true); + }); +}); + +// ============================================================================= +// removeType +// ============================================================================= + +describe("removeType", () => { + test("removes .variable.yaml suffix", () => { + expect(removeType("f/test/my_var.variable.yaml", "variable")).toBe("f/test/my_var"); + }); + + test("removes .resource.yaml suffix", () => { + expect(removeType("f/test/my_res.resource.yaml", "resource")).toBe("f/test/my_res"); + }); + + test("removes .schedule.yaml suffix", () => { + expect(removeType("u/admin/cron.schedule.yaml", "schedule")).toBe("u/admin/cron"); + }); + + test("removes .json suffix too", () => { + expect(removeType("f/test/my_var.variable.json", "variable")).toBe("f/test/my_var"); + }); + + test("throws for wrong type suffix", () => { + expect(() => removeType("f/test/my_var.variable.yaml", "resource")).toThrow(); + }); + + test("throws for no type suffix", () => { + expect(() => removeType("f/test/my_script.ts", "variable")).toThrow(); + }); +}); + +// ============================================================================= +// removePathPrefix +// ============================================================================= + +describe("removePathPrefix", () => { + test("removes prefix from path", () => { + expect(removePathPrefix("f/test/my_script.ts", "f/test")).toBe("my_script.ts"); + }); + + test("handles exact match", () => { + expect(removePathPrefix("f/test", "f/test")).toBe(""); + }); + + test("throws when prefix doesn't match", () => { + expect(() => removePathPrefix("g/admin/script.ts", "f/test")).toThrow(); + }); +}); + +// ============================================================================= +// getTypeStrFromPath +// ============================================================================= + +describe("getTypeStrFromPath", () => { + test("detects script types by extension", () => { + expect(getTypeStrFromPath("f/test/my_script.ts")).toBe("script"); + expect(getTypeStrFromPath("f/test/my_script.py")).toBe("script"); + expect(getTypeStrFromPath("f/test/my_script.go")).toBe("script"); + expect(getTypeStrFromPath("f/test/my_script.sh")).toBe("script"); + expect(getTypeStrFromPath("f/test/my_script.sql")).toBe("script"); + expect(getTypeStrFromPath("f/test/my_script.php")).toBe("script"); + expect(getTypeStrFromPath("f/test/my_script.rs")).toBe("script"); + }); + + test("detects metadata types by name suffix", () => { + expect(getTypeStrFromPath("f/test/my_var.variable.yaml")).toBe("variable"); + expect(getTypeStrFromPath("f/test/my_res.resource.yaml")).toBe("resource"); + expect(getTypeStrFromPath("f/test/my_sched.schedule.yaml")).toBe("schedule"); + expect(getTypeStrFromPath("f/test/my_rt.resource-type.yaml")).toBe("resource-type"); + }); + + test("detects trigger types", () => { + expect(getTypeStrFromPath("f/test/my_trig.http_trigger.yaml")).toBe("http_trigger"); + expect(getTypeStrFromPath("f/test/my_trig.websocket_trigger.yaml")).toBe("websocket_trigger"); + expect(getTypeStrFromPath("f/test/my_trig.kafka_trigger.yaml")).toBe("kafka_trigger"); + }); + + test("detects folder metadata", () => { + expect(getTypeStrFromPath("f/test/folder.meta.yaml")).toBe("folder"); + }); + + test("detects user and group", () => { + expect(getTypeStrFromPath("admin.user.yaml")).toBe("user"); + expect(getTypeStrFromPath("devs.group.yaml")).toBe("group"); + }); + + test("throws for unknown type", () => { + expect(() => getTypeStrFromPath("f/test/unknown.xyz.yaml")).toThrow(); + }); +}); + +// ============================================================================= +// validatePath +// ============================================================================= + +describe("validatePath", () => { + test("accepts valid paths", () => { + expect(validatePath("f/test/my_script")).toBe(true); + expect(validatePath("u/admin/my_script")).toBe(true); + expect(validatePath("g/all/my_script")).toBe(true); + }); + + test("rejects invalid paths", () => { + expect(validatePath("invalid/path")).toBe(false); + expect(validatePath("test/my_script")).toBe(false); + }); +}); + +// ============================================================================= +// inferContentTypeFromFilePath +// ============================================================================= + +describe("inferContentTypeFromFilePath", () => { + test("detects Python", () => { + expect(inferContentTypeFromFilePath("script.py", undefined)).toBe("python3"); + }); + + test("detects Go", () => { + expect(inferContentTypeFromFilePath("script.go", undefined)).toBe("go"); + }); + + test("detects Bash", () => { + expect(inferContentTypeFromFilePath("script.sh", undefined)).toBe("bash"); + }); + + test("detects PHP", () => { + expect(inferContentTypeFromFilePath("script.php", undefined)).toBe("php"); + }); + + test("detects Rust", () => { + expect(inferContentTypeFromFilePath("script.rs", undefined)).toBe("rust"); + }); + + test("detects PowerShell", () => { + expect(inferContentTypeFromFilePath("script.ps1", undefined)).toBe("powershell"); + }); + + test("detects GraphQL", () => { + expect(inferContentTypeFromFilePath("query.gql", undefined)).toBe("graphql"); + }); + + test("defaults .ts to bun", () => { + expect(inferContentTypeFromFilePath("script.ts", undefined)).toBe("bun"); + }); + + test("uses defaultTs for .ts files", () => { + expect(inferContentTypeFromFilePath("script.ts", "deno")).toBe("deno"); + expect(inferContentTypeFromFilePath("script.ts", "bun")).toBe("bun"); + }); + + test("explicit bun.ts and deno.ts override defaultTs", () => { + expect(inferContentTypeFromFilePath("script.bun.ts", "deno")).toBe("bun"); + expect(inferContentTypeFromFilePath("script.deno.ts", "bun")).toBe("deno"); + }); + + test("detects nativets with fetch.ts", () => { + expect(inferContentTypeFromFilePath("script.fetch.ts", "bun")).toBe("nativets"); + }); + + test("detects SQL variants", () => { + expect(inferContentTypeFromFilePath("query.pg.sql", undefined)).toBe("postgresql"); + expect(inferContentTypeFromFilePath("query.my.sql", undefined)).toBe("mysql"); + expect(inferContentTypeFromFilePath("query.bq.sql", undefined)).toBe("bigquery"); + expect(inferContentTypeFromFilePath("query.ms.sql", undefined)).toBe("mssql"); + expect(inferContentTypeFromFilePath("query.sf.sql", undefined)).toBe("snowflake"); + expect(inferContentTypeFromFilePath("query.duckdb.sql", undefined)).toBe("duckdb"); + expect(inferContentTypeFromFilePath("query.odb.sql", undefined)).toBe("oracledb"); + }); +}); + +// ============================================================================= +// extractNativeTriggerInfo +// ============================================================================= + +describe("extractNativeTriggerInfo", () => { + test("extracts info from valid flow trigger path", () => { + const result = extractNativeTriggerInfo( + "u/admin/script.flow.12345.nextcloud_native_trigger.json" + ); + expect(result).not.toBeNull(); + expect(result!.scriptPath).toBe("u/admin/script"); + expect(result!.isFlow).toBe(true); + expect(result!.externalId).toBe("12345"); + expect(result!.serviceName).toBe("nextcloud"); + }); + + test("detects script (non-flow) triggers", () => { + const result = extractNativeTriggerInfo( + "f/test/handler.script.abc123.nextcloud_native_trigger.json" + ); + expect(result).not.toBeNull(); + expect(result!.isFlow).toBe(false); + expect(result!.scriptPath).toBe("f/test/handler"); + }); + + test("returns null for non-native trigger paths", () => { + expect(extractNativeTriggerInfo("f/test/my_var.variable.yaml")).toBeNull(); + expect(extractNativeTriggerInfo("f/test/trig.http_trigger.yaml")).toBeNull(); + }); +}); + +// ============================================================================= +// isSuperset +// ============================================================================= + +describe("isSuperset", () => { + test("returns true when subset matches superset", () => { + expect(isSuperset({ a: 1 }, { a: 1, b: 2 })).toBe(true); + }); + + test("returns true when objects are identical", () => { + expect(isSuperset({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); + }); + + test("returns false when values differ", () => { + expect(isSuperset({ a: 1 }, { a: 2 })).toBe(false); + }); + + test("handles nested objects", () => { + expect(isSuperset({ a: { x: 1 } }, { a: { x: 1 }, b: 2 })).toBe(true); + expect(isSuperset({ a: { x: 1 } }, { a: { x: 2 } })).toBe(false); + }); + + test("empty subset is always a superset match", () => { + expect(isSuperset({}, { a: 1, b: 2 })).toBe(true); + }); +}); + +// ============================================================================= +// filePathExtensionFromContentType +// ============================================================================= + +describe("filePathExtensionFromContentType", () => { + test("returns .py for python3", () => { + expect(filePathExtensionFromContentType("python3", undefined)).toBe(".py"); + }); + + test("returns .fetch.ts for nativets", () => { + expect(filePathExtensionFromContentType("nativets", undefined)).toBe(".fetch.ts"); + }); + + test("returns .ts for bun when defaultTs is bun or undefined", () => { + expect(filePathExtensionFromContentType("bun", "bun")).toBe(".ts"); + expect(filePathExtensionFromContentType("bun", undefined)).toBe(".ts"); + }); + + test("returns .bun.ts for bun when defaultTs is deno", () => { + expect(filePathExtensionFromContentType("bun", "deno")).toBe(".bun.ts"); + }); + + test("returns .ts for deno when defaultTs is deno", () => { + expect(filePathExtensionFromContentType("deno", "deno")).toBe(".ts"); + }); + + test("returns .deno.ts for deno when defaultTs is bun or undefined", () => { + expect(filePathExtensionFromContentType("deno", "bun")).toBe(".deno.ts"); + expect(filePathExtensionFromContentType("deno", undefined)).toBe(".deno.ts"); + }); + + test("returns .go for go", () => { + expect(filePathExtensionFromContentType("go", undefined)).toBe(".go"); + }); + + test("returns .sh for bash", () => { + expect(filePathExtensionFromContentType("bash", undefined)).toBe(".sh"); + }); + + test("returns .ps1 for powershell", () => { + expect(filePathExtensionFromContentType("powershell", undefined)).toBe(".ps1"); + }); + + test("returns .gql for graphql", () => { + expect(filePathExtensionFromContentType("graphql", undefined)).toBe(".gql"); + }); + + test("returns .php for php", () => { + expect(filePathExtensionFromContentType("php", undefined)).toBe(".php"); + }); + + test("returns .rs for rust", () => { + expect(filePathExtensionFromContentType("rust", undefined)).toBe(".rs"); + }); + + test("returns .cs for csharp", () => { + expect(filePathExtensionFromContentType("csharp", undefined)).toBe(".cs"); + }); + + test("returns .nu for nu", () => { + expect(filePathExtensionFromContentType("nu", undefined)).toBe(".nu"); + }); + + test("returns .java for java", () => { + expect(filePathExtensionFromContentType("java", undefined)).toBe(".java"); + }); + + test("returns .rb for ruby", () => { + expect(filePathExtensionFromContentType("ruby", undefined)).toBe(".rb"); + }); + + test("returns .playbook.yml for ansible", () => { + expect(filePathExtensionFromContentType("ansible", undefined)).toBe(".playbook.yml"); + }); + + test("returns correct SQL extensions", () => { + expect(filePathExtensionFromContentType("postgresql", undefined)).toBe(".pg.sql"); + expect(filePathExtensionFromContentType("mysql", undefined)).toBe(".my.sql"); + expect(filePathExtensionFromContentType("bigquery", undefined)).toBe(".bq.sql"); + expect(filePathExtensionFromContentType("duckdb", undefined)).toBe(".duckdb.sql"); + expect(filePathExtensionFromContentType("oracledb", undefined)).toBe(".odb.sql"); + expect(filePathExtensionFromContentType("snowflake", undefined)).toBe(".sf.sql"); + expect(filePathExtensionFromContentType("mssql", undefined)).toBe(".ms.sql"); + }); + + test("throws for invalid language", () => { + expect(() => + filePathExtensionFromContentType("invalid" as any, undefined) + ).toThrow(); + }); +}); + +// ============================================================================= +// removeExtensionToPath +// ============================================================================= + +describe("removeExtensionToPath", () => { + test("removes .ts extension", () => { + expect(removeExtensionToPath("f/test/script.ts")).toBe("f/test/script"); + }); + + test("removes .py extension", () => { + expect(removeExtensionToPath("f/test/script.py")).toBe("f/test/script"); + }); + + test("removes .go extension", () => { + expect(removeExtensionToPath("f/test/script.go")).toBe("f/test/script"); + }); + + test("removes .sh extension", () => { + expect(removeExtensionToPath("f/test/script.sh")).toBe("f/test/script"); + }); + + test("removes .pg.sql extension", () => { + expect(removeExtensionToPath("f/test/query.pg.sql")).toBe("f/test/query"); + }); + + test("removes .my.sql extension", () => { + expect(removeExtensionToPath("f/test/query.my.sql")).toBe("f/test/query"); + }); + + test("removes .duckdb.sql extension", () => { + expect(removeExtensionToPath("f/test/query.duckdb.sql")).toBe("f/test/query"); + }); + + test("removes .fetch.ts extension", () => { + expect(removeExtensionToPath("f/test/script.fetch.ts")).toBe("f/test/script"); + }); + + test("removes .bun.ts extension", () => { + expect(removeExtensionToPath("f/test/script.bun.ts")).toBe("f/test/script"); + }); + + test("removes .deno.ts extension", () => { + expect(removeExtensionToPath("f/test/script.deno.ts")).toBe("f/test/script"); + }); + + test("removes .gql extension", () => { + expect(removeExtensionToPath("f/test/query.gql")).toBe("f/test/query"); + }); + + test("removes .ps1 extension", () => { + expect(removeExtensionToPath("f/test/script.ps1")).toBe("f/test/script"); + }); + + test("removes .php extension", () => { + expect(removeExtensionToPath("f/test/script.php")).toBe("f/test/script"); + }); + + test("removes .rs extension", () => { + expect(removeExtensionToPath("f/test/script.rs")).toBe("f/test/script"); + }); + + test("removes .cs extension", () => { + expect(removeExtensionToPath("f/test/script.cs")).toBe("f/test/script"); + }); + + test("removes .nu extension", () => { + expect(removeExtensionToPath("f/test/script.nu")).toBe("f/test/script"); + }); + + test("removes .playbook.yml extension", () => { + expect(removeExtensionToPath("f/test/play.playbook.yml")).toBe("f/test/play"); + }); + + test("removes .java extension", () => { + expect(removeExtensionToPath("f/test/Script.java")).toBe("f/test/Script"); + }); + + test("removes .rb extension", () => { + expect(removeExtensionToPath("f/test/script.rb")).toBe("f/test/script"); + }); + + test("throws for unknown extension", () => { + expect(() => removeExtensionToPath("f/test/file.xyz")).toThrow(); + }); + + test("prioritizes longer extensions (fetch.ts over .ts)", () => { + // fetch.ts should be recognized as nativets, not as bun .ts + expect(removeExtensionToPath("f/test/api.fetch.ts")).toBe("f/test/api"); + }); +}); diff --git a/cli/test/variable_resource_push.test.ts b/cli/test/variable_resource_push.test.ts new file mode 100644 index 0000000000..c348a31e66 --- /dev/null +++ b/cli/test/variable_resource_push.test.ts @@ -0,0 +1,340 @@ +/** + * Integration tests for variable and resource CLI commands. + * Tests list and push operations via CLI and direct API. + */ + +import { expect, test, describe } from "bun:test"; +import { writeFile, mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +async function setupWorkspaceProfile(backend: any): Promise { + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "localhost_test", + token: backend.token, + }, + { force: true, configDir: backend.testConfigDir } + ); +} + +// ============================================================================= +// Variable Tests +// ============================================================================= + +describe("variable", () => { + test("list returns seeded variables", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand(["variable"], tempDir); + + expect(result.code).toEqual(0); + // seedTestData creates f/test/my_variable + expect(result.stdout).toContain("f/test/my_variable"); + }); + }); + + test("push creates a new variable via sync push", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + + // Create wmill.yaml + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + + // Create variable file + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + const varPath = `f/test/test_var_${uniqueId}.variable.yaml`; + await writeFile( + join(tempDir, varPath), + `value: "hello_from_test_${uniqueId}"\nis_secret: false\ndescription: "Test variable created by integration test"\n`, + "utf-8" + ); + + // Push with sync push targeting just our variable + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `f/test/test_var_${uniqueId}**`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify via API that the variable was created + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/f/test/test_var_${uniqueId}` + ); + expect(apiResp.status).toEqual(200); + const varData = await apiResp.json(); + expect(varData.path).toBe(`f/test/test_var_${uniqueId}`); + expect(varData.is_secret).toBe(false); + }); + }); + + test("push updates an existing variable", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + + // Create variable via API first + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/update_var_${uniqueId}`, + value: "original_value", + is_secret: false, + description: "Original description", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Create wmill.yaml and updated variable file + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + await writeFile( + join(tempDir, `f/test/update_var_${uniqueId}.variable.yaml`), + `value: "updated_value"\nis_secret: false\ndescription: "Updated description"\n`, + "utf-8" + ); + + // Push the update + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `f/test/update_var_${uniqueId}**`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify the update via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/f/test/update_var_${uniqueId}` + ); + expect(apiResp.status).toEqual(200); + const varData = await apiResp.json(); + expect(varData.description).toBe("Updated description"); + }); + }); + + test("pull retrieves variables into local files", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + + // Create a variable via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/pull_var_${uniqueId}`, + value: "pull_test_value", + is_secret: false, + description: "Variable for pull test", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Create wmill.yaml + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "f/test/pull_var_${uniqueId}**"\nexcludes: []\n`, + "utf-8" + ); + + // Pull + const pullResult = await backend.runCLICommand( + ["sync", "pull", "--yes"], + tempDir + ); + expect(pullResult.code).toEqual(0); + + // Check the file was created + const content = await readFile( + join(tempDir, `f/test/pull_var_${uniqueId}.variable.yaml`), "utf-8" + ); + expect(content).toContain("pull_test_value"); + expect(content).toContain("is_secret: false"); + }); + }); +}); + +// ============================================================================= +// Resource Tests +// ============================================================================= + +describe("resource", () => { + test("list returns seeded resources", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const result = await backend.runCLICommand(["resource"], tempDir); + + expect(result.code).toEqual(0); + // seedTestData creates f/test/my_resource + expect(result.stdout).toContain("f/test/my_resource"); + }); + }); + + test("push creates a new resource via sync push", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + + // Create wmill.yaml + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + + // Create resource file + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + const resPath = `f/test/test_res_${uniqueId}.resource.yaml`; + await writeFile( + join(tempDir, resPath), + `resource_type: "any"\nvalue:\n host: "localhost"\n port: 3000\ndescription: "Test resource"\n`, + "utf-8" + ); + + // Push + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `f/test/test_res_${uniqueId}**`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify via API + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/resources/get/f/test/test_res_${uniqueId}` + ); + expect(apiResp.status).toEqual(200); + const resData = await apiResp.json(); + expect(resData.path).toBe(`f/test/test_res_${uniqueId}`); + expect(resData.resource_type).toBe("any"); + expect(resData.value.host).toBe("localhost"); + }); + }); + + test("push updates an existing resource", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + + // Create resource via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/resources/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/update_res_${uniqueId}`, + resource_type: "any", + value: { host: "old_host" }, + description: "Original", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Create wmill.yaml and updated resource file + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + await writeFile( + join(tempDir, `f/test/update_res_${uniqueId}.resource.yaml`), + `resource_type: "any"\nvalue:\n host: "new_host"\n port: 9999\ndescription: "Updated"\n`, + "utf-8" + ); + + // Push the update + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `f/test/update_res_${uniqueId}**`], + tempDir + ); + + expect(pushResult.code).toEqual(0); + + // Verify update + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/resources/get/f/test/update_res_${uniqueId}` + ); + expect(apiResp.status).toEqual(200); + const resData = await apiResp.json(); + expect(resData.value.host).toBe("new_host"); + expect(resData.value.port).toBe(9999); + }); + }); + + test("pull retrieves resources into local files", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + + // Create resource via API + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/resources/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: `f/test/pull_res_${uniqueId}`, + resource_type: "any", + value: { key: "pull_test" }, + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + + // Create wmill.yaml + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "f/test/pull_res_${uniqueId}**"\nexcludes: []\nskipVariables: true\n`, + "utf-8" + ); + + // Pull + const pullResult = await backend.runCLICommand( + ["sync", "pull", "--yes"], + tempDir + ); + expect(pullResult.code).toEqual(0); + + // Check the resource file was created + const content = await readFile( + join(tempDir, `f/test/pull_res_${uniqueId}.resource.yaml`), "utf-8" + ); + expect(content).toContain("pull_test"); + }); + }); +}); diff --git a/cli/test/wmill_lock.test.ts b/cli/test/wmill_lock.test.ts index f3a635a36e..22c6c52ea0 100644 --- a/cli/test/wmill_lock.test.ts +++ b/cli/test/wmill_lock.test.ts @@ -6,9 +6,10 @@ * looked up on both Windows and Linux systems. */ -import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import * as path from "https://deno.land/std@0.224.0/path/mod.ts"; -import { ensureDir } from "https://deno.land/std@0.224.0/fs/mod.ts"; +import { expect, test } from "bun:test"; +import * as path from "node:path"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import { normalizeLockPath, readLockfile, @@ -17,30 +18,31 @@ import { clearGlobalLock, } from "../src/utils/metadata.ts"; import { generateHash } from "../src/utils/utils.ts"; -import { yamlStringify, yamlParseFile } from "../deps.ts"; +import { stringify as yamlStringify } from "yaml"; +import { yamlParseFile } from "../src/utils/yaml.ts"; // ============================================================================= // UNIT TESTS - Path Normalization // ============================================================================= -Deno.test("normalizeLockPath: converts Windows backslashes to forward slashes", () => { - assertEquals(normalizeLockPath("f\\test\\script"), "f/test/script"); - assertEquals(normalizeLockPath("f\\deeply\\nested\\path\\script"), "f/deeply/nested/path/script"); +test("normalizeLockPath: converts Windows backslashes to forward slashes", () => { + expect(normalizeLockPath("f\\test\\script")).toEqual("f/test/script"); + expect(normalizeLockPath("f\\deeply\\nested\\path\\script")).toEqual("f/deeply/nested/path/script"); }); -Deno.test("normalizeLockPath: preserves already-normalized paths", () => { - assertEquals(normalizeLockPath("f/test/script"), "f/test/script"); - assertEquals(normalizeLockPath("f/deeply/nested/path/script"), "f/deeply/nested/path/script"); +test("normalizeLockPath: preserves already-normalized paths", () => { + expect(normalizeLockPath("f/test/script")).toEqual("f/test/script"); + expect(normalizeLockPath("f/deeply/nested/path/script")).toEqual("f/deeply/nested/path/script"); }); -Deno.test("normalizeLockPath: handles paths without separators", () => { - assertEquals(normalizeLockPath("script"), "script"); - assertEquals(normalizeLockPath(""), ""); +test("normalizeLockPath: handles paths without separators", () => { + expect(normalizeLockPath("script")).toEqual("script"); + expect(normalizeLockPath("")).toEqual(""); }); -Deno.test("normalizeLockPath: handles mixed separators", () => { - assertEquals(normalizeLockPath("f/test\\nested/script"), "f/test/nested/script"); - assertEquals(normalizeLockPath("f\\test/nested\\script"), "f/test/nested/script"); +test("normalizeLockPath: handles mixed separators", () => { + expect(normalizeLockPath("f/test\\nested/script")).toEqual("f/test/nested/script"); + expect(normalizeLockPath("f\\test/nested\\script")).toEqual("f/test/nested/script"); }); // ============================================================================= @@ -48,18 +50,18 @@ Deno.test("normalizeLockPath: handles mixed separators", () => { // ============================================================================= async function withTempDir(fn: (tempDir: string) => Promise): Promise { - const tempDir = await Deno.makeTempDir({ prefix: "wmill_lock_test_" }); - const originalCwd = Deno.cwd(); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "wmill_lock_test_")); + const originalCwd = process.cwd(); try { - Deno.chdir(tempDir); + process.chdir(tempDir); await fn(tempDir); } finally { - Deno.chdir(originalCwd); - await Deno.remove(tempDir, { recursive: true }); + process.chdir(originalCwd); + await rm(tempDir, { recursive: true }); } } -Deno.test("wmill-lock: stores paths with Linux separators even when given Windows paths", async () => { +test("wmill-lock: stores paths with Linux separators even when given Windows paths", async () => { await withTempDir(async (tempDir) => { // Simulate a Windows-style path const windowsPath = "f\\flows\\my-flow.flow"; @@ -71,12 +73,12 @@ Deno.test("wmill-lock: stores paths with Linux separators even when given Window const lockfile = await yamlParseFile("wmill-lock.yaml") as { version: string; locks: Record }; // Path should be stored with forward slashes - assertEquals(lockfile.locks["f/flows/my-flow.flow"], hash); - assertEquals(lockfile.locks["f\\flows\\my-flow.flow"], undefined); + expect(lockfile.locks["f/flows/my-flow.flow"]).toEqual(hash); + expect(lockfile.locks["f\\flows\\my-flow.flow"]).toEqual(undefined); }); }); -Deno.test("wmill-lock: checkifMetadataUptodate finds paths regardless of separator style", async () => { +test("wmill-lock: checkifMetadataUptodate finds paths regardless of separator style", async () => { await withTempDir(async (tempDir) => { const linuxPath = "f/scripts/my-script"; const windowsPath = "f\\scripts\\my-script"; @@ -87,18 +89,18 @@ Deno.test("wmill-lock: checkifMetadataUptodate finds paths regardless of separat // Should find with Linux-style lookup const conf = await readLockfile(); - assertEquals(await checkifMetadataUptodate(linuxPath, hash, conf), true); + expect(await checkifMetadataUptodate(linuxPath, hash, conf)).toEqual(true); // Should also find with Windows-style lookup (simulating Windows usage) - assertEquals(await checkifMetadataUptodate(windowsPath, hash, conf), true); + expect(await checkifMetadataUptodate(windowsPath, hash, conf)).toEqual(true); // Should not find with wrong hash - assertEquals(await checkifMetadataUptodate(linuxPath, "wrong", conf), false); - assertEquals(await checkifMetadataUptodate(windowsPath, "wrong", conf), false); + expect(await checkifMetadataUptodate(linuxPath, "wrong", conf)).toEqual(false); + expect(await checkifMetadataUptodate(windowsPath, "wrong", conf)).toEqual(false); }); }); -Deno.test("wmill-lock: updateMetadataGlobalLock with subpath normalizes both path and subpath", async () => { +test("wmill-lock: updateMetadataGlobalLock with subpath normalizes both path and subpath", async () => { await withTempDir(async (tempDir) => { const windowsPath = "f\\flows\\my-flow.flow"; const windowsSubpath = "inline\\script.ts"; @@ -110,11 +112,11 @@ Deno.test("wmill-lock: updateMetadataGlobalLock with subpath normalizes both pat const lockfile = await yamlParseFile("wmill-lock.yaml") as { version: string; locks: Record }; // Both path and subpath should use forward slashes - assertEquals(lockfile.locks["f/flows/my-flow.flow+inline/script.ts"], hash); + expect(lockfile.locks["f/flows/my-flow.flow+inline/script.ts"]).toEqual(hash); }); }); -Deno.test("wmill-lock: checkifMetadataUptodate with subpath handles Windows separators", async () => { +test("wmill-lock: checkifMetadataUptodate with subpath handles Windows separators", async () => { await withTempDir(async (tempDir) => { const linuxPath = "f/apps/my-app.app"; const linuxSubpath = "scripts/button.ts"; @@ -128,18 +130,18 @@ Deno.test("wmill-lock: checkifMetadataUptodate with subpath handles Windows sepa const conf = await readLockfile(); // Should find with Linux-style lookup - assertEquals(await checkifMetadataUptodate(linuxPath, hash, conf, linuxSubpath), true); + expect(await checkifMetadataUptodate(linuxPath, hash, conf, linuxSubpath)).toEqual(true); // Should find with Windows-style lookup - assertEquals(await checkifMetadataUptodate(windowsPath, hash, conf, windowsSubpath), true); + expect(await checkifMetadataUptodate(windowsPath, hash, conf, windowsSubpath)).toEqual(true); // Should find with mixed-style lookup - assertEquals(await checkifMetadataUptodate(windowsPath, hash, conf, linuxSubpath), true); - assertEquals(await checkifMetadataUptodate(linuxPath, hash, conf, windowsSubpath), true); + expect(await checkifMetadataUptodate(windowsPath, hash, conf, linuxSubpath)).toEqual(true); + expect(await checkifMetadataUptodate(linuxPath, hash, conf, windowsSubpath)).toEqual(true); }); }); -Deno.test("wmill-lock: clearGlobalLock clears paths regardless of separator style", async () => { +test("wmill-lock: clearGlobalLock clears paths regardless of separator style", async () => { await withTempDir(async (tempDir) => { const basePath = "f/flows/my-flow.flow"; const subpath1 = "scripts/a.ts"; @@ -152,21 +154,21 @@ Deno.test("wmill-lock: clearGlobalLock clears paths regardless of separator styl // Verify they exist let conf = await readLockfile(); - assertEquals(await checkifMetadataUptodate(basePath, "hash1", conf, subpath1), true); - assertEquals(await checkifMetadataUptodate(basePath, "hash2", conf, subpath2), true); + expect(await checkifMetadataUptodate(basePath, "hash1", conf, subpath1)).toEqual(true); + expect(await checkifMetadataUptodate(basePath, "hash2", conf, subpath2)).toEqual(true); // Clear using Windows-style path await clearGlobalLock("f\\flows\\my-flow.flow"); // All entries should be cleared conf = await readLockfile(); - assertEquals(await checkifMetadataUptodate(basePath, "hash1", conf, subpath1), false); - assertEquals(await checkifMetadataUptodate(basePath, "hash2", conf, subpath2), false); - assertEquals(await checkifMetadataUptodate(basePath, "topHash", conf, "__flow_hash"), false); + expect(await checkifMetadataUptodate(basePath, "hash1", conf, subpath1)).toEqual(false); + expect(await checkifMetadataUptodate(basePath, "hash2", conf, subpath2)).toEqual(false); + expect(await checkifMetadataUptodate(basePath, "topHash", conf, "__flow_hash")).toEqual(false); }); }); -Deno.test("wmill-lock: lock file created on Linux can be used on Windows (simulated)", async () => { +test("wmill-lock: lock file created on Linux can be used on Windows (simulated)", async () => { await withTempDir(async (tempDir) => { // Simulate a lock file created on Linux const linuxLockContent = { @@ -178,21 +180,22 @@ Deno.test("wmill-lock: lock file created on Linux can be used on Windows (simula }, }; - await Deno.writeTextFile( + await writeFile( "wmill-lock.yaml", - yamlStringify(linuxLockContent as Record) + yamlStringify(linuxLockContent as Record), + "utf-8" ); const conf = await readLockfile(); // Simulate Windows lookups (using backslashes) - assertEquals(await checkifMetadataUptodate("f\\scripts\\utility", "hash1", conf), true); - assertEquals(await checkifMetadataUptodate("f\\flows\\main.flow", "hash2", conf, "scripts\\step1.ts"), true); - assertEquals(await checkifMetadataUptodate("f\\apps\\dashboard.app", "hash3", conf, "components\\chart.ts"), true); + expect(await checkifMetadataUptodate("f\\scripts\\utility", "hash1", conf)).toEqual(true); + expect(await checkifMetadataUptodate("f\\flows\\main.flow", "hash2", conf, "scripts\\step1.ts")).toEqual(true); + expect(await checkifMetadataUptodate("f\\apps\\dashboard.app", "hash3", conf, "components\\chart.ts")).toEqual(true); }); }); -Deno.test("wmill-lock: multiple updates with different separator styles result in single entry", async () => { +test("wmill-lock: multiple updates with different separator styles result in single entry", async () => { await withTempDir(async (tempDir) => { const linuxPath = "f/scripts/shared"; const windowsPath = "f\\scripts\\shared"; @@ -207,9 +210,9 @@ Deno.test("wmill-lock: multiple updates with different separator styles result i // Should only have one entry with the latest hash const lockKeys = Object.keys(lockfile.locks); - assertEquals(lockKeys.length, 1); - assertEquals(lockKeys[0], "f/scripts/shared"); - assertEquals(lockfile.locks["f/scripts/shared"], "hash2"); + expect(lockKeys.length).toEqual(1); + expect(lockKeys[0]).toEqual("f/scripts/shared"); + expect(lockfile.locks["f/scripts/shared"]).toEqual("hash2"); }); }); @@ -217,7 +220,7 @@ Deno.test("wmill-lock: multiple updates with different separator styles result i // HASH COMPUTATION TESTS - OS-Independent Hash Generation // ============================================================================= -Deno.test("hash computation: normalized paths produce same hash on Windows and Linux", async () => { +test("hash computation: normalized paths produce same hash on Windows and Linux", async () => { // Simulate how generateFlowHash/generateAppHash compute hashes // by using paths as keys in an object that gets stringified @@ -246,13 +249,13 @@ Deno.test("hash computation: normalized paths produce same hash on Windows and L const linuxTopHash = await generateHash(JSON.stringify(linuxHashes)); // Both should produce the same top hash - assertEquals(windowsTopHash, linuxTopHash); + expect(windowsTopHash).toEqual(linuxTopHash); // And the individual hashes should have the same keys - assertEquals(Object.keys(windowsHashes).sort(), Object.keys(linuxHashes).sort()); + expect(Object.keys(windowsHashes).sort()).toEqual(Object.keys(linuxHashes).sort()); }); -Deno.test("hash computation: without normalization, Windows and Linux would produce different hashes", async () => { +test("hash computation: without normalization, Windows and Linux would produce different hashes", async () => { // This test demonstrates the problem that normalization fixes const fileContents = { "script1.ts": "export function main() { return 1; }", @@ -282,13 +285,13 @@ Deno.test("hash computation: without normalization, Windows and Linux would prod const linuxKeys = Object.keys(linuxHashesNoNormalize).sort(); // Keys should be different without normalization - assertEquals(windowsKeys.includes("nested\\script2.ts"), true); - assertEquals(linuxKeys.includes("nested/script2.ts"), true); - assertEquals(windowsKeys.includes("nested/script2.ts"), false); - assertEquals(linuxKeys.includes("nested\\script2.ts"), false); + expect(windowsKeys.includes("nested\\script2.ts")).toEqual(true); + expect(linuxKeys.includes("nested/script2.ts")).toEqual(true); + expect(windowsKeys.includes("nested/script2.ts")).toEqual(false); + expect(linuxKeys.includes("nested\\script2.ts")).toEqual(false); }); -Deno.test("hash computation: deeply nested paths are normalized correctly", async () => { +test("hash computation: deeply nested paths are normalized correctly", async () => { const deepWindowsPath = "f\\flows\\my-flow.flow\\inline\\scripts\\deeply\\nested\\handler.ts"; const deepLinuxPath = "f/flows/my-flow.flow/inline/scripts/deeply/nested/handler.ts"; @@ -304,12 +307,12 @@ Deno.test("hash computation: deeply nested paths are normalized correctly", asyn linuxHashes[normalizeLockPath(deepLinuxPath)] = await generateHash(content); const linuxTopHash = await generateHash(JSON.stringify(linuxHashes)); - assertEquals(windowsTopHash, linuxTopHash); - assertEquals(Object.keys(windowsHashes)[0], Object.keys(linuxHashes)[0]); - assertEquals(Object.keys(windowsHashes)[0], deepLinuxPath); + expect(windowsTopHash).toEqual(linuxTopHash); + expect(Object.keys(windowsHashes)[0]).toEqual(Object.keys(linuxHashes)[0]); + expect(Object.keys(windowsHashes)[0]).toEqual(deepLinuxPath); }); -Deno.test("hash computation: changedScripts comparison works with inline module paths", () => { +test("hash computation: changedScripts comparison works with inline module paths", () => { // This test simulates the comparison done in replaceInlineScripts // where changedScripts (from hashes keys) is compared with paths from flow module content @@ -329,10 +332,8 @@ Deno.test("hash computation: changedScripts comparison works with inline module // All inline module paths should be found in changedScripts for (const inlinePath of inlineModulePaths) { - assertEquals( - changedScripts.includes(inlinePath), - true, - `Expected changedScripts to include "${inlinePath}"` - ); + expect( + changedScripts.includes(inlinePath) + ).toEqual(true); } }); diff --git a/cli/test/workspace_conflicts.test.ts b/cli/test/workspace_conflicts.test.ts index 2e550d69f6..3083d53218 100644 --- a/cli/test/workspace_conflicts.test.ts +++ b/cli/test/workspace_conflicts.test.ts @@ -1,22 +1,22 @@ -import { assertEquals, assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; import { addWorkspace, allWorkspaces } from "../workspace.ts"; import { withTestConfig, clearTestRemotes } from "./test_config_helpers.ts"; // Test workspace conflict detection -Deno.test("addWorkspace: prevents duplicate workspace names", async () => { +test("addWorkspace: prevents duplicate workspace names", async () => { await withTestConfig(async (testConfigDir) => { await clearTestRemotes(testConfigDir); - + // Add first workspace const workspace1 = { name: "test_workspace", remote: "http://localhost:8001/", - workspaceId: "workspace1", + workspaceId: "workspace1", token: "token1" }; - + await addWorkspace(workspace1, { force: true, configDir: testConfigDir }); - + // Try to add workspace with same name but different details const workspace2 = { name: "test_workspace", // Same name @@ -24,33 +24,39 @@ Deno.test("addWorkspace: prevents duplicate workspace names", async () => { workspaceId: "workspace2", // Different ID token: "token2" }; - - // Should throw error in non-interactive mode without force - await assertRejects( - () => addWorkspace(workspace2, { configDir: testConfigDir }), - Error, - "Workspace name conflict. Use --force to overwrite or choose a different name." - ); - + + // Force non-interactive mode so addWorkspace throws instead of prompting + const origStdinTTY = process.stdin.isTTY; + const origStdoutTTY = process.stdout.isTTY; + try { + process.stdin.isTTY = false as any; + process.stdout.isTTY = false as any; + + // Should throw error in non-interactive mode without force + await expect( + addWorkspace(workspace2, { configDir: testConfigDir }) + ).rejects.toThrow("Workspace name conflict. Use --force to overwrite or choose a different name."); + } finally { + process.stdin.isTTY = origStdinTTY; + process.stdout.isTTY = origStdoutTTY; + } + // Should succeed with force flag await addWorkspace(workspace2, { force: true, configDir: testConfigDir }); - + // Verify the workspace was overwritten const workspaces = await allWorkspaces(testConfigDir); - assertEquals(workspaces.length, 1); - assertEquals(workspaces[0].name, "test_workspace"); - assertEquals(workspaces[0].remote, "http://localhost:8002/"); - assertEquals(workspaces[0].workspaceId, "workspace2"); + expect(workspaces.length).toEqual(1); + expect(workspaces[0].name).toEqual("test_workspace"); + expect(workspaces[0].remote).toEqual("http://localhost:8002/"); + expect(workspaces[0].workspaceId).toEqual("workspace2"); }); }); -Deno.test({ - name: "addWorkspace: prevents duplicate (remote, workspaceId) tuples", - ignore: true, // TODO: Investigate addWorkspace behavior - not throwing expected error - fn: async () => { +test("addWorkspace: prevents duplicate (remote, workspaceId) tuples", async () => { await withTestConfig(async (testConfigDir) => { await clearTestRemotes(testConfigDir); - + // Add first workspace const workspace1 = { name: "first_workspace", @@ -58,9 +64,9 @@ Deno.test({ workspaceId: "test", token: "token1" }; - + await addWorkspace(workspace1, { force: true, configDir: testConfigDir }); - + // Try to add workspace with same (remote, workspaceId) but different name const workspace2 = { name: "second_workspace", // Different name @@ -68,30 +74,28 @@ Deno.test({ workspaceId: "test", // Same workspaceId token: "token2" }; - + // Should throw error in non-interactive mode without force - await assertRejects( - () => addWorkspace(workspace2, { configDir: testConfigDir }), - Error, - 'Backend constraint violation: (http://localhost:8001/, test) already exists as "first_workspace". Use --force to overwrite.' - ); - + await expect( + addWorkspace(workspace2, { configDir: testConfigDir }) + ).rejects.toThrow('Backend constraint violation: (http://localhost:8001/, test) already exists as "first_workspace". Use --force to overwrite.'); + // Should succeed with force flag (overwrites first workspace) await addWorkspace(workspace2, { force: true, configDir: testConfigDir }); - + // Verify the first workspace was removed and second was added const workspaces = await allWorkspaces(testConfigDir); - assertEquals(workspaces.length, 1); - assertEquals(workspaces[0].name, "second_workspace"); - assertEquals(workspaces[0].remote, "http://localhost:8001/"); - assertEquals(workspaces[0].workspaceId, "test"); + expect(workspaces.length).toEqual(1); + expect(workspaces[0].name).toEqual("second_workspace"); + expect(workspaces[0].remote).toEqual("http://localhost:8001/"); + expect(workspaces[0].workspaceId).toEqual("test"); }); -}}); +}); -Deno.test("addWorkspace: allows same workspace (name, remote, workspaceId) with token update", async () => { +test("addWorkspace: allows same workspace (name, remote, workspaceId) with token update", async () => { await withTestConfig(async (testConfigDir) => { await clearTestRemotes(testConfigDir); - + // Add first workspace const workspace1 = { name: "same_workspace", @@ -99,9 +103,9 @@ Deno.test("addWorkspace: allows same workspace (name, remote, workspaceId) with workspaceId: "test", token: "old_token" }; - + await addWorkspace(workspace1, { force: true, configDir: testConfigDir }); - + // Add same workspace with updated token const workspace2 = { name: "same_workspace", // Same name @@ -109,22 +113,61 @@ Deno.test("addWorkspace: allows same workspace (name, remote, workspaceId) with workspaceId: "test", // Same workspaceId token: "new_token" // Different token }; - + // Should succeed without force (just token update) await addWorkspace(workspace2, { configDir: testConfigDir }); - + // Verify token was updated const workspaces = await allWorkspaces(testConfigDir); - assertEquals(workspaces.length, 1); - assertEquals(workspaces[0].name, "same_workspace"); - assertEquals(workspaces[0].token, "new_token"); + expect(workspaces.length).toEqual(1); + expect(workspaces[0].name).toEqual("same_workspace"); + expect(workspaces[0].token).toEqual("new_token"); }); }); -Deno.test("addWorkspace: allows different workspaces on different remotes", async () => { +test("addWorkspace: returns true on successful add", async () => { await withTestConfig(async (testConfigDir) => { await clearTestRemotes(testConfigDir); - + + const workspace = { + name: "return_test", + remote: "http://localhost:8001/", + workspaceId: "test", + token: "token1" + }; + + const result = await addWorkspace(workspace, { force: true, configDir: testConfigDir }); + expect(result).toEqual(true); + }); +}); + +test("addWorkspace: returns true when force-overwriting conflict", async () => { + await withTestConfig(async (testConfigDir) => { + await clearTestRemotes(testConfigDir); + + const workspace1 = { + name: "force_test", + remote: "http://localhost:8001/", + workspaceId: "workspace1", + token: "token1" + }; + await addWorkspace(workspace1, { force: true, configDir: testConfigDir }); + + const workspace2 = { + name: "force_test", + remote: "http://localhost:8002/", + workspaceId: "workspace2", + token: "token2" + }; + const result = await addWorkspace(workspace2, { force: true, configDir: testConfigDir }); + expect(result).toEqual(true); + }); +}); + +test("addWorkspace: allows different workspaces on different remotes", async () => { + await withTestConfig(async (testConfigDir) => { + await clearTestRemotes(testConfigDir); + // Add workspace on first remote const workspace1 = { name: "workspace_remote1", @@ -132,9 +175,9 @@ Deno.test("addWorkspace: allows different workspaces on different remotes", asyn workspaceId: "test", token: "token1" }; - + await addWorkspace(workspace1, { force: true, configDir: testConfigDir }); - + // Add workspace with same workspaceId on different remote (should be allowed) const workspace2 = { name: "workspace_remote2", @@ -142,15 +185,15 @@ Deno.test("addWorkspace: allows different workspaces on different remotes", asyn workspaceId: "test", // Same workspaceId (OK on different remote) token: "token2" }; - + // Should succeed (different remotes) await addWorkspace(workspace2, { configDir: testConfigDir }); - + // Verify both workspaces exist const workspaces = await allWorkspaces(testConfigDir); - assertEquals(workspaces.length, 2); - + expect(workspaces.length).toEqual(2); + const names = workspaces.map(w => w.name).sort(); - assertEquals(names, ["workspace_remote1", "workspace_remote2"]); + expect(names).toEqual(["workspace_remote1", "workspace_remote2"]); }); -}); \ No newline at end of file +}); diff --git a/cli/test/workspace_deps_filter.test.ts b/cli/test/workspace_deps_filter.test.ts index eab576cd0d..c184e9a524 100644 --- a/cli/test/workspace_deps_filter.test.ts +++ b/cli/test/workspace_deps_filter.test.ts @@ -10,11 +10,11 @@ * changing specific deps only marks the expected scripts as stale. */ -import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { expect, test } from "bun:test"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; -import { ensureDir } from "https://deno.land/std@0.224.0/fs/mod.ts"; -import { stringify as stringifyYaml } from "jsr:@std/yaml"; +import { writeFile, mkdir } from "node:fs/promises"; +import { stringify as stringifyYaml } from "yaml"; // Import hash generation utilities from CLI import { generateHash } from "../src/utils/utils.ts"; @@ -45,12 +45,7 @@ function createLockfile(locks: Record): string { // Test 1: Scripts - changing default dep only marks scripts without annotation as stale // ============================================================================= -Deno.test({ - name: "Workspace deps: Scripts - dry-run shows correct stale scripts when default dep changes", - ignore: false, - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Workspace deps: Scripts - dry-run shows correct stale scripts when default dep changes", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -62,20 +57,20 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" -excludes: []`); +excludes: []`, "utf-8"); // Setup dependencies folder (Bun/TypeScript) - await ensureDir(`${tempDir}/dependencies`); + await mkdir(`${tempDir}/dependencies`, { recursive: true }); const defaultDep = `{"dependencies": {"lodash": "4.17.21"}}`; const explicitDep = `{"dependencies": {"axios": "1.6.0"}}`; - await Deno.writeTextFile(`${tempDir}/dependencies/package.json`, defaultDep); - await Deno.writeTextFile(`${tempDir}/dependencies/explicit.package.json`, explicitDep); + await writeFile(`${tempDir}/dependencies/package.json`, defaultDep, "utf-8"); + await writeFile(`${tempDir}/dependencies/explicit.package.json`, explicitDep, "utf-8"); // Setup script folder - await ensureDir(`${tempDir}/f/test`); + await mkdir(`${tempDir}/f/test`, { recursive: true }); // Script 1: No annotation - uses default dep const script1Content = `export async function main() { @@ -88,8 +83,8 @@ schema: properties: {} lock: "" `; - await Deno.writeTextFile(`${tempDir}/f/test/uses_default.ts`, script1Content); - await Deno.writeTextFile(`${tempDir}/f/test/uses_default.script.yaml`, script1Metadata); + await writeFile(`${tempDir}/f/test/uses_default.ts`, script1Content, "utf-8"); + await writeFile(`${tempDir}/f/test/uses_default.script.yaml`, script1Metadata, "utf-8"); // Script 2: Uses explicit dep (TypeScript/Bun with annotation) const script2Content = `// package_json: explicit @@ -103,8 +98,8 @@ schema: properties: {} lock: "" `; - await Deno.writeTextFile(`${tempDir}/f/test/uses_explicit.ts`, script2Content); - await Deno.writeTextFile(`${tempDir}/f/test/uses_explicit.script.yaml`, script2Metadata); + await writeFile(`${tempDir}/f/test/uses_explicit.ts`, script2Content, "utf-8"); + await writeFile(`${tempDir}/f/test/uses_explicit.script.yaml`, script2Metadata, "utf-8"); // Build raw workspace dependencies map (as the CLI would) const rawWorkspaceDeps: Record = { @@ -120,10 +115,10 @@ lock: "" const script2Hash = await generateScriptHash(script2FilteredDeps, script2Content, script2Metadata); // Create initial wmill-lock.yaml with these hashes - await Deno.writeTextFile(`${tempDir}/wmill-lock.yaml`, createLockfile({ + await writeFile(`${tempDir}/wmill-lock.yaml`, createLockfile({ "f/test/uses_default": script1Hash, "f/test/uses_explicit": script2Hash, - })); + }), "utf-8"); // Verify initial state - both scripts should be up-to-date const initialResult = await backend.runCLICommand( @@ -131,13 +126,12 @@ lock: "" tempDir, "workspace_deps_test" ); - assertEquals(initialResult.code, 0, `Initial dry-run should succeed: ${initialResult.stderr}`); - assertStringIncludes(initialResult.stdout, "No metadata to update", - `Initial state should show no updates needed. Output: ${initialResult.stdout}`); + expect(initialResult.code).toEqual(0); + expect(initialResult.stdout).toContain("No metadata to update"); // Now change package.json (default dep) const newDefaultDep = `{"dependencies": {"lodash": "4.17.22"}}`; - await Deno.writeTextFile(`${tempDir}/dependencies/package.json`, newDefaultDep); + await writeFile(`${tempDir}/dependencies/package.json`, newDefaultDep, "utf-8"); // Run dry-run again const afterDefaultChangeResult = await backend.runCLICommand( @@ -145,20 +139,18 @@ lock: "" tempDir, "workspace_deps_test" ); - assertEquals(afterDefaultChangeResult.code, 0, `Dry-run should succeed: ${afterDefaultChangeResult.stderr}`); + expect(afterDefaultChangeResult.code).toEqual(0); // uses_default should be stale (uses default dep which changed) - assertStringIncludes(afterDefaultChangeResult.stdout, "uses_default", - `uses_default should be marked stale after default dep change. Output: ${afterDefaultChangeResult.stdout}`); + expect(afterDefaultChangeResult.stdout).toContain("uses_default"); // uses_explicit should NOT be stale (uses explicit dep, not default) - assert(!afterDefaultChangeResult.stdout.includes("uses_explicit"), - `uses_explicit should NOT be marked stale after default dep change. Output: ${afterDefaultChangeResult.stdout}`); + expect(!afterDefaultChangeResult.stdout.includes("uses_explicit")).toBeTruthy(); // Reset and test the reverse: change explicit dep - await Deno.writeTextFile(`${tempDir}/dependencies/package.json`, defaultDep); // restore original + await writeFile(`${tempDir}/dependencies/package.json`, defaultDep, "utf-8"); // restore original const newExplicitDep = `{"dependencies": {"axios": "1.6.1"}}`; - await Deno.writeTextFile(`${tempDir}/dependencies/explicit.package.json`, newExplicitDep); + await writeFile(`${tempDir}/dependencies/explicit.package.json`, newExplicitDep, "utf-8"); // Run dry-run again const afterExplicitChangeResult = await backend.runCLICommand( @@ -166,29 +158,21 @@ lock: "" tempDir, "workspace_deps_test" ); - assertEquals(afterExplicitChangeResult.code, 0, `Dry-run should succeed: ${afterExplicitChangeResult.stderr}`); + expect(afterExplicitChangeResult.code).toEqual(0); // uses_explicit should be stale (uses explicit dep which changed) - assertStringIncludes(afterExplicitChangeResult.stdout, "uses_explicit", - `uses_explicit should be marked stale after explicit dep change. Output: ${afterExplicitChangeResult.stdout}`); + expect(afterExplicitChangeResult.stdout).toContain("uses_explicit"); // uses_default should NOT be stale (uses default dep, not explicit) - assert(!afterExplicitChangeResult.stdout.includes("uses_default"), - `uses_default should NOT be marked stale after explicit dep change. Output: ${afterExplicitChangeResult.stdout}`); + expect(!afterExplicitChangeResult.stdout.includes("uses_default")).toBeTruthy(); }); - }, -}); + }); // ============================================================================= // Test 2: Flows - filterWorkspaceDependenciesForScripts correctly filters by annotation // ============================================================================= -Deno.test({ - name: "Workspace deps: Flows - filterWorkspaceDependenciesForScripts correctly filters inline scripts", - ignore: false, - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Workspace deps: Flows - filterWorkspaceDependenciesForScripts correctly filters inline scripts", async () => { // This test verifies the filtering logic used by flows without needing workers // We test filterWorkspaceDependenciesForScripts directly since flow generate-locks // doesn't have a --dry-run option @@ -216,21 +200,15 @@ export async function main() { // Filter for default script - should only include default dep const defaultFiltered = filterWorkspaceDependencies(rawWorkspaceDeps, defaultScriptContent, "bun"); - assertEquals(Object.keys(defaultFiltered).length, 1, - `Default script should have 1 filtered dep, got: ${JSON.stringify(defaultFiltered)}`); - assert("dependencies/package.json" in defaultFiltered, - `Default script should have package.json`); - assert(!("dependencies/explicit.package.json" in defaultFiltered), - `Default script should NOT have explicit.package.json`); + expect(Object.keys(defaultFiltered).length).toEqual(1); + expect("dependencies/package.json" in defaultFiltered).toBeTruthy(); + expect(!("dependencies/explicit.package.json" in defaultFiltered)).toBeTruthy(); // Filter for explicit script - should only include explicit dep const explicitFiltered = filterWorkspaceDependencies(rawWorkspaceDeps, explicitScriptContent, "bun"); - assertEquals(Object.keys(explicitFiltered).length, 1, - `Explicit script should have 1 filtered dep, got: ${JSON.stringify(explicitFiltered)}`); - assert("dependencies/explicit.package.json" in explicitFiltered, - `Explicit script should have explicit.package.json`); - assert(!("dependencies/package.json" in explicitFiltered), - `Explicit script should NOT have package.json`); + expect(Object.keys(explicitFiltered).length).toEqual(1); + expect("dependencies/explicit.package.json" in explicitFiltered).toBeTruthy(); + expect(!("dependencies/package.json" in explicitFiltered)).toBeTruthy(); // Verify hashes change correctly when deps change const defaultHash1 = await generateScriptHash(defaultFiltered, defaultScriptContent, "metadata"); @@ -250,12 +228,10 @@ export async function main() { const explicitHash2 = await generateScriptHash(explicitFiltered2, explicitScriptContent, "metadata"); // Default script hash should change (its dep changed) - assert(defaultHash1 !== defaultHash2, - `Default script hash should change when default dep changes`); + expect(defaultHash1 !== defaultHash2).toBeTruthy(); // Explicit script hash should NOT change (its dep didn't change) - assertEquals(explicitHash1, explicitHash2, - `Explicit script hash should NOT change when default dep changes`); + expect(explicitHash1).toEqual(explicitHash2); // Now change explicit dep const newExplicitDep = `{"dependencies": {"axios": "1.6.1"}}`; @@ -271,25 +247,17 @@ export async function main() { const explicitHash3 = await generateScriptHash(explicitFiltered3, explicitScriptContent, "metadata"); // Default script hash should be back to original (dep is back to original) - assertEquals(defaultHash1, defaultHash3, - `Default script hash should be same as original when dep reverts`); + expect(defaultHash1).toEqual(defaultHash3); // Explicit script hash should change (its dep changed) - assert(explicitHash1 !== explicitHash3, - `Explicit script hash should change when explicit dep changes`); - }, -}); + expect(explicitHash1 !== explicitHash3).toBeTruthy(); + }); // ============================================================================= // Test 3: Cross-language isolation - Python dep change doesn't affect Bun script // ============================================================================= -Deno.test({ - name: "Workspace deps: Cross-language - Python dep change doesn't affect Bun script", - ignore: false, - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Workspace deps: Cross-language - Python dep change doesn't affect Bun script", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -301,20 +269,20 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" -excludes: []`); +excludes: []`, "utf-8"); // Setup dependencies folder with deps for multiple languages - await ensureDir(`${tempDir}/dependencies`); + await mkdir(`${tempDir}/dependencies`, { recursive: true }); const pythonDep = "requests==2.31.0"; const bunDep = `{"dependencies": {"lodash": "4.17.21"}}`; - await Deno.writeTextFile(`${tempDir}/dependencies/requirements.in`, pythonDep); - await Deno.writeTextFile(`${tempDir}/dependencies/package.json`, bunDep); + await writeFile(`${tempDir}/dependencies/requirements.in`, pythonDep, "utf-8"); + await writeFile(`${tempDir}/dependencies/package.json`, bunDep, "utf-8"); // Setup script folder - await ensureDir(`${tempDir}/f/test`); + await mkdir(`${tempDir}/f/test`, { recursive: true }); // Python script const pythonContent = `def main(): @@ -326,8 +294,8 @@ schema: properties: {} lock: "" `; - await Deno.writeTextFile(`${tempDir}/f/test/python_script.py`, pythonContent); - await Deno.writeTextFile(`${tempDir}/f/test/python_script.script.yaml`, pythonMetadata); + await writeFile(`${tempDir}/f/test/python_script.py`, pythonContent, "utf-8"); + await writeFile(`${tempDir}/f/test/python_script.script.yaml`, pythonMetadata, "utf-8"); // Bun script const bunContent = `export async function main() { @@ -340,8 +308,8 @@ schema: properties: {} lock: "" `; - await Deno.writeTextFile(`${tempDir}/f/test/bun_script.ts`, bunContent); - await Deno.writeTextFile(`${tempDir}/f/test/bun_script.script.yaml`, bunMetadata); + await writeFile(`${tempDir}/f/test/bun_script.ts`, bunContent, "utf-8"); + await writeFile(`${tempDir}/f/test/bun_script.script.yaml`, bunMetadata, "utf-8"); // Build raw workspace dependencies map const rawWorkspaceDeps: Record = { @@ -354,26 +322,22 @@ lock: "" const bunFilteredDeps = filterWorkspaceDependencies(rawWorkspaceDeps, bunContent, "bun"); // Python script should only get requirements.in - assertEquals(Object.keys(pythonFilteredDeps).length, 1, - `Python script should only have 1 filtered dep, got: ${JSON.stringify(pythonFilteredDeps)}`); - assert("dependencies/requirements.in" in pythonFilteredDeps, - `Python script should have requirements.in in filtered deps`); + expect(Object.keys(pythonFilteredDeps).length).toEqual(1); + expect("dependencies/requirements.in" in pythonFilteredDeps).toBeTruthy(); // Bun script should only get package.json - assertEquals(Object.keys(bunFilteredDeps).length, 1, - `Bun script should only have 1 filtered dep, got: ${JSON.stringify(bunFilteredDeps)}`); - assert("dependencies/package.json" in bunFilteredDeps, - `Bun script should have package.json in filtered deps`); + expect(Object.keys(bunFilteredDeps).length).toEqual(1); + expect("dependencies/package.json" in bunFilteredDeps).toBeTruthy(); // Compute initial hashes const pythonHash = await generateScriptHash(pythonFilteredDeps, pythonContent, pythonMetadata); const bunHash = await generateScriptHash(bunFilteredDeps, bunContent, bunMetadata); // Create initial wmill-lock.yaml - await Deno.writeTextFile(`${tempDir}/wmill-lock.yaml`, createLockfile({ + await writeFile(`${tempDir}/wmill-lock.yaml`, createLockfile({ "f/test/python_script": pythonHash, "f/test/bun_script": bunHash, - })); + }), "utf-8"); // Verify initial state - both scripts should be up-to-date const initialResult = await backend.runCLICommand( @@ -381,12 +345,11 @@ lock: "" tempDir, "workspace_deps_cross_lang_test" ); - assertEquals(initialResult.code, 0, `Initial dry-run should succeed: ${initialResult.stderr}`); - assertStringIncludes(initialResult.stdout, "No metadata to update", - `Initial state should show no updates needed. Output: ${initialResult.stdout}`); + expect(initialResult.code).toEqual(0); + expect(initialResult.stdout).toContain("No metadata to update"); // Change Python dep (requirements.in) - await Deno.writeTextFile(`${tempDir}/dependencies/requirements.in`, "requests==2.32.0"); + await writeFile(`${tempDir}/dependencies/requirements.in`, "requests==2.32.0", "utf-8"); // Run dry-run const afterPythonChangeResult = await backend.runCLICommand( @@ -394,48 +357,38 @@ lock: "" tempDir, "workspace_deps_cross_lang_test" ); - assertEquals(afterPythonChangeResult.code, 0, `Dry-run should succeed: ${afterPythonChangeResult.stderr}`); + expect(afterPythonChangeResult.code).toEqual(0); // python_script should be stale - assertStringIncludes(afterPythonChangeResult.stdout, "python_script", - `python_script should be marked stale after Python dep change. Output: ${afterPythonChangeResult.stdout}`); + expect(afterPythonChangeResult.stdout).toContain("python_script"); // bun_script should NOT be stale (different language) - assert(!afterPythonChangeResult.stdout.includes("bun_script"), - `bun_script should NOT be marked stale after Python dep change. Output: ${afterPythonChangeResult.stdout}`); + expect(!afterPythonChangeResult.stdout.includes("bun_script")).toBeTruthy(); // Reset and test the reverse - await Deno.writeTextFile(`${tempDir}/dependencies/requirements.in`, pythonDep); - await Deno.writeTextFile(`${tempDir}/dependencies/package.json`, `{"dependencies": {"lodash": "4.17.22"}}`); + await writeFile(`${tempDir}/dependencies/requirements.in`, pythonDep, "utf-8"); + await writeFile(`${tempDir}/dependencies/package.json`, `{"dependencies": {"lodash": "4.17.22"}}`, "utf-8"); const afterBunChangeResult = await backend.runCLICommand( ["script", "generate-metadata", "-i", "f/test/python_script*,f/test/bun_script*", "--yes", "--dry-run"], tempDir, "workspace_deps_cross_lang_test" ); - assertEquals(afterBunChangeResult.code, 0, `Dry-run should succeed: ${afterBunChangeResult.stderr}`); + expect(afterBunChangeResult.code).toEqual(0); // bun_script should be stale - assertStringIncludes(afterBunChangeResult.stdout, "bun_script", - `bun_script should be marked stale after Bun dep change. Output: ${afterBunChangeResult.stdout}`); + expect(afterBunChangeResult.stdout).toContain("bun_script"); // python_script should NOT be stale (different language) - assert(!afterBunChangeResult.stdout.includes("python_script"), - `python_script should NOT be marked stale after Bun dep change. Output: ${afterBunChangeResult.stdout}`); + expect(!afterBunChangeResult.stdout.includes("python_script")).toBeTruthy(); }); - }, -}); + }); // ============================================================================= // Test 4: Apps - Create app via API and test filterWorkspaceDependenciesForApp // ============================================================================= -Deno.test({ - name: "Workspace deps: Apps - filterWorkspaceDependenciesForApp with real app via API", - ignore: false, - sanitizeResources: false, - sanitizeOps: false, - fn: async () => { +test("Workspace deps: Apps - filterWorkspaceDependenciesForApp with real app via API", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace const testWorkspace = { @@ -447,10 +400,10 @@ Deno.test({ await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); // Create wmill.yaml - await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun includes: - "**" -excludes: []`); +excludes: []`, "utf-8"); // Create app with multiple inline scripts via backend API const appPath = "f/test/multi_script_app"; @@ -530,7 +483,7 @@ excludes: []`); }), } ); - assertEquals(createResponse.ok, true, `Failed to create app: ${await createResponse.text()}`); + expect(createResponse.ok).toEqual(true); // Pull the app to disk const pullResult = await backend.runCLICommand( @@ -538,19 +491,19 @@ excludes: []`); tempDir, "workspace_deps_app_test" ); - assertEquals(pullResult.code, 0, `Sync pull should succeed: ${pullResult.stderr}`); + expect(pullResult.code).toEqual(0); // Setup workspace dependencies - await ensureDir(`${tempDir}/dependencies`); + await mkdir(`${tempDir}/dependencies`, { recursive: true }); const defaultBunDep = `{"dependencies": {"lodash": "4.17.21"}}`; const explicitBunDep = `{"dependencies": {"axios": "1.6.0"}}`; const pythonDep = "requests==2.31.0"; - await Deno.writeTextFile(`${tempDir}/dependencies/package.json`, defaultBunDep); - await Deno.writeTextFile(`${tempDir}/dependencies/explicit.package.json`, explicitBunDep); - await Deno.writeTextFile(`${tempDir}/dependencies/requirements.in`, pythonDep); + await writeFile(`${tempDir}/dependencies/package.json`, defaultBunDep, "utf-8"); + await writeFile(`${tempDir}/dependencies/explicit.package.json`, explicitBunDep, "utf-8"); + await writeFile(`${tempDir}/dependencies/requirements.in`, pythonDep, "utf-8"); // Read the pulled app.yaml - const { yamlParseFile } = await import("../deps.ts"); + const { yamlParseFile } = await import("../src/utils/yaml.ts"); const appFilePath = `${tempDir}/${appPath}.app/app.yaml`; const appFile = await yamlParseFile(appFilePath); @@ -568,14 +521,10 @@ excludes: []`); ); // Verify all 3 dep types are included - assertEquals(Object.keys(filteredDeps).length, 3, - `App with bun (default), bun (explicit), and python should have 3 filtered deps, got: ${JSON.stringify(filteredDeps)}`); - assert("dependencies/package.json" in filteredDeps, - `Should include default package.json for default bun script`); - assert("dependencies/explicit.package.json" in filteredDeps, - `Should include explicit.package.json for annotated bun script`); - assert("dependencies/requirements.in" in filteredDeps, - `Should include requirements.in for python script`); + expect(Object.keys(filteredDeps).length).toEqual(3); + expect("dependencies/package.json" in filteredDeps).toBeTruthy(); + expect("dependencies/explicit.package.json" in filteredDeps).toBeTruthy(); + expect("dependencies/requirements.in" in filteredDeps).toBeTruthy(); // Verify hash changes when deps change const hash1 = await generateHash(JSON.stringify(filteredDeps)); @@ -594,7 +543,6 @@ excludes: []`); ); const hash2 = await generateHash(JSON.stringify(filteredDeps2)); - assert(hash1 !== hash2, `Hash should change when filtered deps change`); + expect(hash1 !== hash2).toBeTruthy(); }); - }, -}); + }); diff --git a/cli/test_completions4.ts b/cli/test_completions4.ts new file mode 100644 index 0000000000..77f60959f2 --- /dev/null +++ b/cli/test_completions4.ts @@ -0,0 +1,13 @@ +import { Command } from "@cliffy/command"; +import { ZshCompletionsGenerator } from "@cliffy/command/completions/_zsh_completions_generator"; +const { default: command } = await import("./src/main.ts"); + +// Generate completions manually +const output = ZshCompletionsGenerator.generate("wmill", command); +console.error(`output length: ${output.length}`); + +// Write using process.stdout.write with callback +process.stdout.write(output + "\n", () => { + console.error("write callback called"); + process.stdin.destroy(); +}); diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000000..60ea163be9 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src/**/*", "gen/**/*"], + "exclude": ["node_modules", "dist", "npm", "test"] +} diff --git a/cli/wasm/csharp/windmill_parser_wasm.js b/cli/wasm/csharp/windmill_parser_wasm.js index 5f0003a020..47e8bc20d6 100644 --- a/cli/wasm/csharp/windmill_parser_wasm.js +++ b/cli/wasm/csharp/windmill_parser_wasm.js @@ -103,7 +103,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/go/windmill_parser_wasm.js b/cli/wasm/go/windmill_parser_wasm.js index ce2eb507ea..7e49d1d2ab 100644 --- a/cli/wasm/go/windmill_parser_wasm.js +++ b/cli/wasm/go/windmill_parser_wasm.js @@ -103,7 +103,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/java/windmill_parser_wasm.js b/cli/wasm/java/windmill_parser_wasm.js index 8dd745d243..c06c35d64f 100644 --- a/cli/wasm/java/windmill_parser_wasm.js +++ b/cli/wasm/java/windmill_parser_wasm.js @@ -103,7 +103,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/nu/windmill_parser_wasm.js b/cli/wasm/nu/windmill_parser_wasm.js index 2f21f260da..66c2800995 100644 --- a/cli/wasm/nu/windmill_parser_wasm.js +++ b/cli/wasm/nu/windmill_parser_wasm.js @@ -107,7 +107,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/php/windmill_parser_wasm.js b/cli/wasm/php/windmill_parser_wasm.js index e95e3a5126..a73d2f8f59 100644 --- a/cli/wasm/php/windmill_parser_wasm.js +++ b/cli/wasm/php/windmill_parser_wasm.js @@ -114,7 +114,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/py/windmill_parser_wasm.js b/cli/wasm/py/windmill_parser_wasm.js index 18c5dffdaa..c940f42556 100644 --- a/cli/wasm/py/windmill_parser_wasm.js +++ b/cli/wasm/py/windmill_parser_wasm.js @@ -133,7 +133,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/python/windmill_parser_wasm.js b/cli/wasm/python/windmill_parser_wasm.js index 4eb09bc044..ebf0bdb18d 100644 --- a/cli/wasm/python/windmill_parser_wasm.js +++ b/cli/wasm/python/windmill_parser_wasm.js @@ -129,7 +129,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/regex/windmill_parser_wasm.js b/cli/wasm/regex/windmill_parser_wasm.js index 580d3b0334..62c5c662a9 100644 --- a/cli/wasm/regex/windmill_parser_wasm.js +++ b/cli/wasm/regex/windmill_parser_wasm.js @@ -313,7 +313,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/ruby/windmill_parser_wasm.js b/cli/wasm/ruby/windmill_parser_wasm.js index 2c44b756b6..ad9cd842cd 100644 --- a/cli/wasm/ruby/windmill_parser_wasm.js +++ b/cli/wasm/ruby/windmill_parser_wasm.js @@ -103,7 +103,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/rust/windmill_parser_wasm.js b/cli/wasm/rust/windmill_parser_wasm.js index 6639224cf5..7c2fdd6584 100644 --- a/cli/wasm/rust/windmill_parser_wasm.js +++ b/cli/wasm/rust/windmill_parser_wasm.js @@ -100,7 +100,13 @@ const imports = { }; const wasmUrl = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); -const wasm = (await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports)).instance.exports; +let wasmCode; +if (wasmUrl.protocol === 'file:') { + wasmCode = (await import('node:fs')).readFileSync(wasmUrl); +} else { + wasmCode = await (await fetch(wasmUrl)).arrayBuffer(); +} +const wasm = (await WebAssembly.instantiate(wasmCode, imports)).instance.exports; export { wasm as __wasm }; wasm.__wbindgen_start(); diff --git a/cli/wasm/ts/windmill_parser_wasm.js b/cli/wasm/ts/windmill_parser_wasm.js index 20b7073aac..ba7dcb8de2 100644 --- a/cli/wasm/ts/windmill_parser_wasm.js +++ b/cli/wasm/ts/windmill_parser_wasm.js @@ -432,7 +432,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/wasm/yaml/windmill_parser_wasm.js b/cli/wasm/yaml/windmill_parser_wasm.js index 909de8a6e8..5b61d05523 100644 --- a/cli/wasm/yaml/windmill_parser_wasm.js +++ b/cli/wasm/yaml/windmill_parser_wasm.js @@ -103,7 +103,7 @@ const wasm_url = new URL('windmill_parser_wasm_bg.wasm', import.meta.url); let wasmCode = ''; switch (wasm_url.protocol) { case 'file:': - wasmCode = await Deno.readFile(wasm_url); + wasmCode = (await import('node:fs')).readFileSync(wasm_url); break case 'https:': case 'http:': diff --git a/cli/windmill-utils-internal/remove-ts-ext.sh b/cli/windmill-utils-internal/remove-ts-ext.sh index 8b5390b73e..b69eb20009 100755 --- a/cli/windmill-utils-internal/remove-ts-ext.sh +++ b/cli/windmill-utils-internal/remove-ts-ext.sh @@ -24,7 +24,8 @@ done if [[ "$RESTORE_MODE" == true ]]; then echo "Adding .ts extensions to imports..." # Only add .ts if the path doesn't already end with .ts or / - REGEX='/\.ts["'\'']/! s/(from|import)[[:space:]]+["'\'']([^"'\'']*[^/])(["'\''])/\1 "\2.ts\3/g' + # Also skip node: built-in module imports + REGEX='/\.ts["'\'']/! { /["'\''"]node:/! s/(from|import)[[:space:]]+["'\'']([^"'\'']*[^/])(["'\''])/\1 "\2.ts\3/g; }' SUCCESS_MSG="✓ All .ts extensions added to import/export statements" else echo "Removing .ts extensions from imports..." diff --git a/cli/windmill-utils-internal/src/config/config.ts b/cli/windmill-utils-internal/src/config/config.ts index d2431e3861..0641607efa 100644 --- a/cli/windmill-utils-internal/src/config/config.ts +++ b/cli/windmill-utils-internal/src/config/config.ts @@ -1,8 +1,4 @@ -// Runtime detection -// @ts-ignore - Cross-platform runtime detection -const isDeno = typeof Deno !== "undefined"; -// @ts-ignore - Cross-platform runtime detection -const isNode = typeof process !== "undefined" && process.versions?.node; +import { stat, mkdir } from "node:fs/promises"; export const WINDMILL_CONFIG_DIR = "windmill"; export const WINDMILL_ACTIVE_WORKSPACE_FILE = "activeWorkspace"; @@ -10,60 +6,22 @@ export const WINDMILL_WORKSPACE_CONFIG_FILE = "remotes.ndjson"; export const INSTANCES_CONFIG_FILE = "instances.ndjson"; export const WINDMILL_ACTIVE_INSTANCE_FILE = "activeInstance"; -// Cross-platform environment variable access function getEnv(key: string): string | undefined { - if (isDeno) { - // @ts-ignore - Deno API - return Deno.env.get(key); - } else { - // @ts-ignore - Node API - return process.env[key]; - } + return process.env[key]; } -// Cross-platform OS detection with normalization function getOS(): "linux" | "darwin" | "windows" | null { - if (isDeno) { - // @ts-ignore - Deno API - return Deno.build.os as "linux" | "darwin" | "windows"; - } else if (isNode) { - // @ts-ignore - Node API - const platform = process.platform; - switch (platform) { - case "linux": return "linux"; - case "darwin": return "darwin"; - case "win32": return "windows"; // Normalize win32 to windows - default: return null; - } - } - return null; -} - -// Cross-platform file system operations -async function stat(path: string | URL): Promise { - if (isDeno) { - // @ts-ignore - Deno API - return await Deno.stat(path); - } else { - // @ts-ignore - Node API - const fs = await import('fs/promises'); - return await fs.stat(path); + const platform = process.platform; + switch (platform) { + case "linux": return "linux"; + case "darwin": return "darwin"; + case "win32": return "windows"; + default: return null; } } -async function mkdir(path: string | URL, options?: { recursive?: boolean }): Promise { - if (isDeno) { - // @ts-ignore - Deno API - await Deno.mkdir(path, options); - } else { - // @ts-ignore - Node API - const fs = await import('fs/promises'); - await fs.mkdir(path, options); - } -} - -function throwIfNotDirectory(fileInfo: any): void { - if (!fileInfo.isDirectory) { +function throwIfNotDirectory(fileInfo: import("node:fs").Stats): void { + if (!fileInfo.isDirectory()) { throw new Error("Path is not a directory"); } } @@ -125,17 +83,8 @@ async function ensureDir(dir: string | URL) { throwIfNotDirectory(fileInfo); return; } catch (err: any) { - // Check for file not found error in cross-platform way - if (isDeno) { - // @ts-ignore - Deno API - if (!(err instanceof Deno.errors.NotFound)) { - throw err; - } - } else { - // Node.js error codes - if (err.code !== 'ENOENT') { - throw err; - } + if (err.code !== 'ENOENT') { + throw err; } } @@ -144,17 +93,8 @@ async function ensureDir(dir: string | URL) { try { await mkdir(dir, { recursive: true }); } catch (err: any) { - // Check for already exists error in cross-platform way - if (isDeno) { - // @ts-ignore - Deno API - if (!(err instanceof Deno.errors.AlreadyExists)) { - throw err; - } - } else { - // Node.js error codes - if (err.code !== 'EEXIST') { - throw err; - } + if (err.code !== 'EEXIST') { + throw err; } const fileInfo = await stat(dir); @@ -163,10 +103,10 @@ async function ensureDir(dir: string | URL) { } export async function getBaseConfigDir(configDirOverride?: string): Promise { - const baseDir = configDirOverride ?? - getEnv("WMILL_CONFIG_DIR") ?? - config_dir() ?? - tmp_dir() ?? + const baseDir = configDirOverride ?? + getEnv("WMILL_CONFIG_DIR") ?? + config_dir() ?? + tmp_dir() ?? "/tmp/"; return baseDir; } @@ -196,4 +136,4 @@ export async function getInstancesConfigFilePath(configDirOverride?: string): Pr export async function getActiveInstanceFilePath(configDirOverride?: string): Promise { const configDir = await getConfigDirPath(configDirOverride); return `${configDir}/${WINDMILL_ACTIVE_INSTANCE_FILE}`; -} \ No newline at end of file +} diff --git a/cli/windmill-utils-internal/src/parse/parse-schema.ts b/cli/windmill-utils-internal/src/parse/parse-schema.ts index 1887ef2d40..6e94c0ff77 100644 --- a/cli/windmill-utils-internal/src/parse/parse-schema.ts +++ b/cli/windmill-utils-internal/src/parse/parse-schema.ts @@ -228,7 +228,7 @@ export function argSigToJsonSchemaType( if (oldS.items && typeof oldS.items === "object") { ITEMS_PRESERVED_FIELDS.forEach((field) => { if (oldS.items && oldS.items[field] !== undefined) { - newS.items![field] = oldS.items[field]; + (newS.items as any)[field] = oldS.items[field]; } }); } @@ -241,7 +241,7 @@ export function argSigToJsonSchemaType( if (oldS.items && typeof oldS.items === "object") { ITEMS_PRESERVED_FIELDS.forEach((field) => { if (oldS.items && oldS.items[field] !== undefined) { - newS.items![field] = oldS.items[field]; + (newS.items as any)[field] = oldS.items[field]; } }); } diff --git a/docker/DockerfileCli b/docker/DockerfileCli index a3e5d80cb2..546ce49924 100644 --- a/docker/DockerfileCli +++ b/docker/DockerfileCli @@ -1,5 +1,7 @@ -FROM node:slim +FROM oven/bun:slim -RUN npm install -g windmill-cli +RUN bun install -g windmill-cli -ENTRYPOINT [ "wmill" ] \ No newline at end of file +RUN ln -s $(bun pm bin -g)/wmill /usr/bin/wmill + +ENTRYPOINT [ "wmill" ] diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 155746cd64..d20067e862 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -56,6 +56,11 @@ RUN mkdir -p /tmp/windmill/cache && \ COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun +# Install windmill CLI (node symlink needed for bun install) +RUN ln -s /usr/bin/bun /usr/bin/node \ + && bun install -g windmill-cli \ + && ln -s $(bun pm bin -g)/wmill /usr/bin/wmill + # add the docker client to call docker from a worker if enabled COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index 53fc9ae51d..8510ce59ad 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -56,6 +56,11 @@ RUN mkdir -p /tmp/windmill/cache && \ COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun +# Install windmill CLI (node symlink needed for bun install) +RUN ln -s /usr/bin/bun /usr/bin/node \ + && bun install -g windmill-cli \ + && ln -s $(bun pm bin -g)/wmill /usr/bin/wmill + # add the docker client to call docker from a worker if enabled COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ diff --git a/docker/RHEL8/Dockerfile b/docker/RHEL8/Dockerfile index f18d35ccf4..6b7cbfa997 100644 --- a/docker/RHEL8/Dockerfile +++ b/docker/RHEL8/Dockerfile @@ -16,8 +16,6 @@ RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install cargo-chef --version ^0.1 WORKDIR /windmill ENV SQLX_OFFLINE=true -ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=cc -ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="" # ENV CARGO_INCREMENTAL=1 FROM node:20-alpine as frontend @@ -25,6 +23,7 @@ FROM node:20-alpine as frontend # install dependencies WORKDIR /frontend COPY ./frontend/package.json ./frontend/package-lock.json ./ +COPY ./frontend/scripts/ ./scripts/ RUN npm ci # Copy all local files into the image. @@ -38,9 +37,10 @@ COPY /system_prompts/auto-generated /system_prompts/auto-generated RUN cd /backend/windmill-api && . ./build_openapi.sh COPY /backend/parsers/windmill-parser-wasm/pkg/ /backend/parsers/windmill-parser-wasm/pkg/ COPY /typescript-client/docs/ /frontend/static/tsdocs/ +COPY /python-client/docs/ /frontend/static/pydocs/ RUN npm run generate-backend-client -ENV NODE_OPTIONS "--max-old-space-size=10240" +ENV NODE_OPTIONS "--max-old-space-size=8192" RUN npm run build @@ -48,6 +48,7 @@ FROM rust_base AS planner COPY ./openflow.openapi.yaml /openflow.openapi.yaml COPY ./backend ./ +RUN rm -f .cargo/config.toml RUN --mount=type=cache,target=/usr/local/cargo/registry \ CARGO_NET_GIT_FETCH_WITH_CLI=true cargo chef prepare --recipe-path recipe.json @@ -72,6 +73,9 @@ RUN yum update -y && \ COPY ./openflow.openapi.yaml /openflow.openapi.yaml COPY ./backend ./ +# Remove .cargo/config.toml which configures the mold linker (not available on RHEL) +RUN rm -f .cargo/config.toml + COPY --from=frontend /frontend /frontend COPY --from=frontend /backend/windmill-api/openapi-deref.yaml ./windmill-api/openapi-deref.yaml COPY .git/ .git/ diff --git a/docker/RHEL9/Dockerfile b/docker/RHEL9/Dockerfile index a7048d1797..fdbb6b3799 100644 --- a/docker/RHEL9/Dockerfile +++ b/docker/RHEL9/Dockerfile @@ -16,8 +16,6 @@ RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install cargo-chef --version ^0.1 WORKDIR /windmill ENV SQLX_OFFLINE=true -ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=cc -ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="" # ENV CARGO_INCREMENTAL=1 FROM node:20-alpine as frontend @@ -25,6 +23,7 @@ FROM node:20-alpine as frontend # install dependencies WORKDIR /frontend COPY ./frontend/package.json ./frontend/package-lock.json ./ +COPY ./frontend/scripts/ ./scripts/ RUN npm ci # Copy all local files into the image. @@ -38,9 +37,10 @@ COPY /system_prompts/auto-generated /system_prompts/auto-generated RUN cd /backend/windmill-api && . ./build_openapi.sh COPY /backend/parsers/windmill-parser-wasm/pkg/ /backend/parsers/windmill-parser-wasm/pkg/ COPY /typescript-client/docs/ /frontend/static/tsdocs/ +COPY /python-client/docs/ /frontend/static/pydocs/ RUN npm run generate-backend-client -ENV NODE_OPTIONS "--max-old-space-size=10240" +ENV NODE_OPTIONS "--max-old-space-size=8192" RUN npm run build @@ -48,6 +48,7 @@ FROM rust_base AS planner COPY ./openflow.openapi.yaml /openflow.openapi.yaml COPY ./backend ./ +RUN rm -f .cargo/config.toml RUN --mount=type=cache,target=/usr/local/cargo/registry \ CARGO_NET_GIT_FETCH_WITH_CLI=true cargo chef prepare --recipe-path recipe.json @@ -72,6 +73,9 @@ RUN yum update -y && \ COPY ./openflow.openapi.yaml /openflow.openapi.yaml COPY ./backend ./ +# Remove .cargo/config.toml which configures the mold linker (not available on RHEL) +RUN rm -f .cargo/config.toml + COPY --from=frontend /frontend /frontend COPY --from=frontend /backend/windmill-api/openapi-deref.yaml ./windmill-api/openapi-deref.yaml COPY .git/ .git/ diff --git a/frontend/.gitignore b/frontend/.gitignore index 9795c44865..972b0f540f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -25,4 +25,6 @@ src/lib/components/copilot/chat/__tests__/app/results/ /playwright/.cache/ /playwright/.auth/ e2e/auth.json -e2e/*.png \ No newline at end of file +e2e/*.png + +.fast-check/ \ No newline at end of file diff --git a/frontend/.workmux.yaml b/frontend/.workmux.yaml new file mode 100644 index 0000000000..5806617069 --- /dev/null +++ b/frontend/.workmux.yaml @@ -0,0 +1,15 @@ +panes: + # Pane 1: Install dependencies, then start dev server + - command: npm install && npm run generate-backend-client && npm run dev + + # Pane 2: AI agent + - command: + split: horizontal + focus: true + +files: + copy: + - .env + + symlink: + - node_modules diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 35fa075087..4ce27aa1ae 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -97,6 +97,11 @@ The `resource()` utility: After making frontend changes, you MUST run the following and fix all errors and warnings before considering the work done: +```bash +npm run check:fast +``` + +At the end of a PR to do final validation, you can do the longer one (2s for fast vs 50s for the slow one): ```bash npm run check ``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e9e8f3caf..4aac239774 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.638.4", + "version": "1.642.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.638.4", + "version": "1.642.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -80,7 +80,7 @@ "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.574.1", "windmill-parser-wasm-py": "^1.628.3", - "windmill-parser-wasm-regex": "1.625.0", + "windmill-parser-wasm-regex": "1.639.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.558.1", "windmill-parser-wasm-ts": "1.623.1", @@ -134,6 +134,7 @@ "svelte": "^5.38.0", "svelte-awesome-color-picker": "^3.0.4", "svelte-check": "^4.0.0", + "svelte-fast-check": "^0.4.5", "svelte-floating-ui": "^1.5.8", "svelte-highlight": "^7.6.0", "svelte-popperjs": "^1.3.2", @@ -834,6 +835,7 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -845,6 +847,7 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -855,6 +858,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1344,6 +1348,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1498,6 +1503,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1514,6 +1520,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1530,6 +1537,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1546,6 +1554,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1562,6 +1571,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1578,6 +1588,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1594,6 +1605,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1610,6 +1622,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1626,6 +1639,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1642,6 +1656,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1658,6 +1673,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1674,6 +1690,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1690,6 +1707,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2145,21 +2163,6 @@ "svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1" } }, - "node_modules/@sveltejs/package/node_modules/svelte2tsx": { - "version": "0.7.45", - "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.45.tgz", - "integrity": "sha512-cSci+mYGygYBHIZLHlm/jYlEc1acjAHqaQaDFHdEBpUueM9kSTnPpvPtSl5VkJOU1qSJ7h1K+6F/LIUYiqC8VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "dedent-js": "^1.0.1", - "scule": "^1.3.0" - }, - "peerDependencies": { - "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", - "typescript": "^4.9.4 || ^5.0.0" - } - }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz", @@ -2310,6 +2313,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2931,6 +2935,123 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20260218.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260218.1.tgz", + "integrity": "sha512-hbGRXBk7abFvOQJk/7mc8K9q1kPkiyziyUsS8r8Hc1sLxrDFUbGgsW9p8qg67Xe1K6NUv/9UU2cdeIitUDexIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo.js" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260218.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260218.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260218.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260218.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260218.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260218.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260218.1" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20260218.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260218.1.tgz", + "integrity": "sha512-ybxez4ClJU12TUvX/IxGPIQfS26+Zia7kbB1L4RH+G8yzYg90RPt4njfJkU2WxP70Hp59zS2copPkaBz5gUJkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20260218.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260218.1.tgz", + "integrity": "sha512-n9Ki8WTW82w6PlBTlrAQAjEUQB2V7C2oXrkN5U7ElwUH4FOostSFzZHuAdnPMbdzMx76P0pEw9FteYrLDA4m9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20260218.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260218.1.tgz", + "integrity": "sha512-WRPMvTztPatQ91UzYWSp82NT45JmjMgo/pVgZjXYEWdF2rwS4ejzR6DnHq30jXhEPnMah1bTeOzSWFF2kvXUmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20260218.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260218.1.tgz", + "integrity": "sha512-Osus82LSlwi1l3LoxLWKDuxh5E8JyWwkseBjr2n+TMaTuDPcRSzT8Jr4ywIp3NJpCUUV/LzR84i64jA6g8iVIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20260218.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260218.1.tgz", + "integrity": "sha512-jcDhKCvhWQyMbra4MiqSgyUoSdM9mAiSkIdc80qScpk03aZOU+BZEmHz51S+fEn+8KRWuMuIHXM3sG3oX/EJZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20260218.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260218.1.tgz", + "integrity": "sha512-VmWvJ+TEuTPmZrhWe+buvvUvHbMyiD4ZLgxYPdYcJ3kRQlk2mD5lOq63ZISx1pDB8kYz5/R5xYKy/8gSIU5MgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20260218.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260218.1.tgz", + "integrity": "sha512-9zfUrKV3xBog2tpIR9NZOags+QJZSj7v9Ek7KdSkVu978IJqF9RX7oa2xftX+eiHySfV5ZQ8r2fdhdbYBk+kMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -4014,6 +4135,20 @@ "consola": "^3.2.3" } }, + "node_modules/cleye": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cleye/-/cleye-2.2.1.tgz", + "integrity": "sha512-eZzJGlG3N6+IsKV+297HIRS2fyRsLMOrx62hGUmmcyOtP/I+L7JVeSKZH49WZUdVB8NoaZOUvq01363I/PHJiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "terminal-columns": "^2.0.0", + "type-flag": "^4.0.3" + }, + "funding": { + "url": "https://github.com/privatenumber/cleye?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -7058,7 +7193,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7557,6 +7692,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7577,6 +7713,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7597,6 +7734,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7617,6 +7755,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7637,6 +7776,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7657,6 +7797,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7677,6 +7818,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7697,6 +7839,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7717,6 +7860,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7737,6 +7881,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7757,6 +7902,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12383,6 +12529,21 @@ } } }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12453,6 +12614,33 @@ "svelte": "^5.1.3" } }, + "node_modules/svelte-fast-check": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/svelte-fast-check/-/svelte-fast-check-0.4.5.tgz", + "integrity": "sha512-xk9+0CF4f/pRxY6AipDSL5Vhh8yLi4wGV3tR3gLKYIWSaOXeoJkPMciPZBRY7nSWUKRVvQy6s6WYDasg2Wh0Xw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/fixtures/*" + ], + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@typescript/native-preview": "^7.0.0-dev.20251229.1", + "cleye": "^2.2.1", + "svelte2tsx": "^0.7.34", + "tinyglobby": "^0.2.15" + }, + "bin": { + "svelte-fast-check": "dist/cli.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "svelte": ">=5.0.0", + "typescript": ">=5.0.0" + } + }, "node_modules/svelte-floating-ui": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/svelte-floating-ui/-/svelte-floating-ui-1.6.2.tgz", @@ -12566,6 +12754,21 @@ "svelte": "^4.2.19 || ^5.1.0" } }, + "node_modules/svelte2tsx": { + "version": "0.7.49", + "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.49.tgz", + "integrity": "sha512-dMX/KwCAF70PE32WJo9qvUkAkOBy66Rl3TP3pExEa/pEtqMW1WT1AeUj/Hcjpf0hKbFYjnkQendMaZnLN/oF0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dedent-js": "^1.0.1", + "scule": "^1.3.0" + }, + "peerDependencies": { + "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", + "typescript": "^4.9.4 || ^5.0.0" + } + }, "node_modules/svg-tags": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", @@ -12801,6 +13004,16 @@ "node": ">=6" } }, + "node_modules/terminal-columns": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/terminal-columns/-/terminal-columns-2.0.0.tgz", + "integrity": "sha512-6IByuUjyNZJXUtwDNm+OIe62zgwwaRbH+WMNTcx05O2G5V9WhvluAAHJY8OvUdwmzMPpqAD/7EUpGdI6ae1aiQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/terminal-columns?sponsor=1" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -13047,6 +13260,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-flag": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/type-flag/-/type-flag-4.0.3.tgz", + "integrity": "sha512-YA09cL07U7hSV+/doSfKl+RkIZ2olCnevZsVgAuyBUG3h2ROf9Oh2vmbq5Rf26aA9/qu9RtStuc7ap5PC6k/vw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/type-flag?sponsor=1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -14428,9 +14651,9 @@ "integrity": "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw==" }, "node_modules/windmill-parser-wasm-regex": { - "version": "1.625.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.625.0.tgz", - "integrity": "sha512-xXzr0O2+U4IaQW8Y+SlbU2wEN5lyZhb+ZjO0LouWVlbQkj/RVeQkQ1M7tZr5LlmvGwXbFmBjct/3IjVR8c3Qwg==" + "version": "1.639.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz", + "integrity": "sha512-qvYM4sYxB6M0xrqwBljS2fWqOMk6rp++60TRltJnzZDzVaWQrKjTGwNMmfepGAIWy1OGVKp0SCVERhe2P+O6tQ==" }, "node_modules/windmill-parser-wasm-ruby": { "version": "1.526.1", diff --git a/frontend/package.json b/frontend/package.json index 432df6f5ac..c4cc0b9426 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,12 +1,13 @@ { "name": "windmill-components", - "version": "1.638.4", + "version": "1.642.0", "scripts": { "dev": "vite dev", "build": "vite build", "build:utils": "vite build --config sharedUtils/vite.sharedUtils.config.js", "preview": "vite preview", "postinstall": "node -e \"if (require('fs').existsSync('./scripts/untar_ui_builder.js')) { require('child_process').execSync('node ./scripts/untar_ui_builder.js', {stdio: 'inherit'}) }\"", + "check:fast": "bun --bun svelte-fast-check --no-svelte-warnings --incremental", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold warning", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", @@ -58,6 +59,7 @@ "svelte": "^5.38.0", "svelte-awesome-color-picker": "^3.0.4", "svelte-check": "^4.0.0", + "svelte-fast-check": "^0.4.5", "svelte-floating-ui": "^1.5.8", "svelte-highlight": "^7.6.0", "svelte-popperjs": "^1.3.2", @@ -150,7 +152,7 @@ "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.574.1", "windmill-parser-wasm-py": "^1.628.3", - "windmill-parser-wasm-regex": "1.625.0", + "windmill-parser-wasm-regex": "1.639.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.558.1", "windmill-parser-wasm-ts": "1.623.1", diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 8aeccc37db..2ddf79fd2c 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -4,25 +4,34 @@ @tailwind utilities; @media (min-width: 1760px) { - :root { - font-size: 18px; - } + :root { + font-size: 18px; + } } @layer base { - /* Light mode: default border color */ - .border, .border-t, .border-r, .border-b, .border-l, - .border-x, .border-y, - .divide-x > :not([hidden]) ~ :not([hidden]), + .border, + .border-t, + .border-r, + .border-b, + .border-l, + .border-x, + .border-y, + .divide-x > :not([hidden]) ~ :not([hidden]), .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: rgb(var(--color-border-light)); } /* Dark mode: change border color */ - .dark .border, .dark .border-t, .dark .border-r, .dark .border-b, .dark .border-l, - .dark .border-x, .dark .border-y, - .dark .divide-x > :not([hidden]) ~ :not([hidden]), + .dark .border, + .dark .border-t, + .dark .border-r, + .dark .border-b, + .dark .border-l, + .dark .border-x, + .dark .border-y, + .dark .divide-x > :not([hidden]) ~ :not([hidden]), .dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: rgb(var(--color-border-light)); } @@ -205,11 +214,21 @@ svelte-virtual-list-contents > * + * { /* Prevent clock icon in input[type="time"] making the input taller */ /* Chrome, Safari, Edge, Opera */ -input[type="time"]::-webkit-calendar-picker-indicator { +input[type='time']::-webkit-calendar-picker-indicator { margin: 0; - padding: 0; + padding: 0; +} + +/* Settings search highlight (used by both the drawer and the setup page) */ +[data-setting-key] { + transition: outline 0.8s ease; + outline: 2px solid transparent; + outline-offset: -2px; +} +[data-setting-key].setting-highlight { + outline: 2px solid rgb(var(--color-border-accent, 59 130 246)); } .svelte-flow__edges { z-index: -10; -} \ No newline at end of file +} diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index 1872684410..42f0e0430a 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -24,10 +24,10 @@ } } - let automateUsernameCreation = $state(false) + let automateUsernameCreation = $state(true) async function getAutomateUsernameCreationSetting() { automateUsernameCreation = - ((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? false + ((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? true } getAutomateUsernameCreationSetting() diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index be82752f24..a557b15b4a 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -3,7 +3,6 @@ import { workspaceStore } from '$lib/stores' import { emptySchema, emptyString } from '$lib/utils' import SchemaForm from './SchemaForm.svelte' - import type SimpleEditor from './SimpleEditor.svelte' import Toggle from './Toggle.svelte' import TestConnection from './TestConnection.svelte' import SupabaseIcon from './icons/SupabaseIcon.svelte' @@ -115,7 +114,7 @@ } } - let rawCodeEditor: SimpleEditor | undefined = $state(undefined) + let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined) let textFileContent: string | undefined = $state(undefined) function parseTextFileContent() { diff --git a/frontend/src/lib/components/ArgInput.svelte b/frontend/src/lib/components/ArgInput.svelte index c88c63b54e..3fc1a772e3 100644 --- a/frontend/src/lib/components/ArgInput.svelte +++ b/frontend/src/lib/components/ArgInput.svelte @@ -20,7 +20,6 @@ import ObjectResourceInput from './ObjectResourceInput.svelte' import Range from './Range.svelte' import ResourcePicker from './ResourcePicker.svelte' - import type SimpleEditor from './SimpleEditor.svelte' import Toggle from './Toggle.svelte' import type VariableEditor from './VariableEditor.svelte' import { twMerge } from 'tailwind-merge' @@ -97,7 +96,7 @@ title?: string | undefined placeholder?: string | undefined order?: string[] | undefined - editor?: SimpleEditor | undefined + editor?: any | undefined orderEditable?: boolean shouldDispatchChanges?: boolean noDefaultOnSelectFirst?: boolean diff --git a/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte b/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte index 8b4d41eea1..37e3da3d21 100644 --- a/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte +++ b/frontend/src/lib/components/ChangeInstanceUsernameInner.svelte @@ -47,7 +47,7 @@ loading = true try { const automateUsernameCreation = - (await SettingService.getGlobal({ key: 'automate_username_creation' })) ?? false + (await SettingService.getGlobal({ key: 'automate_username_creation' })) ?? true if (!automateUsernameCreation) { sendUserToast( diff --git a/frontend/src/lib/components/DedicatedWorkersSelector.svelte b/frontend/src/lib/components/DedicatedWorkersSelector.svelte index 336c2f263b..1ed9d36a7c 100644 --- a/frontend/src/lib/components/DedicatedWorkersSelector.svelte +++ b/frontend/src/lib/components/DedicatedWorkersSelector.svelte @@ -61,7 +61,7 @@ let selectedTagsInfo: SvelteMap = $state(new SvelteMap()) // Languages that support dedicated workers - const DEDICATED_WORKER_LANGUAGES = ['python3', 'bun', 'deno'] + const DEDICATED_WORKER_LANGUAGES = ['python3', 'bun', 'bunnative', 'deno'] // Resolve workspace script languages and filter to supported languages async function resolveAndFilterRunners( @@ -603,7 +603,7 @@
{#if runnable.runners.length === 0}
- No eligible steps (python3/bun/deno) + No eligible steps (python3/bun/bunnative/deno)
{:else} {#each runnable.runners as runner (runner.stepId)} diff --git a/frontend/src/lib/components/DiffEditor.svelte b/frontend/src/lib/components/DiffEditor.svelte index 4015a7a9ac..1d252d082a 100644 --- a/frontend/src/lib/components/DiffEditor.svelte +++ b/frontend/src/lib/components/DiffEditor.svelte @@ -11,16 +11,10 @@ import EditorTheme from './EditorTheme.svelte' import Button from '$lib/components/common/button/Button.svelte' import { twMerge } from 'tailwind-merge' - import type { ButtonType } from './common' + import type { ButtonProp } from './diffEditorTypes' const SIDE_BY_SIDE_MIN_WIDTH = 700 - export interface ButtonProp { - text: string - color?: ButtonType.Color - onClick: () => void - } - interface Props { open?: boolean className?: string @@ -169,12 +163,6 @@ open = false } - function onWidthChange(editorWidth: number) { - diffEditor?.updateOptions({ - renderSideBySide: inlineDiff ? false : editorWidth >= SIDE_BY_SIDE_MIN_WIDTH - }) - } - $effect(() => { if (open && diffDivEl) { loadDiffEditor() @@ -182,7 +170,11 @@ }) $effect(() => { - onWidthChange(editorWidth) + if (diffEditor) { + diffEditor.updateOptions({ + renderSideBySide: inlineDiff ? false : editorWidth >= SIDE_BY_SIDE_MIN_WIDTH + }) + } }) onMount(() => { diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index 9807ed877c..3dfc25e127 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -543,8 +543,7 @@ } editor.insertAtCursor(`v, _ := wmill.GetVariable("${path}")`) } else if (lang == 'bash') { - editor.insertAtCursor(`curl -s -H "Authorization: Bearer $WM_TOKEN" \\ - "$BASE_INTERNAL_URL/api/w/$WM_WORKSPACE/variables/get_value/${path}" | jq -r .`) + editor.insertAtCursor(`wmill variable get ${path} --json | jq -r .value`) } else if (lang == 'powershell') { editor.insertAtCursor(`$Headers = @{\n"Authorization" = "Bearer $Env:WM_TOKEN"`) editor.arrowDown() @@ -620,8 +619,7 @@ string ${windmillPathToCamelCaseName(path)} = await client.GetStringAsync(uri); } editor.insertAtCursor(`r, _ := wmill.GetResource("${path}")`) } else if (lang == 'bash') { - editor.insertAtCursor(`curl -s -H "Authorization: Bearer $WM_TOKEN" \\ - "$BASE_INTERNAL_URL/api/w/$WM_WORKSPACE/resources/get_value_interpolated/${path}" | jq`) + editor.insertAtCursor(`wmill resource get ${path} --json | jq .value`) } else if (lang == 'powershell') { editor.insertAtCursor(`$Headers = @{\n"Authorization" = "Bearer $Env:WM_TOKEN"`) editor.arrowDown() diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 3ea8560261..7f6352241f 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -11,7 +11,7 @@
- {#if diffMode} -
- {#await import('$lib/components/DiffEditor.svelte')} - - {:then Module} - {@const diff = buildFullDiff()} - +

+ Use this YAML to manage instance settings as code. + Learn more +

+ +
+ handleShowSensitiveToggle(e.detail)} + options={{ right: 'Show sensitive values' }} + size="xs" /> - {/await} -
- {:else if yamlMode} -

- Use this YAML to manage instance settings as code. - Learn more -

- -
- handleShowSensitiveToggle(e.detail)} - options={{ right: 'Show sensitive values' }} - size="xs" - /> +
{#await import('$lib/components/SimpleEditor.svelte')} @@ -879,11 +901,7 @@ link="https://www.windmill.dev/docs/advanced/imports" /> {#if !$enterpriseLicense} - + {/if} {:else if category == 'Alerts'} {/if} + {#if quickSetup && category === 'Core' && setting.key === 'base_url'} + {@const licenseKeySetting = settings['Core'].find((s) => s.key === 'license_key')} + {#if licenseKeySetting} + closeDrawer?.()} + {loading} + setting={licenseKeySetting} + {values} + {version} + {oauths} + /> + {/if} + {/if} {/each} {#if quickSetup && category === 'Core'} - {@const licenseKeySetting = settings['Core'].find((s) => s.key === 'license_key')} {@const extraSettings = [ ...settings['Jobs'].filter((s) => s.key === 'job_isolation'), - ...(licenseKeySetting ? [licenseKeySetting] : []), ...settings['Jobs'].filter((s) => s.key === 'retention_period_secs'), ...(settings['Object Storage']?.filter((s) => s.key === 'object_store_cache_config') ?? []) diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index 92e2f858be..fed87bf150 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -226,7 +226,8 @@ export async function runFlowPreview( args: Record, flow: OpenFlow & { tag?: string }, - callbacks?: Callbacks + callbacks?: Callbacks, + path?: string ): Promise { return abstractRun( () => @@ -235,7 +236,8 @@ requestBody: { args, value: flow.value, - tag: flow.tag + tag: flow.tag, + path } }), callbacks @@ -288,7 +290,8 @@ tag: string | undefined, lock?: string, hash?: string, - callbacks?: Callbacks + callbacks?: Callbacks, + flowPath?: string ): Promise { return abstractRun( () => @@ -301,7 +304,8 @@ language: lang as Preview['language'], tag, lock, - script_hash: hash + script_hash: hash, + flow_path: flowPath } }), callbacks diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index ad9d35d113..59c8f973ea 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -76,7 +76,8 @@ flowStore?.val?.tag ?? val.tag, undefined, undefined, - callbacks + callbacks, + $pathStore ) } else if (val.type == 'script') { const script = val.hash @@ -90,7 +91,8 @@ flowStore?.val?.tag ?? (val.tag_override ? val.tag_override : script.tag), script.lock, val.hash ?? script.hash, - callbacks + callbacks, + $pathStore ) } else if (val.type == 'flow') { await jobLoader?.runFlowByPath(val.path, args, callbacks) @@ -125,7 +127,8 @@ summary: '', schema }, - callbacks + callbacks, + $pathStore ) } else { throw Error('Not supported module type') diff --git a/frontend/src/lib/components/OAuthSetting.svelte b/frontend/src/lib/components/OAuthSetting.svelte index 1d4a386f72..ebdbfb3223 100644 --- a/frontend/src/lib/components/OAuthSetting.svelte +++ b/frontend/src/lib/components/OAuthSetting.svelte @@ -176,7 +176,7 @@ {/if} {#if name == 'google'} -
+
Create a new OAuth 2.0 Client in Google console {:else if name == 'slack'} -
+
To use Slack OAuth, create a new Slack app {:else if name == 'teams'} -
+ - - diff --git a/frontend/src/lib/components/SaveButton.svelte b/frontend/src/lib/components/SaveButton.svelte new file mode 100644 index 0000000000..7c89c2e196 --- /dev/null +++ b/frontend/src/lib/components/SaveButton.svelte @@ -0,0 +1,99 @@ + + +
+ + + {#if saveStatus === 'success'} +
+ +
+ {:else if saveStatus === 'error'} +
+ +
+ {/if} +
diff --git a/frontend/src/lib/components/SchemaFormWithArgPicker.svelte b/frontend/src/lib/components/SchemaFormWithArgPicker.svelte index 14d84b5fcb..f1f7bae6e9 100644 --- a/frontend/src/lib/components/SchemaFormWithArgPicker.svelte +++ b/frontend/src/lib/components/SchemaFormWithArgPicker.svelte @@ -7,16 +7,29 @@ import CaptureIcon from '$lib/components/triggers/CaptureIcon.svelte' import CaptureButton from './triggers/CaptureButton.svelte' import SavedInputsPicker from './SavedInputsPicker.svelte' - import { createEventDispatcher } from 'svelte' + import { createEventDispatcher, untrack } from 'svelte' import CaptureTable from './triggers/CaptureTable.svelte' import RefreshButton from './common/button/RefreshButton.svelte' - export let runnableId: string = '' - export let stablePathForCaptures: string = '' - export let runnableType: any - export let previewArgs: any - export let isValid: boolean = true - export let jsonView: boolean = false + interface Props { + runnableId?: string + stablePathForCaptures?: string + runnableType: any + previewArgs: any + isValid?: boolean + jsonView?: boolean + children?: import('svelte').Snippet + } + + let { + runnableId = '', + stablePathForCaptures = '', + runnableType, + previewArgs, + isValid = true, + jsonView = false, + children + }: Props = $props() const dispatch = createEventDispatcher() @@ -77,17 +90,22 @@ ] } - let rightHeight = 0 - let selectedTab: 'history' | 'saved_inputs' | 'captures' | undefined = undefined - let dropdownItems: any - let rightPanelOpen = false + let rightHeight = $state(0) + let selectedTab: 'history' | 'saved_inputs' | 'captures' | undefined = $state(undefined) + let dropdownItems: any = $state() + let rightPanelOpen = $state(false) - let savedInputsPicker: SavedInputsPicker | undefined = undefined - let captureTable: CaptureTable | undefined = undefined - let historicInputs: HistoricInputs | undefined = undefined - $: (selectedTab, (dropdownItems = getDropdownItems())) + let savedInputsPicker: SavedInputsPicker | undefined = $state(undefined) + let captureTable: CaptureTable | undefined = $state(undefined) + let historicInputs: HistoricInputs | undefined = $state(undefined) + $effect(() => { + selectedTab + untrack(() => { + dropdownItems = getDropdownItems() + }) + }) - let inputPanelSize = 70 + let inputPanelSize = $state(70)
@@ -99,7 +117,7 @@
- + {@render children?.()}
@@ -108,14 +126,16 @@
{#if selectedTab === 'history'} - -
- historicInputs?.refresh()} - /> -
-
+ {#snippet action()} + +
+ historicInputs?.refresh()} + /> +
+
+ {/snippet} {:else if selectedTab === 'captures'} - -
- -
-
+ {#snippet action()} + +
+ +
+
+ {/snippet}
- A worker group needs to be configured to listen to this script. Select - it in the dedicated workers section of the worker group configuration. + A worker group needs to be configured to listen to this script. Select it + in the dedicated workers section of the worker group configuration.
{/if} @@ -1454,8 +1456,8 @@ > In this mode, the script is meant to be run on dedicated workers that run the script at native speed. Can reach >1500rps per dedicated worker. Only - available on enterprise edition and for Python3, Deno and Bun. For other - languages, the efficiency is already on par with deidcated workers since + available on enterprise edition and for Python3, Deno, Bun and Bunnative. For other + languages, the efficiency is already on par with dedicated workers since they do not spawn a full runtime {/snippet} diff --git a/frontend/src/lib/components/SqlRepl.svelte b/frontend/src/lib/components/SqlRepl.svelte index 681dd06d10..661b0ccd2c 100644 --- a/frontend/src/lib/components/SqlRepl.svelte +++ b/frontend/src/lib/components/SqlRepl.svelte @@ -47,7 +47,6 @@ diff --git a/frontend/src/lib/components/SuperadminSettings.svelte b/frontend/src/lib/components/SuperadminSettings.svelte index 1600de6e0a..f9b446b04e 100644 --- a/frontend/src/lib/components/SuperadminSettings.svelte +++ b/frontend/src/lib/components/SuperadminSettings.svelte @@ -5,7 +5,9 @@ import MeltTooltip from './meltComponents/Tooltip.svelte' import Toggle from './Toggle.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' - import { X, FileDiff, Save, Loader2 } from 'lucide-svelte' + import SaveButton from './SaveButton.svelte' + import { X, FileDiff, Loader2 } from 'lucide-svelte' + import { fade } from 'svelte/transition' import { SettingsService } from '$lib/gen' import { isCloudHosted } from '$lib/cloud' @@ -16,14 +18,14 @@ let { disableChatOffset = false }: Props = $props() let drawer: Drawer | undefined = $state() + let diffDrawer: Drawer | undefined = $state() let innerComponent: SuperadminSettingsInner | undefined = $state() let uptodateVersion: string | undefined = $state(undefined) let yamlMode = $state(false) - let diffMode = $state(false) let hasUnsavedChanges = $state(false) - let pendingSave = $state(false) - let isSaving = $state(false) let showCloseConfirmModal = $state(false) + let diffData: { original: string; modified: string } = $state({ original: '', modified: '' }) + let inlineDiff = $state(false) async function loadUptodate() { try { @@ -65,39 +67,24 @@ bypassCloseCheck = false } - async function handleSave() { - if (!pendingSave) { - if (!innerComponent?.syncBeforeDiff()) return - diffMode = true - pendingSave = true - return - } - isSaving = true - try { - await innerComponent?.saveSettings() - diffMode = false - pendingSave = false - } catch (e) { - console.error('Save failed:', e) - } finally { - isSaving = false - } + async function handleSave(): Promise { + if (!innerComponent?.syncBeforeDiff()) throw new Error('YAML sync failed') + await innerComponent?.saveSettings() + } + + async function handleSaveAndCloseDiff(): Promise { + await handleSave() + diffDrawer?.closeDrawer() } function handleDiscard() { innerComponent?.discardAll() - diffMode = false - pendingSave = false } - function handleShowDiff() { - if (!diffMode) { - if (!innerComponent?.syncBeforeDiff()) return - } - diffMode = !diffMode - if (!diffMode) { - pendingSave = false - } + function handleReviewChanges() { + if (!innerComponent?.syncBeforeDiff()) return + diffData = innerComponent?.buildFullDiff() ?? { original: '', modified: '' } + diffDrawer?.openDrawer() } @@ -124,41 +111,32 @@ {/snippet} {#snippet actions()}
+ {#if hasUnsavedChanges} +
+ +
+ {/if} - -
{/snippet} + + diffDrawer?.closeDrawer()}> + {#snippet actions()} + + + {/snippet} + +
+ {#await import('$lib/components/DiffEditor.svelte')} + + {:then Module} + + {/await} +
+
+
+ {#if showCloseConfirmModal} { innerComponent?.discardAll() showCloseConfirmModal = false - diffMode = false - pendingSave = false closeDrawer() }} > @@ -192,14 +196,14 @@ You have unsaved changes. Are you sure you want to discard them and close?
diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 001365b7b0..4d6b0e33f1 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -26,16 +26,19 @@ import InstanceNameEditor from './InstanceNameEditor.svelte' import Toggle from './Toggle.svelte' import { instanceSettingsSelectedTab } from '$lib/stores' - import { onDestroy } from 'svelte' + import { onDestroy, tick } from 'svelte' import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte' import { instanceSettingsNavigationGroups, tabToCategoryMap, tabToAuthSubTab, - categoryToTabMap + categoryToTabMap, + buildSearchableSettingItems, + type SearchableSettingItem } from './instanceSettings' import TextInput from './text_input/TextInput.svelte' import SettingsPageHeader from './settings/SettingsPageHeader.svelte' + import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte' let filter = $state('') @@ -43,7 +46,6 @@ closeDrawer, showHeaderInfo = true, yamlMode = $bindable(false), - diffMode = $bindable(false), hasUnsavedChanges = $bindable(false) } = $props() @@ -86,10 +88,10 @@ let instanceSettings: InstanceSettings | undefined = $state() - let automateUsernameCreation = $state(false) + let automateUsernameCreation = $state(true) async function getAutomateUsernameCreationSetting() { automateUsernameCreation = - ((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? false + ((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? true } getAutomateUsernameCreationSetting() let automateUsernameModalOpen = $state(false) @@ -138,6 +140,38 @@ export function syncBeforeDiff(): boolean { return instanceSettings?.syncBeforeDiff() ?? true } + + export function buildFullDiff(): { original: string; modified: string } { + return instanceSettings?.buildFullDiff() ?? { original: '', modified: '' } + } + // --- Settings search --- + const searchableItems = buildSearchableSettingItems() + + let scrollTimeout: ReturnType | undefined + let highlightTimeout: ReturnType | undefined + + async function handleSearchSelect(item: SearchableSettingItem) { + handleNavigate(item.tabId) + if (item.settingKey) { + clearTimeout(scrollTimeout) + clearTimeout(highlightTimeout) + await tick() + // Wait for the tab content to render before scrolling + scrollTimeout = setTimeout(() => { + const el = document.querySelector(`[data-setting-key="${item.settingKey}"]`) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + el.classList.add('setting-highlight') + highlightTimeout = setTimeout(() => el.classList.remove('setting-highlight'), 2500) + } + }, 100) + } + } + + onDestroy(() => { + clearTimeout(scrollTimeout) + clearTimeout(highlightTimeout) + }) - {#if !yamlMode && !diffMode} + {#if !yamlMode}
+ {#if $workspaceStore !== 'admins'} -
+
- {#if tab === 'users' && !yamlMode && !diffMode} + {#if tab === 'users' && !yamlMode}
{#if !automateUsernameCreation && !isCloudHosted()}
@@ -474,7 +509,6 @@ bind:this={instanceSettings} hideTabs bind:yamlMode - bind:diffMode bind:hasUnsavedChanges tab={instanceSettingsCategory} {authSubTab} diff --git a/frontend/src/lib/components/TemplateEditor.svelte b/frontend/src/lib/components/TemplateEditor.svelte index c6ef90e663..f3ad00ffc3 100644 --- a/frontend/src/lib/components/TemplateEditor.svelte +++ b/frontend/src/lib/components/TemplateEditor.svelte @@ -622,7 +622,7 @@ resolveCompletionItem: async (item: languages.CompletionItem, token: any) => { extraModel.setValue('`' + model.getValue() + '`') - const myItem = item + const myItem = item as any const position = myItem.position const offset = myItem.offset @@ -634,7 +634,7 @@ if (!details) { return myItem } - return { + return { uri: model.uri, position: position, label: details.name, @@ -643,7 +643,7 @@ documentation: { value: createDocumentationString(details) } - } + } as any } }) } catch (e) { diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte index 44281db0d1..13e857ed6f 100644 --- a/frontend/src/lib/components/Toast.svelte +++ b/frontend/src/lib/components/Toast.svelte @@ -10,13 +10,13 @@ let hover = Object.values(toastStates).some((state) => state.hover) for (const toastId in toastStates) { - const state = toastStates[toastId] + const st = toastStates[toastId] if (hover) continue - if (state.elapsed >= state.duration) { + if (st.elapsed >= st.duration) { delete toastStates[toastId] continue } - state.elapsed += delta + st.elapsed += delta } lastTime = time @@ -36,18 +36,16 @@ }) } } - - export type ToastType = AlertType diff --git a/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte b/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte index fd202e2272..b643c7e86e 100644 --- a/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte +++ b/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte @@ -1,13 +1,16 @@ -
- + {#snippet actions()}
{ const config = { - insertionMode: CONNECT, onSelect: (code) => { setExpr(code) return true @@ -405,7 +404,7 @@ notSelectable pickableProperties={stepPropPicker.pickableProperties} on:select={({ detail }) => { - if ($flowPropPickerConfig?.insertionMode == CONNECT) { + if ($flowPropPickerConfig) { setExpr(detail) flowPropPickerConfig.set(undefined) return diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index a0f3ede833..42ad317a32 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -36,7 +36,8 @@ import FlowModuleMockTransitionMessage from './FlowModuleMockTransitionMessage.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import { SecondsInput } from '$lib/components/common' - import DiffEditor, { type ButtonProp } from '$lib/components/DiffEditor.svelte' + import DiffEditor from '$lib/components/DiffEditor.svelte' + import type { ButtonProp } from '$lib/components/diffEditorTypes' import FlowModuleTimeout from './FlowModuleTimeout.svelte' import HighlightCode from '$lib/components/HighlightCode.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' @@ -140,7 +141,7 @@ } ]) - let editor: Editor | undefined = $state() + let editor: any | undefined = $state() let diffEditor: DiffEditor | undefined = $state() let modulePreview: ModulePreview | undefined = $state() let websocketAlive = $state({ diff --git a/frontend/src/lib/components/flows/propPicker/FlowPropPicker.svelte b/frontend/src/lib/components/flows/propPicker/FlowPropPicker.svelte deleted file mode 100644 index 5a0524eae7..0000000000 --- a/frontend/src/lib/components/flows/propPicker/FlowPropPicker.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - -
- { - if ($flowPropPickerConfig?.onSelect(detail)) { - $flowPropPickerConfig?.clearFocus() - } - }} - allowCopy={!$flowPropPickerConfig} - /> -
-
- - - diff --git a/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte b/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte index 14701b1799..cdbfe8d153 100644 --- a/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte +++ b/frontend/src/lib/components/flows/propPicker/OutputPicker.svelte @@ -50,9 +50,7 @@ const zoom = $derived.by(useSvelteFlow().getZoom) - let showConnecting = $derived( - isConnectingCandidate && $flowPropPickerConfig?.insertionMode === 'connect' - ) + let showConnecting = $derived(isConnectingCandidate && $flowPropPickerConfig != undefined) function selectConnection(value: string) { if ($flowPropPickerConfig?.onSelect(value)) { diff --git a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte index a151b31509..6910fabbb5 100644 --- a/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte +++ b/frontend/src/lib/components/flows/propPicker/PropPickerWrapper.svelte @@ -6,18 +6,14 @@ propName?: string onSelect: SelectCallback clearFocus: () => void - insertionMode: 'append' | 'connect' | 'insert' } export type PropPickerWrapperContext = { propPickerConfig: Writable inputMatches: Writable<{ word: string; value: string }[] | undefined> - focusProp: ( - propName: string, - insertionMode: 'append' | 'connect' | 'insert', - onSelect: SelectCallback - ) => void - clearFocus: () => void + connectProp: (propName: string, onSelect: SelectCallback) => void + clearConnect: () => void + exprBeingEdited: Writable } @@ -77,10 +73,9 @@ setContext('PropPickerWrapper', { propPickerConfig, inputMatches, - focusProp: (propName, insertionMode, onSelect) => { + connectProp: (propName, onSelect) => { const config = { propName, - insertionMode, onSelect, clearFocus: () => { propPickerConfig.set(undefined) @@ -98,10 +93,11 @@ }) } }, - clearFocus: () => { + clearConnect: () => { flowPropPickerConfig.set(undefined) propPickerConfig.set(undefined) - } + }, + exprBeingEdited: writable([]) }) async function getPropPickerElements(): Promise { @@ -134,14 +130,11 @@
{#if result != undefined && !pickableProperties} diff --git a/frontend/src/lib/components/flows/types.ts b/frontend/src/lib/components/flows/types.ts index bb2ac57a7c..eace554bf6 100644 --- a/frontend/src/lib/components/flows/types.ts +++ b/frontend/src/lib/components/flows/types.ts @@ -13,7 +13,7 @@ import type { Asset, AssetWithAccessType } from '../assets/lib' import type S3FilePicker from '../S3FilePicker.svelte' import type ResourceEditorDrawer from '../ResourceEditorDrawer.svelte' import type { ModulesTestStates } from '../modulesTest.svelte' -import type { ButtonProp } from '$lib/components/DiffEditor.svelte' +import type { ButtonProp } from '$lib/components/diffEditorTypes' import type { SelectionManager } from '../graph/selectionUtils.svelte' import type { InferAssetsSqlQueryDetails } from '$lib/infer' diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte index 15efe7e98e..7b144a3fb6 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte @@ -225,7 +225,6 @@ type AssetWithAltAccessType } from '$lib/components/assets/lib' import { twMerge } from 'tailwind-merge' - import type { FlowGraphAssetContext } from '$lib/components/flows/types' import { getContext } from 'svelte' import ExploreAssetButton, { assetCanBeExplored } from '../../../ExploreAssetButton.svelte' import { Tooltip } from '$lib/components/meltComponents' @@ -243,7 +242,7 @@ data: AssetN['data'] } - const flowGraphAssetsCtx = getContext('FlowGraphAssetContext') + const flowGraphAssetsCtx = getContext('FlowGraphAssetContext') let { data }: Props = $props() diff --git a/frontend/src/lib/components/home/Item.svelte b/frontend/src/lib/components/home/Item.svelte index 5f1cd8f964..b4a57175cd 100644 --- a/frontend/src/lib/components/home/Item.svelte +++ b/frontend/src/lib/components/home/Item.svelte @@ -11,19 +11,22 @@ import { createEventDispatcher } from 'svelte' import { ArrowBigUp } from 'lucide-svelte' - export let item - export let depth: number = 0 - const dispatch = createEventDispatcher() - let deleteConfirmedCallback: (() => void) | undefined = undefined - let shareModal: ShareModal - let moveDrawer: MoveDrawer - let deploymentDrawer: DeployWorkspaceDrawer + let deleteConfirmedCallback: (() => void) | undefined = $state(undefined) + let shareModal: any | undefined = $state() + let moveDrawer: any | undefined = $state() + let deploymentDrawer: any | undefined = $state() - let menuOpen: boolean = false - export let showCode: (path: string, summary: string) => void - export let showEditButton: boolean = true + let menuOpen: boolean = $state(false) + interface Props { + item: any + depth?: number + showCode: (path: string, summary: string) => void + showEditButton?: boolean + } + + let { item, depth = 0, showCode, showEditButton = true }: Props = $props() {#if item.type == 'script'} diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index deda42a994..9e67ff585c 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -181,7 +181,7 @@ export const settings: Record = { ], Jobs: [ { - label: 'Job Isolation', + label: 'Job isolation', key: 'job_isolation', fieldType: 'select', description: @@ -321,7 +321,7 @@ export const settings: Record = { ], SMTP: [ { - label: 'SMTP', + label: 'SMTP configuration', key: 'smtp_settings', fieldType: 'smtp_connect', storage: 'setting', @@ -406,23 +406,37 @@ export const settings: Record = { ee_only: '' }, { - label: 'Npm config registry', - description: 'Add private npm registry', - key: 'npm_config_registry', - fieldType: 'password', - placeholder: 'https://registry.npmjs.org/:_authToken=npm_FOOBAR', + label: 'NPM Registry Configuration (.npmrc)', + description: + 'Full .npmrc file content for private npm registries. Used by Bun, Deno, and the npm proxy. Takes precedence over the legacy fields below.', + key: 'npmrc', + fieldType: 'codearea', + codeAreaLang: 'ini', + placeholder: + 'registry=https://registry.mycompany.com/\n//registry.mycompany.com/:_authToken=YOUR_TOKEN\n\n@myorg:registry=https://registry.myorg.com/\n//registry.myorg.com/:_authToken=SCOPED_TOKEN', storage: 'setting', ee_only: '' }, { - label: 'Bunfig install scopes', + label: 'Npm config registry (legacy)', + description: 'Add private npm registry. Prefer using the .npmrc field above.', + key: 'npm_config_registry', + fieldType: 'password', + placeholder: 'https://registry.npmjs.org/:_authToken=npm_FOOBAR', + storage: 'setting', + ee_only: '', + hiddenIfEmpty: true + }, + { + label: 'Bunfig install scopes (legacy)', description: - 'Add private scoped registries for Bun, See: https://bun.sh/docs/install/registries', + 'Add private scoped registries for Bun. Prefer using the .npmrc field above. See: https://bun.sh/docs/install/registries', key: 'bunfig_install_scopes', fieldType: 'password', placeholder: '"@myorg3" = { token = "mytoken", url = "https://registry.myorg.com/" }', storage: 'setting', - ee_only: '' + ee_only: '', + hiddenIfEmpty: true }, { label: 'Nuget Config', @@ -782,3 +796,84 @@ export const categoryToTabMap: Record = { Jobs: 'jobs', 'Private Hub': 'private_hub' } + +export interface SearchableSettingItem { + label: string + tabId: string + settingKey?: string + category: string + /** Full description text (HTML stripped), used for search matching only — not displayed */ + description?: string +} + +/** + * Extract the label portion from a uFuzzy marked/highlighted string. + * Only allows `` and `` tags through (sanitizes everything else). + */ +export function extractMarkedLabel(marked: string | undefined, labelLength: number): string { + if (!marked) return '' + let plainIdx = 0 + let markedIdx = 0 + while (plainIdx < labelLength && markedIdx < marked.length) { + if (marked[markedIdx] === '<') { + while (markedIdx < marked.length && marked[markedIdx] !== '>') markedIdx++ + markedIdx++ + } else { + plainIdx++ + markedIdx++ + } + } + // Include any closing right after + if (marked.startsWith('', markedIdx)) { + markedIdx += ''.length + } + // Sanitize: only allow and tags from uFuzzy highlight + return marked.slice(0, markedIdx).replace(/<(?!\/?mark>)[^>]*>/g, '') +} + +export function buildSearchableSettingItems( + navigationGroups: typeof instanceSettingsNavigationGroups = instanceSettingsNavigationGroups +): SearchableSettingItem[] { + const items: SearchableSettingItem[] = [] + + // Add sidebar navigation items (tab-level) + for (const group of navigationGroups) { + for (const navItem of group.items) { + items.push({ + label: navItem.label, + tabId: navItem.id, + category: group.title + }) + } + } + + // Add individual settings from each category + for (const [category, categorySettings] of Object.entries(settings)) { + const tabId = categoryToTabMap[category] + if (!tabId) continue + for (const setting of categorySettings) { + if (!setting.label) continue + items.push({ + label: setting.label, + tabId, + settingKey: setting.key, + category, + description: setting.description?.replace(/<[^>]*>/g, '') ?? '' + }) + } + } + + // Add SCIM/SAML settings + for (const setting of scimSamlSetting) { + if (!setting.label) continue + items.push({ + label: setting.label, + tabId: 'scim_saml', + settingKey: setting.key, + category: 'SCIM/SAML', + description: setting.description?.replace(/<[^>]*>/g, '') ?? '' + }) + } + + return items +} diff --git a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte index 9a1430dac7..71721ee10f 100644 --- a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte +++ b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte @@ -289,7 +289,7 @@
- + Authentication Method setAuthMethod(v)}> {#snippet children({ item: toggleButton })}
- + Secret Migration Migrate secrets between the database and HashiCorp Vault. Original values are NOT deleted to allow for rollback. diff --git a/frontend/src/lib/components/instanceSettings/SettingCard.svelte b/frontend/src/lib/components/instanceSettings/SettingCard.svelte index b0ecc3b557..892a3c0d62 100644 --- a/frontend/src/lib/components/instanceSettings/SettingCard.svelte +++ b/frontend/src/lib/components/instanceSettings/SettingCard.svelte @@ -11,6 +11,7 @@ description?: string ee_only?: string tooltip?: string + settingKey?: string actionButton?: { label: string onclick: (values: Record) => Promise @@ -26,6 +27,7 @@ description, ee_only, tooltip, + settingKey, actionButton, values, children, @@ -33,7 +35,10 @@ }: Props = $props() -
+
{#if label}
diff --git a/frontend/src/lib/components/instanceSettings/SettingsSearchInput.svelte b/frontend/src/lib/components/instanceSettings/SettingsSearchInput.svelte new file mode 100644 index 0000000000..c9c94a1393 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/SettingsSearchInput.svelte @@ -0,0 +1,89 @@ + + + x.label + ' ' + (x.description ?? '') + ' ' + x.category} +/> + +
+
+ + +
+ searchInputEl!.getBoundingClientRect() : undefined} + onSelectValue={(item) => handleSelect(item.value)} + highlightFirstOnOpen + maxHeight={400} + > + {#snippet startSnippet({ item })} +
{@html extractMarkedLabel(item.value.marked, item.value.label.length)}
+ {/snippet} +
+
diff --git a/frontend/src/lib/components/prop_picker.ts b/frontend/src/lib/components/prop_picker.ts index 4940f92244..ced6a5e890 100644 --- a/frontend/src/lib/components/prop_picker.ts +++ b/frontend/src/lib/components/prop_picker.ts @@ -1,10 +1,7 @@ import type { Writable } from 'svelte/store' import type { PickableProperties } from '$lib/components/flows/previousResults' -type InsertionMode = 'append' | 'connect' | 'insert' - export type FlowPropPickerConfig = { - insertionMode: InsertionMode clearFocus: () => void onSelect: (path: string) => boolean } diff --git a/frontend/src/lib/components/propertyPicker/PropPicker.svelte b/frontend/src/lib/components/propertyPicker/PropPicker.svelte index fd4f99e29f..d04683e226 100644 --- a/frontend/src/lib/components/propertyPicker/PropPicker.svelte +++ b/frontend/src/lib/components/propertyPicker/PropPicker.svelte @@ -1,7 +1,7 @@ @@ -119,7 +137,7 @@
@@ -145,7 +163,7 @@
{`${totalNumberOfAlerts === 1000 ? '1000+' : (totalNumberOfAlerts ?? '?')} items`}
- +
diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte index 82f95a03c6..3d372ec06f 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte @@ -295,10 +295,10 @@ } } - let automateUsernameCreation = $state(false) + let automateUsernameCreation = $state(true) async function getAutomateUsernameCreationSetting() { automateUsernameCreation = - ((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? false + ((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? true if (!automateUsernameCreation) { UserService.globalWhoami().then((x) => { diff --git a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte index 5f7eddcef5..4e3a1ab601 100644 --- a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbWizardModal.svelte @@ -15,7 +15,6 @@ import type { ResourceReturn } from 'runed' import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte' import ExploreAssetButton from '../ExploreAssetButton.svelte' - import type DBManagerDrawer from '../DBManagerDrawer.svelte' import { ArrowRight, InfoIcon } from 'lucide-svelte' import type { Snippet } from 'svelte' import { truncate } from '$lib/utils' @@ -25,7 +24,7 @@ type Props = { customInstanceDbs: ResourceReturn confirmationModal: ConfirmationModalHandle - dbManagerDrawer: DBManagerDrawer | undefined + dbManagerDrawer: any | undefined bottomHint?: Snippet | undefined opened: { status: CustomInstanceDb | undefined; dbname: string } | undefined tag?: CustomInstanceDbTag diff --git a/frontend/src/lib/components/workspaceSettings/SettingsFooter.svelte b/frontend/src/lib/components/workspaceSettings/SettingsFooter.svelte index afff676149..bebd117dee 100644 --- a/frontend/src/lib/components/workspaceSettings/SettingsFooter.svelte +++ b/frontend/src/lib/components/workspaceSettings/SettingsFooter.svelte @@ -1,7 +1,8 @@
Discard changes
{/if} -
- - - - {#if saveStatus === 'success'} -
- -
- {:else if saveStatus === 'error'} -
- -
- {/if} -
+
diff --git a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte index c382948b09..db17250222 100644 --- a/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/StorageSettings.svelte @@ -48,7 +48,7 @@ | { open: false } | { open: true; storage: S3ResourceSettingsItem } = $state({ open: false }) - let s3FileViewer: S3FilePicker | undefined = $state() + let s3FileViewer: any | undefined = $state() async function editWindmillLFSSettings(): Promise { const large_file_storage = convertFrontendToBackendSetting(s3ResourceSettings) diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 7b08be90a2..a397cb4b7f 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -14,7 +14,6 @@ import { import { getLocalSetting, type StateStore } from './utils' import { createState } from './svelte5Utils.svelte' import { DEFAULT_HUB_BASE_URL } from './hub' -import type DBManagerDrawer from './components/DBManagerDrawer.svelte' export interface UserExt { email: string @@ -127,7 +126,7 @@ export const codeCompletionSessionEnabled = writable( export const usedTriggerKinds = writable([]) -export let globalDbManagerDrawer: StateStore = createState({ +export let globalDbManagerDrawer: StateStore = createState({ val: undefined }) diff --git a/frontend/src/lib/toast.ts b/frontend/src/lib/toast.ts index 244794a5cc..af09f01e5e 100644 --- a/frontend/src/lib/toast.ts +++ b/frontend/src/lib/toast.ts @@ -1,7 +1,10 @@ -import Toast, { type ToastType } from '$lib/components/Toast.svelte' +import Toast from '$lib/components/Toast.svelte' import { toast } from '@zerodevx/svelte-toast' import type { ComponentProps } from 'svelte' import type { Button } from './components/common' +import type { AlertType } from '$lib/components/common/alert/model' + +export type ToastType = AlertType export type ToastAction = { label: string diff --git a/frontend/src/routes/(root)/(logged)/assets/+page.svelte b/frontend/src/routes/(root)/(logged)/assets/+page.svelte index 380d6dc29a..5679187c1e 100644 --- a/frontend/src/routes/(root)/(logged)/assets/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/assets/+page.svelte @@ -82,7 +82,7 @@ let assets = $derived(_assets.current?.flatMap((page) => page.assets)) let s3FilePicker: S3FilePicker | undefined = $state() - let dbManagerDrawer = $derived(globalDbManagerDrawer.val) + let dbManagerDrawer = $derived(globalDbManagerDrawer.val) as any let assetsUsageDropdown: AssetsUsageDrawer | undefined = $state() let allS3Storages = resource( diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index c06708d961..b3028f9844 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -481,7 +481,7 @@ } }) - let dbManagerDrawer = $derived(globalDbManagerDrawer.val) + let dbManagerDrawer = $derived(globalDbManagerDrawer.val) as any let filterUserFolders = $state(false) let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived( @@ -755,7 +755,7 @@
{/if} - showCreateButtons = v} /> + (showCreateButtons = v)} />
{ diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index 37d42ff03a..b5481f17fe 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -5,22 +5,26 @@ import InstanceSettings from '$lib/components/InstanceSettings.svelte' import { Alert, Button } from '$lib/components/common' import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte' - import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import { setupNavigationGroups, tabToCategoryMap, tabToAuthSubTab, - categoryToTabMap + categoryToTabMap, + buildSearchableSettingItems, + type SearchableSettingItem } from '$lib/components/instanceSettings' + import SettingsSearchInput from '$lib/components/instanceSettings/SettingsSearchInput.svelte' import Breadcrumb from '$lib/components/common/breadcrumb/Breadcrumb.svelte' import { ChevronRight, ArrowLeft } from 'lucide-svelte' import { superadmin } from '$lib/stores' + import { onDestroy, tick } from 'svelte' import { UserService, JobService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import TextInput from '$lib/components/text_input/TextInput.svelte' import Toggle from '$lib/components/Toggle.svelte' import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' + import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' const settingsSteps = [ { id: 'Core', label: 'Core' }, @@ -29,17 +33,35 @@ const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types'] + const fullStepLabels = ['Settings', 'Root login & Resource Types'] + const initialMode = $page.url.searchParams.get('mode') === 'full' ? 'full' : 'wizard' - const initialStep = Math.max(0, Math.min(parseInt($page.url.searchParams.get('step') ?? '0') || 0, wizardStepLabels.length - 1)) + const initialStep = Math.max( + 0, + Math.min(parseInt($page.url.searchParams.get('step') ?? '0') || 0, wizardStepLabels.length - 1) + ) + const initialFullStep = + initialMode === 'full' + ? Math.max( + 0, + Math.min( + parseInt($page.url.searchParams.get('step') ?? '0') || 0, + fullStepLabels.length - 1 + ) + ) + : 0 let mode: 'wizard' | 'full' = $state(initialMode) let wizardStep = $state(initialStep) + let fullStep = $state(initialFullStep) $effect(() => { const url = new URL(window.location.href) if (mode === 'wizard') { url.searchParams.set('step', String(wizardStep)) + url.searchParams.delete('mode') } else { - url.searchParams.delete('step') + url.searchParams.set('step', String(fullStep)) + url.searchParams.set('mode', 'full') } history.replaceState(history.state, '', url) }) @@ -85,7 +107,10 @@ } $effect(() => { - if (!isSettingsStep(wizardStep) && rtSyncStatus === 'idle') { + if ( + rtSyncStatus === 'idle' && + ((mode === 'wizard' && !isSettingsStep(wizardStep)) || (mode === 'full' && fullStep === 1)) + ) { syncCachedResourceTypes() } }) @@ -106,7 +131,12 @@ hubSyncStatus = 'success' hubSyncMessage = 'Resource types synced from hub successfully' } catch (e: any) { - hubSyncMessage = e?.body?.error?.message || e?.body?.message || (typeof e?.body === 'string' ? e.body : null) || e?.message || 'Failed to sync from hub' + hubSyncMessage = + e?.body?.error?.message || + e?.body?.message || + (typeof e?.body === 'string' ? e.body : null) || + e?.message || + 'Failed to sync from hub' hubSyncStatus = 'error' } } @@ -116,28 +146,70 @@ let passwordValid = $derived(newPassword.length >= 2) let accountFormValid = $derived(emailValid && passwordValid) + // --- EE license key warning --- + let showLicenseKeyWarning = $state(false) + let pendingNextCallback: (() => void) | undefined = $state(undefined) + + function isEeImage(): boolean { + const v = instanceSettings?.getVersion() ?? '' + return v.startsWith('EE') + } + + function isLicenseKeyEmpty(): boolean { + const key = instanceSettings?.getLicenseKey() ?? '' + return key.trim() === '' + } + // --- Full settings mode state --- let fullTab = $state('general') let instanceSettingsCategory = $derived(tabToCategoryMap[fullTab] ?? 'Core') let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso') let yamlMode = $state(false) - // --- Unsaved changes detection (full mode) --- - let pendingTab: string | undefined = $state(undefined) - let showUnsavedChangesModal = $state(false) - function handleNavigate(newTab: string) { if (newTab === fullTab) return - const currentCategory = tabToCategoryMap[fullTab] - if (currentCategory && instanceSettings?.isDirty(currentCategory)) { - pendingTab = newTab - showUnsavedChangesModal = true - } else { - fullTab = newTab + fullTab = newTab + } + + // --- Settings search (full mode) --- + const searchableItems = buildSearchableSettingItems(setupNavigationGroups) + + let scrollTimeout: ReturnType | undefined + let highlightTimeout: ReturnType | undefined + + async function handleSearchSelect(item: SearchableSettingItem) { + handleNavigate(item.tabId) + if (item.settingKey) { + clearTimeout(scrollTimeout) + clearTimeout(highlightTimeout) + await tick() + scrollTimeout = setTimeout(() => { + const el = document.querySelector(`[data-setting-key="${item.settingKey}"]`) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + el.classList.add('setting-highlight') + highlightTimeout = setTimeout(() => el.classList.remove('setting-highlight'), 2500) + } + }, 100) } } + onDestroy(() => { + clearTimeout(scrollTimeout) + clearTimeout(highlightTimeout) + }) + /** Check if we need to warn about missing EE license key before proceeding */ + function proceedFromCore(callback: () => void) { + const leavingSettings = + (mode === 'wizard' && wizardStep === 0) || (mode === 'full' && fullStep === 0) + if (leavingSettings && isEeImage() && isLicenseKeyEmpty()) { + pendingNextCallback = callback + showLicenseKeyWarning = true + return + } + saveAndProceed(callback) + } /** Auto-save dirty settings, then run the callback */ async function saveAndProceed(callback: () => void) { @@ -163,6 +235,7 @@ function switchToWizardMode() { yamlMode = false + fullStep = 0 mode = 'wizard' } @@ -250,6 +323,100 @@ } +{#snippet accountSetupContent()} + + +
+ +
+
+ Email + 0 && !emailValid ? 'Must be a valid email' : undefined} + size="md" + /> + {#if $superadmin} +

Current email: {$superadmin}

+ {/if} +
+
+ Password + 0 && !passwordValid + ? 'Must be at least 2 characters' + : undefined} + size="md" + /> +
+
+
+ + +
+ {#if rtSyncStatus === 'loading'} + + {:else if rtSyncStatus === 'success'} + + {rtSyncMessage} + + {:else if rtSyncStatus === 'error'} + + {rtSyncMessage} + + {/if} + +
+ +

+ Fetches the latest resource types directly from the Windmill Hub (requires internet + access). +

+
+ {#if hubSyncStatus === 'success'} + + {hubSyncMessage} + + {:else if hubSyncStatus === 'error'} + + {hubSyncMessage} + + {/if} + +

+ The daily schedule synchronizes resource types from the Hub every day at midnight UTC. +

+
+
+ + {#if accountError} + + {accountError} + + {/if} +
+{/snippet} +
{#if mode === 'wizard'} @@ -287,141 +454,69 @@ /> {/key} {:else} - - - -
- -
-
- Email - 0 && !emailValid ? 'Must be a valid email' : undefined} - size="md" - /> - {#if $superadmin} -

Current email: {$superadmin}

- {/if} -
-
- Password - 0 && !passwordValid - ? 'Must be at least 2 characters' - : undefined} - size="md" - /> -
-
-
- - -
- {#if rtSyncStatus === 'loading'} - - {:else if rtSyncStatus === 'success'} - - {rtSyncMessage} - - {:else if rtSyncStatus === 'error'} - - {rtSyncMessage} - - {/if} - -
- -

- Fetches the latest resource types directly from the Windmill Hub (requires - internet access). -

-
- {#if hubSyncStatus === 'success'} - - {hubSyncMessage} - - {:else if hubSyncStatus === 'error'} - - {hubSyncMessage} - - {/if} - -

- The daily schedule synchronizes resource types from the Hub every day at midnight - UTC. -

-
-
- - {#if accountError} - - {accountError} - - {/if} -
+ {@render accountSetupContent()} {/if}
{:else} - -
- + +
+ { + if (i < fullStep) { + saveAndProceed(() => { + yamlMode = false + fullStep = i + }) + } + }} + > + {#snippet separator()} + + {/snippet} + + {#if fullStep === 0} + + {/if}
- -
- {#if !yamlMode} -
- + {#if fullStep === 0} +
+ {#if !yamlMode} +
+ + +
+ {/if} + +
+ { + const targetTab = categoryToTabMap[category] + if (targetTab) { + handleNavigate(targetTab) + } + }} />
- {/if} - -
- { - const targetTab = categoryToTabMap[category] - if (targetTab) { - handleNavigate(targetTab) - } - }} - />
-
+ {:else} +
+ {@render accountSetupContent()} +
+ {/if} {/if} @@ -454,7 +549,7 @@ @@ -470,7 +565,7 @@ {/if}
- {:else} + {:else if fullStep === 0} - + + {:else} + + {/if}
@@ -496,29 +615,27 @@
-{#if showUnsavedChangesModal} +{#if showLicenseKeyWarning} { - showUnsavedChangesModal = false - pendingTab = undefined + showLicenseKeyWarning = false + pendingNextCallback = undefined }} on:confirmed={() => { - if (pendingTab !== undefined) { - const currentCategory = tabToCategoryMap[fullTab] - if (currentCategory) { - instanceSettings?.discardCategory(currentCategory) - } - fullTab = pendingTab - } - showUnsavedChangesModal = false - pendingTab = undefined + showLicenseKeyWarning = false + const cb = pendingNextCallback + pendingNextCallback = undefined + if (cb) saveAndProceed(cb) }} >
- You have unsaved changes. Are you sure you want to discard them? + + You are running the Enterprise Edition image but have not entered a license key. A valid + license key is required to use EE features. Are you sure you want to continue without one? +
{/if} diff --git a/frontend/svelte-fast-check.config.ts b/frontend/svelte-fast-check.config.ts new file mode 100644 index 0000000000..b28b46c413 --- /dev/null +++ b/frontend/svelte-fast-check.config.ts @@ -0,0 +1,5 @@ +import type { FastCheckConfig } from 'svelte-fast-check'; + +export default { + exclude: ['../src/lib/monaco_workers/**'] +} satisfies FastCheckConfig; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 52954ed612..8251e3ab69 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -6,19 +6,9 @@ "target": "esnext", "outDir": "build", "noUnusedLocals": true, - // "noUnusedParameters": true, - - /** - svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript - to enforce using \`import type\` instead of \`import\` for Types. - */ "isolatedModules": true, "resolveJsonModule": true, "noImplicitAny": false, - /** - To have warnings/errors of the Svelte compiler at the correct position, - enable source maps by default. - */ "sourceMap": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/integration_tests/test/agent_workers.py b/integration_tests/test/agent_workers.py index 4b0c616433..9ced69b5c5 100644 --- a/integration_tests/test/agent_workers.py +++ b/integration_tests/test/agent_workers.py @@ -166,15 +166,14 @@ class TestAgentWorkers(unittest.TestCase): print(f"Agent token tests for token: {token}") self.assertIsNotNone(token) - # JWT tokens have the format: jwt_agent__ - self.assertTrue(token.startswith("jwt_agent_"), "Token should start with jwt_agent_") + # JWT tokens have the format: jwt_agent_ + prefix = "jwt_agent_" + self.assertTrue(token.startswith(prefix), "Token should start with jwt_agent_") - # Test that it's a valid JWT format (should contain 2 dots in the JWT part) - parts = token.split('_') - self.assertGreaterEqual(len(parts), 3, "Token should have at least 3 parts separated by underscores") - - # The actual JWT is after the second underscore - jwt_part = parts[2] + # Extract the JWT by stripping the known prefix (don't split on '_' + # because base64url encoding uses '_' as a valid character) + jwt_part = token[len(prefix):] + self.assertGreater(len(jwt_part), 0, "JWT part should not be empty") self.assertEqual(jwt_part.count('.'), 2, "JWT should contain exactly 2 dots") # Check that the token contains three base64-encoded parts diff --git a/lsp/Pipfile b/lsp/Pipfile index b7273b29ee..962386beb7 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,8 +4,8 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.638.4" -wmill_pg = ">=1.638.4" +wmill = ">=1.642.0" +wmill_pg = ">=1.642.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index d27f86f25a..612151d752 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.638.4 + version: 1.642.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index abb270ac38..5e49354d9a 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.638.4' + ModuleVersion = '1.642.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 85bdbe8071..a0292fa174 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.638.4" +version = "1.642.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/python-client/wmill_pg/pyproject.toml b/python-client/wmill_pg/pyproject.toml index f5243b6043..1fd178b845 100644 --- a/python-client/wmill_pg/pyproject.toml +++ b/python-client/wmill_pg/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill-pg" -version = "1.638.4" +version = "1.642.0" description = "An extension client for the wmill client library focused on pg" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/scripts/worktree-cleanup b/scripts/worktree-cleanup new file mode 100755 index 0000000000..f4337f8b97 --- /dev/null +++ b/scripts/worktree-cleanup @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Remove the matching windmill-ee-private worktree if one exists +wt_basename=$(basename "$(pwd)") + +# Check parent directory first (sibling to worktree root), then fall back to home +parent_dir="$(cd "$(pwd)/.." && pwd)" +if [ -d "${parent_dir}/windmill-ee-private" ]; then + ee_repo="${parent_dir}/windmill-ee-private" +else + ee_repo="${HOME}/windmill-ee-private" +fi + +ee_worktree_dir="${ee_repo}__worktrees/${wt_basename}" +if [ -d "$ee_worktree_dir" ]; then + git -C "$ee_repo" worktree remove "$ee_worktree_dir" --force 2>/dev/null \ + && echo "Removed EE worktree at $ee_worktree_dir" \ + || echo "Warning: Could not remove EE worktree at $ee_worktree_dir" +fi diff --git a/scripts/worktree-env b/scripts/worktree-env new file mode 100755 index 0000000000..f1284674f8 --- /dev/null +++ b/scripts/worktree-env @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +port_in_use() { + lsof -nP -iTCP:"$1" -sTCP:LISTEN &>/dev/null +} + +find_port() { + local port=$1 + while port_in_use "$port"; do + ((port++)) + done + echo "$port" +} + +if [[ -z "${WM_SLOT:-}" ]]; then + # Auto-assign: find the first slot (1-99) where both ports are free + # Slot 0 (8000/3000) is reserved for the main worktree + for slot in $(seq 1 99); do + bp=$((8000 + slot * 10)) + fp=$((3000 + slot * 10)) + if ! port_in_use "$bp" && ! port_in_use "$fp"; then + WM_SLOT=$slot + break + fi + done + if [[ -z "${WM_SLOT:-}" ]]; then + echo "ERROR: No available slot found (tried 1-99)" >&2 + exit 1 + fi + echo "Auto-assigned slot $WM_SLOT" +fi + +# Slot-based: predictable ports for SSH forwarding +# Slot 0 = 8000/3000, slot 1 = 8010/3010, slot 2 = 8020/3020, etc. +backend_port=$((8000 + WM_SLOT * 10)) +frontend_port=$((3000 + WM_SLOT * 10)) + +if port_in_use "$backend_port" || port_in_use "$frontend_port"; then + echo "ERROR: Slot $WM_SLOT ports ($backend_port/$frontend_port) already in use" >&2 + exit 1 +fi + +# Generate .env.local with port overrides +cat > .env.local </dev/null || true) + wt_basename=$(basename "$(pwd)") + ee_worktree_dir="${ee_repo}__worktrees/${wt_basename}" + + if [ -n "$branch" ] && [ ! -d "$ee_worktree_dir" ]; then + mkdir -p "$(dirname "$ee_worktree_dir")" + + # Fetch latest so we can check out remote branches + git -C "$ee_repo" fetch --quiet 2>/dev/null || true + + # Try: existing branch, then new branch from main + if git -C "$ee_repo" worktree add "$ee_worktree_dir" "$branch" 2>/dev/null; then + echo "Created EE worktree at $ee_worktree_dir (branch: $branch)" + elif git -C "$ee_repo" worktree add -b "$branch" "$ee_worktree_dir" main 2>/dev/null; then + echo "Created EE worktree at $ee_worktree_dir (new branch: $branch from main)" + else + echo "Warning: Could not create EE worktree for branch $branch" + fi + elif [ -d "$ee_worktree_dir" ]; then + echo "EE worktree already exists at $ee_worktree_dir" + fi + + # Create symlinks from backend crates to the EE worktree + if [ -d "$ee_worktree_dir" ] && [ -x "./backend/substitute_ee_code.sh" ]; then + ./backend/substitute_ee_code.sh -d "$ee_worktree_dir" + fi +fi diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 631fe67240..65b3c47927 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.638.4", + "version": "1.642.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 4c3c5bbfb0..73a87dc17d 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.638.4", + "version": "1.642.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 41e2382e12..4be1988217 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.638.4 +1.642.0