diff --git a/.changeset/config.json b/.changeset/config.json
index 11fb2a9b..8d365c08 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -2,7 +2,9 @@
"$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json",
"_changelog": [
"@changesets/changelog-github",
- { "repo": "CynToolkit/pipelab" }
+ {
+ "repo": "CynToolkit/pipelab"
+ }
],
"privatePackages": {
"tag": true,
@@ -11,9 +13,8 @@
"changelog": "@changesets/changelog-git",
"commit": false,
"access": "public",
- "fixed": [],
+ "fixed": [["@pipelab/ui", "@pipelab/cli"]],
"linked": [],
"baseBranch": "main",
- "updateInternalDependencies": "patch",
- "ignore": []
+ "updateInternalDependencies": "patch"
}
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 7fb32ef9..0595b8d0 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -10,12 +10,10 @@
"ghcr.io/sebst/devcontainer-features/desktop-fluxbox:0": {},
"ghcr.io/devcontainers/features/desktop-lite:1": {}
},
- "forwardPorts": [
- 6080
- ],
+ "forwardPorts": [6080],
"portsAttributes": {
"6080": {
"label": "desktop"
}
}
-}
\ No newline at end of file
+}
diff --git a/.env.example b/.env.example
deleted file mode 100644
index 0b1c7eef..00000000
--- a/.env.example
+++ /dev/null
@@ -1,5 +0,0 @@
-SUPABASE_URL=
-SUPABASE_ANON_KEY=
-SUPABASE_PROJECT_ID=
-EDGE_FUNCTION_ROOT=
-POSTHOG_API_KEY=
diff --git a/.gemini/settings.json b/.gemini/settings.json
index 7bd98b24..c2691c88 100644
--- a/.gemini/settings.json
+++ b/.gemini/settings.json
@@ -2,13 +2,10 @@
"mcpServers": {
"primevue": {
"command": "npx",
- "args": [
- "-y",
- "@primevue/mcp"
- ]
+ "args": ["-y", "@primevue/mcp"]
},
"supabase": {
"httpUrl": "https://mcp.supabase.com/mcp?project_ref=uehmyyeqheqnxmzhctcd"
}
}
-}
\ No newline at end of file
+}
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
new file mode 100644
index 00000000..1e24f47d
--- /dev/null
+++ b/.github/workflows/pipeline.yml
@@ -0,0 +1,452 @@
+name: Pipeline
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+ workflow_dispatch:
+ inputs:
+ forcePublish:
+ description: "Force publish apps"
+ required: false
+ type: boolean
+ default: false
+ prerelease:
+ description: "Mark as pre-release"
+ required: false
+ type: boolean
+ default: true
+
+permissions:
+ contents: write
+ pull-requests: write
+ id-token: write
+
+jobs:
+ changes:
+ name: Detect Changes
+ runs-on: ubuntu-latest
+ outputs:
+ affected: ${{ steps.detect.outputs.affected }}
+ needs_build: ${{ steps.detect.outputs.needs_build }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+
+ - name: Use Node.js 24
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+
+ - name: Detect Changes
+ id: detect
+ run: |
+ if [ "${{ github.event_name }}" == "pull_request" ]; then
+ BASE_REF="origin/${{ github.base_ref }}"
+ elif [ "${{ github.ref_name }}" != "main" ] && [ "${{ github.ref_name }}" != "develop" ]; then
+ # On feature/integration branches, compare against main to see the full PR history
+ BASE_REF="origin/main"
+ else
+ # On main/develop push, only compare against the previous commit (delta)
+ BASE_REF="${{ github.event.before }}"
+ fi
+
+ # Fallback if BASE_REF is null or empty (initial push)
+ if [ -z "$BASE_REF" ] || [ "$BASE_REF" == "0000000000000000000000000000000000000000" ]; then
+ BASE_REF=$(git describe --tags --abbrev=0 2>/dev/null || echo "HEAD~10")
+ fi
+
+ echo "Comparing against: $BASE_REF"
+
+ # Use turbo to list affected packages
+ # We use npx to avoid full install if possible, but turbo needs the graph
+ npx turbo ls -F "[$BASE_REF]" --output=json > affected.json
+
+ # Run our detection script
+ npx tsx scripts/detect-changes.ts affected.json
+
+ verify-lint:
+ name: Verify Lint
+ needs: changes
+ if: needs.changes.outputs.affected != '[]'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+ - name: Use Node.js 24
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: "pnpm"
+ - name: Install dependencies
+ run: pnpm install
+ - name: Cache Turbo
+ uses: actions/cache@v4
+ with:
+ path: .turbo
+ key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-${{ github.job }}-
+ - name: Lint
+ run: pnpm turbo lint --cache-dir=".turbo"
+ continue-on-error: true
+
+ verify-typecheck:
+ name: Verify Typecheck
+ needs: changes
+ if: needs.changes.outputs.affected != '[]'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+ - name: Use Node.js 24
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: "pnpm"
+ - name: Install dependencies
+ run: pnpm install
+ - name: Cache Turbo
+ uses: actions/cache@v4
+ with:
+ path: .turbo
+ key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-${{ github.job }}-
+ - name: Typecheck
+ run: pnpm turbo typecheck --cache-dir=".turbo"
+ continue-on-error: true
+
+ build-all:
+ name: Build All
+ needs: [verify-lint, verify-typecheck, test-matrix, changes]
+ if: needs.changes.outputs.affected != '[]' && needs.changes.outputs.needs_build == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+ - name: Use Node.js 24
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: "pnpm"
+ - name: Install dependencies
+ run: pnpm install
+ - name: Cache Turbo
+ uses: actions/cache@v4
+ with:
+ path: .turbo
+ key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-${{ github.job }}-
+ - name: Build packages
+ env:
+ SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
+ SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
+ SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}
+ POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
+ run: pnpm build --cache-dir=".turbo"
+ - name: Upload monorepo-dist
+ uses: actions/upload-artifact@v4
+ with:
+ name: monorepo-dist
+ path: |
+ packages/*/dist/**
+ plugins/*/dist/**
+ apps/*/dist/**
+ apps/desktop/.vite/**
+ apps/ui/dist/**
+
+ publish-preview:
+ name: Publish Preview
+ needs: build-all
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+ - name: Use Node.js 24
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: "pnpm"
+ - name: Install dependencies
+ run: pnpm install
+ - name: Download monorepo-dist
+ uses: actions/download-artifact@v4
+ with:
+ name: monorepo-dist
+ path: .
+ - name: Publish to pkg.pr.new
+ run: |
+ npx pkg-pr-new publish \
+ packages/shared \
+ packages/core-node \
+ plugins/plugin-* \
+ assets/asset-* \
+ packages/migration \
+ packages/constants \
+ apps/cli \
+ --comment=update
+
+ release:
+ name: Release & Deploy
+ needs: [build-all, verify-lint, verify-typecheck, test-matrix, changes, build-desktop]
+ if: |
+ always() &&
+ (needs.build-desktop.result == 'success' || needs.build-desktop.result == 'skipped') &&
+ (needs.build-all.result == 'success' || needs.build-all.result == 'skipped') &&
+ needs.verify-lint.result == 'success' &&
+ needs.verify-typecheck.result == 'success' &&
+ needs.test-matrix.result == 'success' &&
+ (github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')))
+ runs-on: ubuntu-latest
+ outputs:
+ published: ${{ steps.changesets.outputs.published }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+
+ - name: Use Node.js 24
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: "pnpm"
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Download monorepo-dist
+ if: needs.changes.outputs.needs_build == 'true'
+ uses: actions/download-artifact@v4
+ with:
+ name: monorepo-dist
+ path: .
+
+ - name: Enter/Exit Changeset Pre-mode
+ run: |
+ if [ "${{ github.ref_name }}" == "develop" ]; then
+ if [ ! -f .changeset/pre.json ]; then
+ pnpm changeset pre enter beta
+ fi
+ elif [ "${{ github.ref_name }}" == "main" ]; then
+ if [ -f .changeset/pre.json ]; then
+ pnpm changeset pre exit
+ fi
+ fi
+
+ - name: Publish to NPM & Create GitHub Release
+ id: changesets
+ uses: changesets/action@v1
+ with:
+ publish: ${{ github.ref_name == 'develop' && 'pnpm changeset publish --tag beta --provenance' || 'pnpm changeset publish --provenance' }}
+ createGithubReleases: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+ NPM_CONFIG_PROVENANCE: true
+
+
+
+ - name: Download all artifacts
+ if: steps.changesets.outputs.published == 'true' || github.event.inputs.forcePublish == 'true'
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+ continue-on-error: true
+
+ - name: Debug Artifacts
+ run: ls -R artifacts
+ continue-on-error: true
+
+ - name: Get Versions
+ id: versions
+ run: |
+ APP_VERSION=$(node -p "require('./apps/desktop/package.json').version")
+ CLI_VERSION=$(node -p "require('./apps/cli/package.json').version")
+ ASSET_ELECTRON_VERSION=$(node -p "require('./assets/asset-electron/package.json').version")
+ ASSET_DISCORD_VERSION=$(node -p "require('./assets/asset-discord/package.json').version")
+ ASSET_NETLIFY_VERSION=$(node -p "require('./assets/asset-netlify/package.json').version")
+ ASSET_TAURI_VERSION=$(node -p "require('./assets/asset-tauri/package.json').version")
+ UI_VERSION=$(node -p "require('./apps/ui/package.json').version")
+ echo "app_version=$APP_VERSION" >> $GITHUB_OUTPUT
+ echo "cli_version=$CLI_VERSION" >> $GITHUB_OUTPUT
+ echo "asset_electron_version=$ASSET_ELECTRON_VERSION" >> $GITHUB_OUTPUT
+ echo "asset_discord_version=$ASSET_DISCORD_VERSION" >> $GITHUB_OUTPUT
+ echo "asset_netlify_version=$ASSET_NETLIFY_VERSION" >> $GITHUB_OUTPUT
+ echo "asset_tauri_version=$ASSET_TAURI_VERSION" >> $GITHUB_OUTPUT
+ echo "ui_version=$UI_VERSION" >> $GITHUB_OUTPUT
+
+ IS_PRERELEASE="false"
+ if [[ $APP_VERSION == *"-"* ]] || [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.prerelease }}" == "true" ]]; then
+ IS_PRERELEASE="true"
+ fi
+
+ echo "prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
+
+ - name: Upload Desktop Binaries
+ if: steps.changesets.outputs.published == 'true' || github.event.inputs.forcePublish == 'true'
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: "@pipelab/app@${{ steps.versions.outputs.app_version }}"
+ prerelease: ${{ steps.versions.outputs.prerelease == 'true' }}
+ files: |
+ artifacts/**/*.dmg
+ artifacts/**/*.exe
+ artifacts/**/*.zip
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Publish @pipelab/ui dynamically
+ if: steps.changesets.outputs.published == 'true' || github.event_name == 'workflow_dispatch'
+ run: node scripts/publish-app.mjs ui
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+ POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
+
+ - name: Publish @pipelab/cli dynamically
+ if: steps.changesets.outputs.published == 'true' || github.event_name == 'workflow_dispatch'
+ run: node scripts/publish-app.mjs cli
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+ POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
+
+ build-desktop:
+ name: Build Desktop (${{ matrix.os }} - ${{ matrix.arch }})
+ needs: [build-all, test-matrix, changes]
+ if: |
+ always() &&
+ (needs.build-all.result == 'success') &&
+ (needs.test-matrix.result == 'success' || needs.test-matrix.result == 'skipped') &&
+ (contains(fromJson(needs.changes.outputs.affected), '@pipelab/app') || github.event.inputs.forcePublish == 'true')
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ include:
+ - os: ubuntu-latest
+ arch: x64
+ - os: windows-latest
+ arch: x64
+ - os: macos-15-intel
+ arch: x64
+ - os: macos-latest
+ arch: arm64
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: "pnpm"
+ - run: pnpm install
+
+ - name: Download monorepo-dist
+ if: needs.changes.outputs.needs_build == 'true'
+ uses: actions/download-artifact@v4
+ with:
+ name: monorepo-dist
+ path: .
+
+ - name: Import Apple Code Signing Certificate
+ uses: apple-actions/import-codesign-certs@v3
+ if: startsWith(matrix.os, 'macos-')
+ with:
+ p12-file-base64: ${{ secrets.CERTIFICATE_OSX_APPLICATION }}
+ p12-password: ${{ secrets.CERTIFICATE_OSX_APPLICATION_PASSWORD }}
+
+ - name: Debug Architectures
+ run: |
+ echo "Host Architecture (uname -m): $(uname -m)"
+ echo "Host Architecture (node process.arch): $(node -p 'process.arch')"
+ echo "Target Architecture (matrix.arch): ${{ matrix.arch }}"
+ echo "Target Architecture (env.TARGET_ARCH): $TARGET_ARCH"
+ echo "Target Architecture (env.npm_config_arch): $npm_config_arch"
+ env:
+ TARGET_ARCH: ${{ matrix.arch }}
+ npm_config_arch: ${{ matrix.arch }}
+
+ - name: Package & Make Desktop
+ env:
+ DEBUG: "electron-osx-sign*,electron-forge:*"
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
+ SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
+ SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}
+ POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ TARGET_ARCH: ${{ matrix.arch }}
+ npm_config_arch: ${{ matrix.arch }}
+ run: |
+ cd apps/desktop
+ pnpm run make -- --arch=${{ matrix.arch }}
+
+ - name: Debug Output
+ run: ls -R apps/desktop/out/make
+ continue-on-error: true
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: desktop-artifacts-${{ matrix.os }}-${{ matrix.arch }}
+ path: apps/desktop/out/make/**/*
+
+ test-matrix:
+ name: Test Matrix (${{ matrix.os }})
+ needs: [changes]
+ if: |
+ always() &&
+ needs.changes.outputs.affected != '[]'
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ run_install: false
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: "pnpm"
+ - run: pnpm install
+
+ - name: Cache Turbo
+ uses: actions/cache@v4
+ with:
+ path: .turbo
+ key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-${{ github.job }}-
+ - name: Run Tests
+ run: pnpm turbo test --cache-dir=".turbo" --log-order=grouped --concurrency 1
diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml
deleted file mode 100644
index 58d89887..00000000
--- a/.github/workflows/push.yml
+++ /dev/null
@@ -1,161 +0,0 @@
-name: Electron Forge CI/CD
-
-on:
- push:
- tags:
- - "*"
- branches:
- - main
- - develop
- pull_request:
- branches: [main, develop]
- merge_group:
- workflow_dispatch:
-
-jobs:
- build-and-test:
- runs-on: ${{ matrix.os }}
- env:
- CI: true
- DEBUG: "*"
-
- strategy:
- matrix:
- os: [ubuntu-latest, windows-latest, macos-latest]
- arch: [x64, arm64]
- exclude:
- - os: ubuntu-latest
- arch: arm64
- - os: windows-latest
- arch: arm64
-
- steps:
- - uses: actions/checkout@v4
-
- - uses: pnpm/action-setup@v4
- name: Install pnpm
- with:
- run_install: false
-
- - name: Use Node.js 22.5.1
- uses: actions/setup-node@v4
- with:
- node-version: "22.5.1"
- cache: "pnpm"
-
- - name: Set up Python 3.10
- uses: actions/setup-python@v5
- with:
- python-version: "3.10"
-
- - name: Install dependencies
- run: pnpm install
-
- - name: rebuild
- run: npm rebuild
-
- - name: Import Apple Code Signing Certificate
- uses: apple-actions/import-codesign-certs@v3
- if: matrix.os == 'macos-latest'
- with:
- p12-file-base64: ${{ secrets.CERTIFICATE_OSX_APPLICATION }}
- p12-password: ${{ secrets.CERTIFICATE_OSX_APPLICATION_PASSWORD }}
-
- - name: Verify certificates
- if: matrix.os == 'macos-latest'
- run: |
- security find-identity -v -p codesigning
- codesign --verify --verbose /Applications/Xcode.app
-
- - name: Package application
- env:
- SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
- SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
- SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}
- POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
- APPLE_ID: ${{ secrets.APPLE_ID }}
- APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
- APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- run: pnpm electron-forge publish --dry-run --arch ${{ matrix.arch }}
-
- - name: Disable AppArmor restriction
- if: matrix.os == 'ubuntu-latest'
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
-
- - name: Run tests
- uses: coactions/setup-xvfb@v1
- continue-on-error: true
- with:
- run: pnpm test:e2e:raw
-
- - name: Upload Playwright artifacts
- uses: actions/upload-artifact@v4
- continue-on-error: true
- with:
- name: ${{ matrix.os }}-playwright
- path: playwright/**/*
-
- - name: windows workaround
- if: matrix.os == 'windows-latest'
- run: rd -r "out/Pipelab-win32-x64/resources/app/node_modules"
-
- - name: Upload artifacts
- if: success()
- continue-on-error: true
- uses: actions/upload-artifact@v4
- with:
- name: artifact-${{ matrix.os }}-${{ matrix.arch }}-out
- path: out/**/*
-
- release:
- permissions:
- contents: write
- discussions: write
- needs: build-and-test
- runs-on: ${{ matrix.os }}
-
- strategy:
- matrix:
- os: [ubuntu-latest, windows-latest, macos-latest]
- arch: [x64, arm64]
- exclude:
- - os: ubuntu-latest
- arch: arm64
- - os: windows-latest
- arch: arm64
- if: |
- !cancelled() &&
- (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch')
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Download artifacts
- uses: actions/download-artifact@v5
- with:
- name: artifact-${{ matrix.os }}-${{ matrix.arch }}-out
- path: out
-
- - uses: pnpm/action-setup@v4
- name: Install pnpm
- with:
- run_install: false
-
- - name: Use Node.js 22.5.1
- uses: actions/setup-node@v4
- with:
- node-version: "22.5.1"
- cache: "pnpm"
-
- - name: Install dependencies
- run: pnpm install --prod=false
-
- - name: windows workaround
- if: matrix.os == 'windows-latest'
- run: cd out/Pipelab-win32-x64/resources/app && pnpm i
-
- - name: Publish release
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: pnpm electron-forge publish --from-dry-run
diff --git a/.gitignore b/.gitignore
index 3a025da9..919a21cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -90,6 +90,9 @@ typings/
# Vite
.vite/
+# Build output
+dist/
+
# Electron-Forge
out/
@@ -106,3 +109,8 @@ assets/tauri/template/app/src/app
# Local Netlify folder
.netlify
+apps/cli/bin
+
+.turbo
+apps/desktop/bin/
+apps/cli/assets/ui
\ No newline at end of file
diff --git a/.mise.toml b/.mise.toml
index 394be3e8..09c0297f 100644
--- a/.mise.toml
+++ b/.mise.toml
@@ -1,20 +1,3 @@
[tools]
-node = "22"
-
-[env]
-_.file = '.env'
-
-[tasks.install]
-run = "pnpm install"
-
-[tasks.dev]
-run = "pnpm dev"
-depends = ["install"]
-
-[tasks.package]
-run = "pnpm package"
-depends = ["install"]
-
-[tasks.test-e2e-local]
-run = "pnpm test:e2e:raw"
-depends = ["package"]
\ No newline at end of file
+node = "24"
+pnpm = "10.33.0"
diff --git a/.prettierignore b/.prettierignore
deleted file mode 100644
index 9c6b791d..00000000
--- a/.prettierignore
+++ /dev/null
@@ -1,6 +0,0 @@
-out
-dist
-pnpm-lock.yaml
-LICENSE.md
-tsconfig.json
-tsconfig.*.json
diff --git a/.prettierrc.yaml b/.prettierrc.yaml
deleted file mode 100644
index 35893b3b..00000000
--- a/.prettierrc.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-singleQuote: true
-semi: false
-printWidth: 100
-trailingComma: none
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 00000000..17fb80cb
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,91 @@
+# Pipelab Architecture
+
+Pipelab has a modular architecture designed to support both a rich desktop experience and standalone execution in headless environments (like CI/CD pipelines).
+
+## High-Level Diagram
+
+The following diagram illustrates how the different packages in the monorepo interact, especially in the context of the Desktop Application.
+
+```mermaid
+graph TD
+ %% Define Packages
+ subgraph apps["Apps Workspace"]
+ Desktop["@pipelab/app
(Electron Shell)"]
+ UI["@pipelab/ui
(Vue 3 Web App)"]
+ CLI["@pipelab/cli
(Node.js Server/CLI)"]
+ end
+
+ subgraph packages["Packages Workspace"]
+ CoreNode["@pipelab/core-node
(Business Logic & Plugins)"]
+ Shared["@pipelab/shared
(Types, APIs, Utilities)"]
+ Constants["@pipelab/constants"]
+ end
+
+ %% Dependencies & Data Flow
+ User([User]) -->|Interacts with| UI
+
+ UI -->|1. Native Dialogs/OS Integration| Desktop
+ note1[IPC via Preload] -.-> UI
+
+ UI -->|2. Graph Execution & File IO| CLI
+ note2[WebSocket Connection] -.-> UI
+
+ Desktop -->|Spawns & Manages Lifecycle| CLI
+
+ CLI -->|Executes Logic| CoreNode
+ Desktop -->|Hooks Native Capabilities| CoreNode
+
+ %% Shared Dependencies
+ UI -.-> Shared
+ Desktop -.-> Shared
+ CLI -.-> Shared
+ CoreNode -.-> Shared
+
+ UI -.-> Constants
+ Desktop -.-> Constants
+ CLI -.-> Constants
+ CoreNode -.-> Constants
+```
+
+## Core Components
+
+### 1. `@pipelab/ui` (The Frontend)
+
+A standalone Vite + Vue 3 Single Page Application (SPA).
+
+- **Responsibility**: Rendering the visual node editor, settings, and pipeline management interfaces.
+- **Agnostic**: It does not import any Node.js or Electron-specific code.
+- **Routing**: It uses an intelligent API composable (`useAPI`) that routes requests:
+ - **Native OS Tasks** (e.g., Opening a file picker dialog) are sent to the Electron shell via IPC.
+ - **Core Tasks** (e.g., Executing a pipeline, reading/writing project files) are sent to the standalone CLI server via WebSockets.
+
+### 2. `@pipelab/app` (The Desktop Shell)
+
+A thin Electron wrapper around the UI and the CLI.
+
+- **Responsibility**: Providing OS-level integration (File Dialogs, Auto-updates, System Tray) and managing the lifecycle of the underlying CLI server.
+- **Startup Flow**:
+ 1. Electron starts up extremely fast as it loads minimal dependencies.
+ 2. It spawns the `@pipelab/cli` server as a background child process.
+ 3. It loads the `@pipelab/ui` web application in a `BrowserWindow`.
+- **Context Injection**: It injects native Electron capabilities (like `BrowserWindow` focus and `dialog` modules) into the shared `SystemContext` so the core logic can request UI prompts if necessary.
+
+### 3. `@pipelab/cli` (The Standalone Server & CLI)
+
+A Node.js command-line interface, bundled into standalone binaries using `pkg` for production.
+
+- **Responsibility**: Running the WebSocket server that the UI connects to, and eventually serving as a headless runner for CI/CD environments.
+- **Capabilities**: It has full file-system access and runs the heavy Node.js plugins (Docker, zip extraction, external command execution).
+
+### 4. `@pipelab/core-node` (The Brains)
+
+A shared library containing all the Node.js specific business logic.
+
+- **Responsibility**: Defining the WebSocket server, IPC handlers, plugin execution engine, and file system operations.
+- **Environment Agnostic**: It relies on an injected `SystemContext` to abstract away whether it is running inside an Electron main process or a headless CLI.
+
+### 5. `@pipelab/shared` & `@pipelab/constants`
+
+Shared libraries containing code that is safe to run in both Node.js and Browser environments.
+
+- **Responsibility**: Defining data models, IPC definitions, validation schemas, and common utilities used across the entire monorepo.
diff --git a/GEMINI.md b/GEMINI.md
index cbce0ca2..03c6b128 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -4,12 +4,13 @@ Pipelab is a visual automation tool designed to create task automation workflows
## Architecture Overview
-The project is an Electron-based application built with Vue 3 and TypeScript.
+The project is an Electron-based application built with Vue 3 and TypeScript, organized as a Turbo monorepo.
-- **Main Process (`src/main.ts`)**: Manages the Electron lifecycle, IPC handlers, a WebSocket server for real-time communication, and handles headless pipeline execution via command-line arguments.
-- **Renderer Process (`src/renderer/`)**: A Vue 3 application using PrimeVue for UI components. It communicates with the main process via IPC and WebSockets.
-- **Shared (`src/shared/`)**: Contains models, types, and utilities shared between the main and renderer processes.
-- **Core Engine**: Leverages `@pipelab/core` and QuickJS (via WebAssembly) for executing automation logic.
+- **Desktop App (`apps/desktop/`)**: Manages the Electron lifecycle, IPC handlers, a WebSocket server for real-time communication, and handles headless pipeline execution.
+- **UI Application (`apps/ui/`)**: A Vue 3 application using PrimeVue for UI components. It communicates with the desktop main process via IPC and WebSockets.
+- **CLI App (`apps/cli/`)**: A command-line interface for Pipelab.
+- **Packages (`packages/`)**: Contains shared logic, constants, plugins (Steam, Itch, etc.), and the core node system.
+- **Core Engine (`packages/core-node/` & `@pipelab/plugin-core`)**: Leverages `@pipelab/core` and QuickJS (via WebAssembly) for executing automation logic.
## Tech Stack
@@ -17,57 +18,65 @@ The project is an Electron-based application built with Vue 3 and TypeScript.
- **Frontend**: [Vue 3](https://vuejs.org/) (Composition API)
- **UI Library**: [PrimeVue v4](https://primevue.org/) with Tailwind/PrimeFlex
- **State Management**: [Pinia](https://pinia.vuejs.org/)
-- **Build Tool**: [Vite](https://vitejs.dev/) with [Electron Forge](https://www.electronforge.io/)
+- **Build Tool**: [Vite](https://vitejs.dev/) with [Electron Forge](https://www.electronforge.io/) and [Turborepo](https://turbo.build/)
- **Database/Backend**: [Supabase](https://supabase.com/)
- **Testing**: [Vitest](https://vitest.dev/) (Unit), [Playwright](https://playwright.dev/) (E2E)
+- **Linting & Formatting**: [oxlint](https://oxc-project.github.io/docs/guide/usage/linter.html) & [oxfmt](https://oxc-project.github.io/docs/guide/usage/formatter.html)
- **Analytics/Monitoring**: [PostHog](https://posthog.com/), [Sentry](https://sentry.io/)
## Key Directories
-- `src/main/`: Electron main process logic, IPC handlers, and API.
-- `src/renderer/`: Vue application source code (components, pages, store, etc.).
-- `src/shared/`: Shared types, constants, and utilities.
-- `assets/`: Static assets and build-related resources (icons, entitlements).
+- `apps/desktop/`: Electron main process logic, IPC handlers, and API.
+- `apps/ui/`: Vue application source code (components, pages, store, etc.).
+- `apps/cli/`: CLI entrypoints and commands.
+- `packages/`: Shared workspace packages (constants, migration code, and plugins).
- `scripts/`: Maintenance and migration scripts.
-- `tests/`: Unit and E2E tests.
+- `tests/`: End-to-end and unit tests.
## Building and Running
-The project uses `pnpm` as its package manager.
-
-| Task | Command |
-| :--- | :--- |
-| **Development** | `pnpm dev` (starts Electron with source maps enabled) |
-| **Build (All)** | `pnpm build` |
-| **Build (Linux)** | `pnpm build:linux` |
-| **Build (Windows)** | `pnpm build:win` |
-| **Build (Mac)** | `pnpm build:mac` |
-| **Package** | `pnpm package` |
-| **Unit Tests** | `pnpm test:unit` |
-| **E2E Tests** | `pnpm test:e2e:local` |
-| **Linting** | `pnpm lint` |
-| **Type Checking**| `pnpm typecheck` |
+The project uses `pnpm` as its package manager and Turborepo for task orchestration.
+
+| Task | Command |
+| :---------------- | :------------------------------------------ |
+| **Development** | `turbo dev` or `pnpm dev` |
+| **Build (All)** | `turbo build` or `pnpm build` |
+| **Package** | `turbo package` or `pnpm package` |
+| **Unit Tests** | `turbo test` or `pnpm test:unit` |
+| **Linting** | `turbo lint` or `pnpm lint` (runs `oxlint`) |
+| **Type Checking** | `turbo typecheck` or `pnpm typecheck` |
+| **Format** | `pnpm format` (runs `oxfmt .`) |
## Development Conventions
- **Typing**: Strict TypeScript usage across the codebase.
- **Components**: Functional and modular Vue components. PrimeVue is used for most UI elements.
- **State**: Persistent state management using Pinia with `pinia-plugin-persistedstate`.
-- **Communication**: Use the defined IPC handlers (`src/main/handlers.ts`) and the WebSocket manager for renderer-to-main or external communication.
+- **Communication**: Use the defined IPC handlers and the WebSocket manager for renderer-to-main or external communication.
- **Versioning**: Uses [Changesets](https://github.com/changesets/changesets) for managing versions and changelogs.
-- **Code Style**: Enforced by ESLint and Prettier. Run `pnpm format` to auto-format.
+- **Code Style**: Enforced by `oxlint` and `oxfmt`. Run `pnpm format` to auto-format.
## Common Workflows
-- **Adding a new Node/Block**: Investigate `src/shared/model.ts` and how nodes are registered in the core engine.
-- **Modifying the UI**: Most views are located in `src/renderer/pages/` or `src/renderer/components/`.
-- **Database Changes**: Update Supabase types using `pnpm supa:types` after modifying the remote schema.
+- **Adding a new Node/Block**: Investigate `plugins/plugin-core` or related plugins and how nodes are registered in the core engine.
+- `plugins/`: Contains all Pipelab plugins.
+- `assets/`: Contains all Pipelab assets.
+- **Modifying the UI**: Most views are located in `apps/ui/src/pages/` or `apps/ui/src/components/`.
+- **Database Changes**: Update Supabase types after modifying the remote schema.
- **Releases**:
1. `pnpm changeset` to document changes.
2. `pnpm changeset version` to bump versions.
3. `pnpm changeset tag` to tag the release.
+# General recommendations
-# General recomandations
- When modifying code, do not attempt to typecheck it.
- When modifying code, do not attempt to lint it.
+- When creating packages, ensure files are **not** accessed via subpaths (e.g. `import { useAPI } from "@pipelab/shared/api"`).
+- Do not read docs from packages irectly, use context7 mcp
+- Prefer your native file read tools over cat command
+- Do not grep unless strictly necessary
+- Use pnpm instead of npm
+- Never grep in gitignored folders (like node_modules or dist or out folders)
+- Think twice before proposing a solution or a plan: there may be evident flaws or simpler alternatives
+- Use rg/ripgrep instead of grep
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..3f204d7d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,110 @@
+# Functional Source License, Version 1.1, MIT Future License
+
+## Abbreviation
+
+FSL-1.1-MIT
+
+## Notice
+
+Copyright 2026 CynToolkit
+
+## Terms and Conditions
+
+### Licensor ("We")
+
+The party offering the Software under these Terms and Conditions.
+
+### The Software
+
+The "Software" is each version of the software that we make available under
+these Terms and Conditions, as indicated by our inclusion of these Terms and
+Conditions with the Software.
+
+### License Grant
+
+Subject to your compliance with this License Grant and the Patents,
+Redistribution and Trademark clauses below, we hereby grant you the right to
+use, copy, modify, create derivative works, publicly perform, publicly display
+and redistribute the Software for any Permitted Purpose identified below.
+
+### Permitted Purpose
+
+A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
+means making the Software available to others in a commercial product or
+service that:
+
+1. substitutes for the Software;
+
+2. substitutes for any other product or service we offer using the Software
+ that exists as of the date we make the Software available; or
+
+3. offers the same or substantially similar functionality as the Software.
+
+Permitted Purposes specifically include using the Software:
+
+1. for your internal use and access;
+
+2. for non-commercial education;
+
+3. for non-commercial research; and
+
+4. in connection with professional services that you provide to a licensee
+ using the Software in accordance with these Terms and Conditions.
+
+### Patents
+
+To the extent your use for a Permitted Purpose would necessarily infringe our
+patents, the license grant above includes a license under our patents. If you
+make a claim against any party that the Software infringes or contributes to
+the infringement of any patent, then your patent license to the Software ends
+immediately.
+
+### Redistribution
+
+The Terms and Conditions apply to all copies, modifications and derivatives of
+the Software.
+
+If you redistribute any copies, modifications or derivatives of the Software,
+you must include a copy of or a link to these Terms and Conditions and not
+remove any copyright notices provided in or with the Software.
+
+### Disclaimer
+
+THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
+PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
+
+IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
+SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
+EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
+
+### Trademarks
+
+Except for displaying the License Details and identifying us as the origin of
+the Software, you have no right under these Terms and Conditions to use our
+trademarks, trade names, service marks or product names.
+
+## Grant of Future License
+
+We hereby irrevocably grant you an additional license to use the Software under
+the MIT license that is effective on the second anniversary of the date we make
+the Software available. On or after that date, you may use the Software under
+the MIT license, in which case the following will apply:
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 87217926..649a0b9f 100644
--- a/README.md
+++ b/README.md
@@ -2,83 +2,92 @@

-## What is Pipelab?
+Pipelab is a visual automation tool designed to create task automation workflows and cross-platform desktop applications.
-A visual tool to create task automation workflows.
+## ποΈ Orchestration Overview
-## Why use Pipelab?
+Pipelab is built as a monorepo with three primary application layers that work together to provide a seamless automation experience:
-- Create cross-platform desktop applications
-- Deploy to popular platforms (Steam, Itch.io, etc.)
-- Automate repetitive tasks
+```mermaid
+graph TD
+ classDef main fill:#0096FF,stroke:#333,stroke-width:2px;
-# Getting Started
+ subgraph UserInterface["User Interface"]
+ UI["@pipelab/ui (Vue 3)"]
+ end
-# Making a release
-```
-pnpm changeset version
-pnpm changeset tag
+ subgraph Container["Desktop Container"]
+ Electron["@pipelab/app (Electron)"]
+ end
+
+ subgraph Engine["The Engine"]
+ CLI["@pipelab/cli (Node.js)"]
+ end
+
+ UI <-->|WebSocket| CLI
+ UI <-->|IPC| Electron
+ Electron ---|Spawns Sidecar| CLI
+ CLI ---|Executes| Pipelines[Automation Pipelines]
+
+ class UI,Electron,CLI main;
```
-# Architecture
-```mermaid
-graph TD
- classDef pipelab fill:#0096FF,stroke:#333,stroke-width:4px;
- classDef todo stroke:#333,stroke-width:4px, stroke-dasharray: 4px;
+- **The Engine (@pipelab/cli)**: A standalone Node.js server that handles the heavy lifting. It executes pipelines, manages plugin logic, and exposes a WebSocket API.
+- **The Interface (@pipelab/ui)**: A Vue 3 application that provides the visual graph editor. It connects to the CLI via WebSockets for real-time execution feedback.
+- **The Container (@pipelab/app)**: An Electron wrapper that provides native OS integration (file dialogs, system tray). In production, it automatically manages the CLI as a "sidecar" process.
- DesktopApp[Desktop App - Pipelab]
- GameBundle[Game Editor output]
+---
- subgraph GameEditors
- Construct3[Construct 3]
- Godot[Godot]
- GDevelop[GDevelop]
- end
+## π οΈ Setup & Development
- PipelabPlugin[Pipelab Plugin]
- SteamPlugin[Steam Plugin]
- CoreMessaging[Core Messaging Library]
- Renderers[Renderers]
+### 1. Prerequisites
- subgraph Runtime
- Electron
- Tauri
- Webview
- end
+Tool versions are managed via **mise**. Check [`.mise.toml`](.mise.toml) for the current requirements.
- subgraph Platforms
- Steam
- Itch
- Poki
- end
+### 2. Environment Configuration
- Steamworks[steamworks.js Library]
+Create a `.env` file in the **root directory**. This is the single source of truth for all packages:
- GameEditors -->|Bundles to| GameBundle
- GameBundle -->|Is imported into| DesktopApp
- GameEditors -->|Includes| PipelabPlugin
+```env
+SUPABASE_URL=your_project_url
+SUPABASE_ANON_KEY=your_key
+POSTHOG_API_KEY=your_key
+```
- PipelabPlugin -->|Is included in| GameBundle
- PipelabPlugin -->|Implements| CoreMessaging
+### 3. Installation
- SteamPlugin -->|Is included in| GameBundle
- SteamPlugin -->|Implements| CoreMessaging
- SteamPlugin -->|Uses| Steamworks
+```bash
+pnpm install
+```
- CoreMessaging -->|Passes messages to| Renderers
- Runtime -->|Is embedded in| Renderers
- DesktopApp -->|Packages to| Runtime
- Runtime -->|Handles events from| CoreMessaging
+### 4. Running Development Mode
- DesktopApp -->|Deploys to| Platforms
- Platforms -->|Uses| Runtime
+The fastest way to start the entire ecosystem is from the root:
- class DesktopApp,PipelabPlugin,SteamPlugin,CoreMessaging pipelab;
- class SteamPlugin,Godot,GDevelop,Tauri,Webview,Itch,Poki todo;
+```bash
+pnpm dev
```
-# Development
-## Enable source maps
+> [!TIP]
+> This command uses **Turborepo** to start the UI dev server, the Electron process, and the CLI server concurrently.
+
+---
+
+## π¦ Project Structure
+
+- **`apps/cli`**: The headless engine and WebSocket server.
+- **`apps/desktop`**: The Electron lifecycle and IPC handlers.
+- **`apps/ui`**: Rendering and visual graph interaction.
+- **`packages/*`**: Shared logic, standard constants, and modular plugins (Steam, Discord, etc.).
+
+---
+
+## π Releases & Versioning
+
+We use **Changesets** to manage versions and changelogs:
+
```bash
-NODE_OPTIONS=--enable-source-maps pnpm xxx
+pnpm changeset # Document a change
+pnpm changeset version # Bump versions
+pnpm changeset tag # Create git tags
```
diff --git a/TODO b/TODO
index 157eb323..e69de29b 100644
--- a/TODO
+++ b/TODO
@@ -1,74 +0,0 @@
-β IMPORTANT
- β To cross compile tauri or electron, I don't want a backend or my own CI
- β The idea is to have confgurations store online on your account
- β On Github Actions or any other CI service, have an action that is
- β pipelab/setup and pipelab/run that install all dependencies and that run a config
- β Infos are from the variables: account, pipeline & api key
- β It will run the pipeline as if it was your computer
-
-process.noAsar = true is here because electron fs can't deal correctly with asar (for example, like when copying an asar archive)
-
-https://github.com/nocode-js/sequential-workflow-editor
-https://github.com/xyflow/awesome-node-based-uis
-https://github.com/CatalystCode/windows-registry-node
-https://github.com/tympanix/electron-regedit
-
-β Target:
- Stage 1:
- β Autosave
- β Optimize/minimize images
- β https://www.google.com/search?q=sharp+lossless+compression+png&client=firefox-b-d&sxsrf=AB5stBg5O1Uihp_JBHyJnrcJEeF5sm2ZAg%3A1690961809752&ei=kQfKZImzLc2JkdUPqKWVkAs&ved=0ahUKEwjJv_7Pu72AAxXNRKQEHahSBbIQ4dUDCA4&uact=5&oq=sharp+lossless+compression+png&gs_lp=Egxnd3Mtd2l6LXNlcnAiHnNoYXJwIGxvc3NsZXNzIGNvbXByZXNzaW9uIHBuZzIKEAAYRxjWBBiwAzIKEAAYRxjWBBiwAzIKEAAYRxjWBBiwAzIKEAAYRxjWBBiwAzIKEAAYRxjWBBiwAzIKEAAYRxjWBBiwAzIKEAAYRxjWBBiwAzIKEAAYRxjWBBiwA0i5GVDSBFixGHABeAGQAQCYAQCgAQCqAQC4AQPIAQD4AQHiAwQYACBBiAYBkAYI&sclient=gws-wiz-serp
- β Package as electron (+ notarize + sign + expose api)
- Stage 2:
- β CLI
- β Github Action integration
- β Online Account
- β Package as tauri
- β Godot export
- β Package to playable ad
- β Package as discord activity
- β Load sensible datas from environnement variables for security purposes
- β Command palette
- β Undo/Redo
- β Upload to
- β poki
- β itch
-.
-
-EULA
-https://codemirror.net/examples/lint/
-https://remixicon.com/
-
-
----
-
-Tests:
-β View logs in realtime: started
-β env (= copy variables)
-β handle pipeline error gracefully
- β display what happened in dialog
-β process quickjs in webworker
-
-New version of the c3 export addons for improved version selection
-Edge case: old version of the app vs new version of c3p
-Activate save only when needed
-Active cancel button to not be locked out
-Simple mode: also allow to pick variables & such
-QuickJS: memory leak, open close action
-Add own timers for electorn export & such
-C3 Preview ne fonctionne pas
-Steam achievement test c3 example
-Filesystem plugin: overwrite parameter + better sentences/descriptions
-cancel running
-cleanup temp directories
-
-Convert dektop app to web app + node backend
-Communication via rest api (for now) or websocket
-
-vΓ©rifier que le fichier custom main existe sinon ca lance une erreur
-https://playwright.dev/docs/browsers#opt-in-to-new-headless-mode
-Docs sur Electron
-Docs sur les logs
-
-Support path `.local/share`: https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/filesystem
-Personalize tmp folder
diff --git a/TODO_MONOREPO.md b/TODO_MONOREPO.md
new file mode 100644
index 00000000..02928d1b
--- /dev/null
+++ b/TODO_MONOREPO.md
@@ -0,0 +1,127 @@
+# Pipelab Monorepo Migration - Roadmap & TODO
+
+The project has undergone a major architectural shift: splitting the monolithic Electron app into a standalone UI package (`@pipelab/ui`), a headless Node.js logic package (`@pipelab/core-node`), and a thin Electron shell (`@pipelab/app`).
+
+---
+
+pipielab run, must return the output as json if "--json" otherwise just return 0 if all done or number for error codee
+define a list of error codes
+
+## π’ Phase 1: Stabilization (CURRENT PRIORITY)
+
+Goal: Ensure the application remains 100% functional in both development and production environments.
+
+### 1. Dev Workflow Verification
+
+- [ ] Run `pnpm dev` from the root.
+- [ ] Verify UI server starts on `http://localhost:5173`.
+- [ ] Verify Electron window opens and successfully loads the UI.
+- [ ] Verify "Core" connection: Create a new pipeline and save it (should go through CLI server via WebSocket).
+- [ ] Verify "Shell" connection: Use "Choose a new path" in settings/project creation (should trigger Electron's native dialog via IPC).
+
+### 2. Production Build Verification
+
+- [ ] Run `pnpm turbo build`.
+- [ ] Run `pnpm --filter @pipelab/app package` (Electron Forge).
+- [ ] Verify that the `prePackage` hook correctly builds CLI binaries in `apps/cli/bin`.
+- [ ] Verify that CLI binaries are copied into the Electron `out/` resource folder.
+- [ ] Install/Run the packaged app and verify it can spawn the embedded CLI server.
+
+### 3. Critical Fixes
+
+- [ ] **Template Prefetch**: Implement prefetching of all necessary templates (`@pipelab/asset-electron`, etc.) before pipeline execution starts to fail fast with clear errors instead of failing mid-pipeline.
+- [ ] **Type Safety**: Resolve the 8 remaining type errors in `apps/ui` reported during `turbo typecheck` (mostly related to PrimeVue components and optional refs).
+- [ ] **External Deps**: Audit `apps/desktop/vite.base.config.mts`'s `external` list. Ensure no `@pipelab/*` packages are accidentally externalized in the bundle.
+- [ ] **IPC Routing**: Ensure `dialog:showOpenDialog` and `dialog:showSaveDialog` are correctly routed to Electron even when the UI is loaded from a remote URL.
+
+---
+
+## π‘ Phase 2: Monorepo Excellence
+
+Goal: Improve build speed, enforce boundaries, and standardize the developer experience.
+
+### 1. Centralized Configuration
+
+- [ ] Create `packages/tsconfig` to store base `tsconfig.json` configurations.
+- [ ] Create `packages/eslint-config` to share linting rules between UI and Node packages.
+- [ ] Update all `package.json` files to use these shared configs.
+
+### 2. TS Project References
+
+- [ ] Enable `composite: true` in all packages.
+- [ ] Add `references` arrays to `tsconfig.json` files to reflect the actual dependency graph.
+- [ ] Switch to `tsc --build` for lightning-fast incremental typechecking.
+
+### 4. Quality Gates
+
+- [ ] Implement `syncpack` to keep dependency versions identical across all packages.
+- [ ] Set up a GitHub Actions workflow to run `turbo build lint typecheck test` on every PR.
+- [ ] Ensure `pnpm run typecheck` passes with 0 errors at the root level.
+
+---
+
+## π΅ Phase 3: Architecture & Communication
+
+Goal: Modernize internal communication between UI, Core-Node, and Shell.
+
+### 1. RPC Migration
+
+- [ ] Investigate replacing manual WebSocket/IPC message serialization with [tRPC](https://trpc.io/).
+- [ ] Define shared tRPC routers in `@pipelab/shared`.
+- [ ] Implement tRPC server in `@pipelab/core-node`.
+- [ ] Update `@pipelab/ui` to consume tRPC hooks/composables instead of raw WebSockets.
+
+### 2. CLI Authentication & Backend-First Auth [IN PROGRESS]
+
+- [ ] Implement `pipelab login` command for interactive CLI authentication.
+- [ ] Implement `pipelab login --token ` command for headless/CI environments.
+- [ ] Implement database schema/API for generating and validating long-lived Personal Access Tokens (PATs).
+
+### 3. Benefits Management Centralization
+
+- [ ] Move benefits mapping (IDs to names) and entitlement logic to `@pipelab/core-node`.
+- [ ] Implement `BenefitsManager` in the backend to calculate user entitlements based on Supabase subscriptions.
+- [ ] Implement persistent dev overrides in the backend (stored in a JSON file instead of `localStorage`).
+- [ ] Define IPC channels (`benefits:get`, `benefits:setOverride`) for UI interaction.
+- [ ] Update UI (`useAuth` store) to react to `benefits:updated` WebSocket events from the backend.
+
+---
+
+## π£ Phase 4: Feature Parity & Stability
+
+Goal: Reach feature parity with the legacy monolithic app and improve core functionality.
+
+### 1. Core Engine Improvements
+
+- [ ] **Autosave**: Implement automatic pipeline saving.
+- [ ] **Undo/Redo**: Add history support for the node editor.
+- [ ] **Error Handling**: Gracefully handle and display pipeline execution errors in dialogs.
+- [ ] **Async Execution**: Investigate processing QuickJS logic in Web Workers to prevent UI blocking.
+- [ ] **Memory Management**: Audit and resolve potential memory leaks in QuickJS (open/close actions).
+- [ ] **Cleanup**: Implement automatic temporary directory cleanup (`tmp/`).
+- [ ] **ASAR Handling**: Ensure `process.noAsar = true` is correctly set and handled for cross-app file copies.
+
+### 2. Node & Plugin Enhancements
+
+- [ ] **Image Optimization**: Integrate `sharp` for lossless compression in relevant nodes.
+- [ ] **Filesystem Expansion**: Support `.local/share` paths and better overwrite parameters.
+- [ ] **C3 Integration**: Fix C3 Preview issues and improve version selection for C3P exports.
+- [ ] **Steam/Discord**: Verify Steam achievement integrations and investigate "Discord Activity" packaging.
+
+---
+
+## βοΈ Phase 5: Cloud & Ecosystem
+
+Goal: Expand Pipelab beyond the local desktop environment.
+
+### 1. CI/CD Integration
+
+- [ ] **GitHub Actions**: Create `pipelab/setup` and `pipelab/run` actions for automated pipeline runs.
+- [ ] **Interactive CLI**: Implement `pipelab login` for local CLI authentication.
+- [ ] **Headless Execution**: Ensure pipelines can run in CI environments using environment variables/PATs for sensitive data.
+
+### 2. Cross-Platform & Distribution
+
+- [ ] **Cloud Sync**: Store configurations and pipelines in the user's online account for cross-machine access.
+- [ ] **Alternative Packaging**: Investigate packaging as **Tauri** for smaller binary sizes or **Godot Export** for game-integrated pipelines.
+- [ ] **Publishing**: Expand specialized upload nodes for **Poki**, **Itch**, etc.
diff --git a/apps/cli/.npmrc b/apps/cli/.npmrc
new file mode 100644
index 00000000..c483022c
--- /dev/null
+++ b/apps/cli/.npmrc
@@ -0,0 +1 @@
+shamefully-hoist=true
\ No newline at end of file
diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md
new file mode 100644
index 00000000..f5797b64
--- /dev/null
+++ b/apps/cli/CHANGELOG.md
@@ -0,0 +1,418 @@
+# @pipelab/cli
+
+## 2.0.1-latest.44
+
+### Patch Changes
+
+- sdq
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.41
+ - @pipelab/core-node@1.0.1-latest.45
+ - @pipelab/shared@2.0.1-latest.42
+
+## 2.0.1-latest.43
+
+### Patch Changes
+
+- qsd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.40
+ - @pipelab/core-node@1.0.1-latest.44
+ - @pipelab/shared@2.0.1-latest.41
+
+## 2.0.1-latest.42
+
+### Patch Changes
+
+- hyg
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.39
+ - @pipelab/core-node@1.0.1-latest.43
+ - @pipelab/shared@2.0.1-latest.40
+
+## 2.0.1-latest.41
+
+### Patch Changes
+
+- sdf
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.38
+ - @pipelab/core-node@1.0.1-latest.42
+ - @pipelab/shared@2.0.1-latest.39
+
+## 2.0.1-latest.40
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.37
+ - @pipelab/core-node@1.0.1-latest.41
+ - @pipelab/shared@2.0.1-latest.38
+
+## 2.0.1-latest.39
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.36
+ - @pipelab/core-node@1.0.1-latest.40
+ - @pipelab/shared@2.0.1-latest.37
+
+## 2.0.1-latest.38
+
+### Patch Changes
+
+- qsd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.35
+ - @pipelab/core-node@1.0.1-latest.39
+ - @pipelab/shared@2.0.1-latest.36
+
+## 2.0.1-latest.37
+
+### Patch Changes
+
+- sdfc
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.34
+ - @pipelab/core-node@1.0.1-latest.38
+ - @pipelab/shared@2.0.1-latest.35
+
+## 2.0.1-latest.36
+
+### Patch Changes
+
+- qsd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.33
+ - @pipelab/core-node@1.0.1-latest.37
+ - @pipelab/shared@2.0.1-latest.34
+
+## 2.0.1-latest.35
+
+### Patch Changes
+
+- sdq
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.32
+ - @pipelab/core-node@1.0.1-latest.36
+ - @pipelab/shared@2.0.1-latest.33
+
+## 2.0.1-latest.34
+
+### Patch Changes
+
+- ad
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.31
+ - @pipelab/core-node@1.0.1-latest.35
+ - @pipelab/shared@2.0.1-latest.32
+
+## 2.0.1-latest.33
+
+### Patch Changes
+
+- sdqsd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.30
+ - @pipelab/core-node@1.0.1-latest.34
+ - @pipelab/shared@2.0.1-latest.31
+
+## 2.0.1-latest.32
+
+### Patch Changes
+
+- sqd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.29
+ - @pipelab/core-node@1.0.1-latest.33
+ - @pipelab/shared@2.0.1-latest.30
+
+## 2.0.1-beta.31
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.28
+ - @pipelab/core-node@1.0.1-beta.32
+ - @pipelab/shared@2.0.1-beta.29
+
+## 2.0.1-beta.30
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.22
+ - @pipelab/constants@1.0.1-beta.27
+ - @pipelab/core-node@1.0.1-beta.31
+ - @pipelab/shared@2.0.1-beta.28
+
+## 2.0.1-beta.29
+
+### Patch Changes
+
+- df
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.21
+ - @pipelab/constants@1.0.1-beta.26
+ - @pipelab/core-node@1.0.1-beta.30
+ - @pipelab/shared@2.0.1-beta.27
+
+## 2.0.1-beta.28
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.20
+ - @pipelab/constants@1.0.1-beta.25
+ - @pipelab/core-node@1.0.1-beta.29
+ - @pipelab/shared@2.0.1-beta.26
+
+## 2.0.1-beta.27
+
+### Patch Changes
+
+- dfg
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.19
+ - @pipelab/constants@1.0.1-beta.24
+ - @pipelab/core-node@1.0.1-beta.28
+ - @pipelab/shared@2.0.1-beta.25
+
+## 2.0.1-beta.26
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.18
+ - @pipelab/constants@1.0.1-beta.23
+ - @pipelab/core-node@1.0.1-beta.27
+ - @pipelab/shared@2.0.1-beta.24
+
+## 2.0.1-beta.25
+
+### Patch Changes
+
+- xwc
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.17
+ - @pipelab/constants@1.0.1-beta.22
+ - @pipelab/core-node@1.0.1-beta.26
+ - @pipelab/shared@2.0.1-beta.23
+
+## 2.0.1-beta.24
+
+### Patch Changes
+
+- sdsd
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.16
+ - @pipelab/constants@1.0.1-beta.21
+ - @pipelab/core-node@1.0.1-beta.25
+ - @pipelab/shared@2.0.1-beta.22
+
+## 2.0.1-beta.23
+
+### Patch Changes
+
+- gf
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.15
+ - @pipelab/constants@1.0.1-beta.20
+ - @pipelab/core-node@1.0.1-beta.24
+ - @pipelab/shared@2.0.1-beta.21
+
+## 2.0.1-beta.22
+
+### Patch Changes
+
+- azs
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.14
+ - @pipelab/constants@1.0.1-beta.19
+ - @pipelab/core-node@1.0.1-beta.23
+ - @pipelab/shared@2.0.1-beta.20
+
+## 2.0.1-beta.21
+
+### Patch Changes
+
+- sqqd
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.13
+ - @pipelab/constants@1.0.1-beta.18
+ - @pipelab/core-node@1.0.1-beta.22
+ - @pipelab/shared@2.0.1-beta.19
+
+## 2.0.1-beta.20
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.12
+ - @pipelab/constants@1.0.1-beta.17
+ - @pipelab/core-node@1.0.1-beta.21
+ - @pipelab/shared@2.0.1-beta.18
+
+## 2.0.1-beta.19
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.11
+ - @pipelab/constants@1.0.1-beta.16
+ - @pipelab/core-node@1.0.1-beta.20
+ - @pipelab/shared@2.0.1-beta.17
+
+## 2.0.1-beta.18
+
+### Patch Changes
+
+- wdf
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.10
+ - @pipelab/constants@1.0.1-beta.15
+ - @pipelab/core-node@1.0.1-beta.19
+ - @pipelab/shared@2.0.1-beta.16
+
+## 2.0.1-beta.17
+
+### Patch Changes
+
+- sdf
+- Updated dependencies
+ - @pipelab/test-utils@1.0.1-beta.9
+ - @pipelab/constants@1.0.1-beta.14
+ - @pipelab/core-node@1.0.1-beta.18
+ - @pipelab/shared@2.0.1-beta.15
+
+## 2.0.1-beta.16
+
+### Patch Changes
+
+- Updated dependencies
+ - @pipelab/core-node@1.0.1-beta.17
+
+## 2.0.1-beta.15
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.14
+
+### Patch Changes
+
+- rfg
+
+## 2.0.1-beta.13
+
+### Patch Changes
+
+- szd
+
+## 2.0.1-beta.12
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.11
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.9
+ - @pipelab/core-node@1.0.1-beta.10
+ - @pipelab/shared@2.0.1-beta.10
+
+## 2.0.1-beta.10
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.8
+ - @pipelab/core-node@1.0.1-beta.9
+ - @pipelab/shared@2.0.1-beta.9
+
+## 2.0.1-beta.9
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.7
+ - @pipelab/core-node@1.0.1-beta.8
+ - @pipelab/shared@2.0.1-beta.8
+
+## 2.0.1-beta.8
+
+## 2.0.1-beta.7
+
+### Patch Changes
+
+- test
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.6
+ - @pipelab/core-node@1.0.1-beta.7
+ - @pipelab/shared@2.0.1-beta.7
+
+## 2.0.1-beta.6
+
+### Patch Changes
+
+- test
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.5
+ - @pipelab/core-node@1.0.1-beta.6
+ - @pipelab/shared@2.0.1-beta.6
+
+## 2.0.1-beta.5
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.4
+
+### Patch Changes
+
+- tesyt
+
+## 2.0.1-beta.3
+
+### Patch Changes
+
+- last test
+
+## 2.0.1-beta.2
+
+## 2.0.1-beta.1
+
+### Patch Changes
+
+- 9268b1e: test 3
+
+## 2.0.1-beta.0
+
+### Patch Changes
+
+- test
+
+## 2.0.1-beta.0
+
+## 2.0.0
+
+### Major Changes
+
+- V2 Release: Major version bump for application packages.
diff --git a/apps/cli/README.md b/apps/cli/README.md
new file mode 100644
index 00000000..cdf3922a
--- /dev/null
+++ b/apps/cli/README.md
@@ -0,0 +1,23 @@
+# @pipelab/cli (The Engine)
+
+The standalone engine of Pipelab. It provides the core automation logic and serves as the backend for the visual editor.
+
+## βοΈ Core Roles
+
+1. **WebSocket Server**: When started with `serve`, it acts as a backend for the `@pipelab/ui`. It handles graph execution, real-time logging, and state synchronization.
+2. **Headless runner**: When started with `run `, it can execute a Pipelab pipeline (.json) directly from the terminal without any UI.
+3. **Plugin Host**: Manages the execution context for all Pipelab plugins, including the QuickJS-based virtual environment.
+
+## π οΈ Development
+
+### Setup
+
+The CLI requires Supabase variables to be "baked in" during the build process. Ensure your root `.env` is populated before building or running `pnpm dev`.
+
+### Commands
+
+```bash
+pnpm dev # Start the server in development mode
+pnpm build # Generate the production CJS bundle
+pnpm pkg # Create a standalone executable in the /bin folder
+```
diff --git a/apps/cli/package.json b/apps/cli/package.json
new file mode 100644
index 00000000..c24ec96b
--- /dev/null
+++ b/apps/cli/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@pipelab/cli",
+ "version": "2.0.1-latest.44",
+ "private": true,
+ "description": "The command line interface for Pipelab",
+ "license": "FSL-1.1-MIT",
+ "author": "CynToolkit",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/CynToolkit/pipelab.git",
+ "directory": "apps/cli"
+ },
+ "bin": {
+ "pipelab": "./src/index.ts",
+ "plab": "./src/index.ts"
+ },
+ "type": "module",
+ "scripts": {
+ "build": "cross-env NODE_ENV=production tsdown",
+ "dev": "cross-env NODE_ENV=development tsx watch src/index.ts",
+ "serve": "cross-env NODE_ENV=development tsx watch src/index.ts serve",
+ "watch": "tsdown --watch",
+ "start": "tsx src/index.ts",
+ "test": "vitest run -c tests/e2e/vitest.config.mts",
+ "format": "oxfmt .",
+ "lint": "oxlint .",
+ "typecheck": "tsc --noEmit -p tsconfig.json --composite false"
+ },
+ "dependencies": {
+ "@clack/prompts": "1.2.0",
+ "@pipelab/constants": "workspace:*",
+ "@pipelab/core-node": "workspace:*",
+ "@pipelab/shared": "workspace:*",
+ "commander": "^12.1.0",
+ "dotenv": "17.4.2",
+ "execa": "9.5.1",
+ "nanoid": "5.0.8",
+ "posthog-node": "5.30.6",
+ "serve-handler": "6.1.7"
+ },
+ "devDependencies": {
+ "@pipelab/test-utils": "workspace:*",
+ "@pipelab/tsconfig": "workspace:*",
+ "@types/node": "24.12.2",
+ "cross-env": "7.0.3",
+ "tsdown": "0.21.2",
+ "typescript": "^5.0.0",
+ "vitest": "3.1.4"
+ },
+ "test": {
+ "env": {
+ "NODE_ENV": "development"
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/cli/src/commands/history.ts b/apps/cli/src/commands/history.ts
new file mode 100644
index 00000000..0dc73c59
--- /dev/null
+++ b/apps/cli/src/commands/history.ts
@@ -0,0 +1,43 @@
+import { PipelabContext, BuildHistoryStorage } from "@pipelab/core-node";
+import { getDefaultUserDataPath } from "../paths";
+import { BuildHistoryEntry } from "@pipelab/shared";
+
+export async function historyCommand(
+ pipelineId: string | undefined,
+ options: { get?: string; userData?: string; limit?: number },
+) {
+ const userDataPath = options.userData || getDefaultUserDataPath();
+ const context = new PipelabContext({ userDataPath });
+ const storage = new BuildHistoryStorage(context);
+
+ if (options.get) {
+ const entry = await storage.get(options.get, pipelineId);
+ if (entry) {
+ console.log(JSON.stringify(entry, null, 2));
+ } else {
+ console.error(`Build history entry with ID "${options.get}" not found.`);
+ process.exit(1);
+ }
+ } else if (pipelineId) {
+ const entries = await storage.getByPipeline(pipelineId);
+ if (entries.length > 0) {
+ const limit = options.limit || 10;
+ const recentEntries = entries.slice(-limit);
+
+ const formatted = recentEntries.map((e: BuildHistoryEntry) => ({
+ ID: e.id,
+ Status: e.status,
+ "Start Time": new Date(e.startTime).toLocaleString(),
+ Duration: e.duration ? `${(e.duration / 1000).toFixed(2)}s` : "N/A",
+ }));
+ console.table(formatted);
+ } else {
+ console.log(`No history found for pipeline "${pipelineId}".`);
+ }
+ } else {
+ console.error(
+ "Please provide a pipeline ID to list its history, or use the --get option with a build ID.",
+ );
+ process.exit(1);
+ }
+}
diff --git a/apps/cli/src/commands/maintenance.ts b/apps/cli/src/commands/maintenance.ts
new file mode 100644
index 00000000..f62dd9de
--- /dev/null
+++ b/apps/cli/src/commands/maintenance.ts
@@ -0,0 +1,61 @@
+import { PipelabContext, BuildHistoryStorage } from "@pipelab/core-node";
+import { getDefaultUserDataPath } from "../paths";
+
+function formatBytes(bytes: number, decimals = 2) {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
+}
+
+export async function usageCommand(options: { userData?: string }) {
+ const userDataPath = options.userData || getDefaultUserDataPath();
+ const context = new PipelabContext({ userDataPath });
+ const storage = new BuildHistoryStorage(context);
+
+ const info = await storage.getStorageInfo();
+
+ console.log("Build History Storage Usage:");
+ console.table({
+ "Total Entries": info.totalEntries,
+ "Pipelines with History": info.numberOfPipelines,
+ "Total Size": formatBytes(info.totalSize),
+ "Oldest Entry": info.oldestEntry ? new Date(info.oldestEntry).toLocaleString() : "N/A",
+ "Newest Entry": info.newestEntry ? new Date(info.newestEntry).toLocaleString() : "N/A",
+ "User Data Path": info.userDataPath,
+ "Cache Path": info.cachePath,
+ });
+
+ console.log("\nRetention Policy:");
+ console.table({
+ Enabled: info.retentionPolicy.enabled ? "Yes" : "No",
+ "Max Entries": info.retentionPolicy.maxEntries > 0 ? info.retentionPolicy.maxEntries : "Unlimited",
+ "Max Age (days)": info.retentionPolicy.maxAge > 0 ? info.retentionPolicy.maxAge : "Unlimited",
+ });
+}
+
+export async function purgeCommand(
+ pipelineId: string | undefined,
+ options: { force?: boolean; userData?: string },
+) {
+ if (!options.force) {
+ console.error(
+ "This is a destructive operation. Please use the --force flag to confirm you want to purge the history.",
+ );
+ process.exit(1);
+ }
+
+ const userDataPath = options.userData || getDefaultUserDataPath();
+ const context = new PipelabContext({ userDataPath });
+ const storage = new BuildHistoryStorage(context);
+
+ if (pipelineId) {
+ await storage.clearByPipeline(pipelineId);
+ console.log(`Successfully purged history for pipeline: ${pipelineId}`);
+ } else {
+ await storage.clear();
+ console.log("Successfully purged all build history.");
+ }
+}
diff --git a/apps/cli/src/commands/pipelines.ts b/apps/cli/src/commands/pipelines.ts
new file mode 100644
index 00000000..01231336
--- /dev/null
+++ b/apps/cli/src/commands/pipelines.ts
@@ -0,0 +1,250 @@
+import { PipelabContext, setupConfigFile } from "@pipelab/core-node";
+import { FileRepo, SaveLocation } from "@pipelab/shared";
+import { readFile, unlink } from "node:fs/promises";
+import { getDefaultUserDataPath } from "../paths";
+
+export async function listPipelinesCommand(options: { userData?: string }) {
+ const userDataPath = options.userData || getDefaultUserDataPath();
+ const context = new PipelabContext({ userDataPath });
+
+ try {
+ const projectsConfig = await setupConfigFile("projects", { context });
+ const repo = await projectsConfig.getConfig();
+
+ if (!repo.pipelines || repo.pipelines.length === 0) {
+ console.log("No pipelines found.");
+ return;
+ }
+
+ console.log(`Found ${repo.pipelines.length} pipelines:\n`);
+
+ for (const pipeline of repo.pipelines) {
+ let content: any;
+ try {
+ if (pipeline.type === "internal") {
+ const path = context.getConfigPath(`${pipeline.configName}.json`);
+ content = JSON.parse(await readFile(path, "utf-8"));
+ } else if (pipeline.type === "external") {
+ content = JSON.parse(await readFile(pipeline.path, "utf-8"));
+ }
+ } catch (e: any) {
+ console.log(`--------------------------------------------------------------------------------`);
+ console.log(`Error: Could not read pipeline ${pipeline.id}`);
+ if (pipeline.type === "external") {
+ console.log(` Expected Path: ${pipeline.path}`);
+ }
+ console.log(` Reason: ${e.message}`);
+ continue;
+ }
+
+ const plugins = new Set();
+ if (content?.canvas?.blocks) {
+ for (const block of content.canvas.blocks) {
+ if (block.origin?.pluginId) {
+ plugins.add(block.origin.pluginId);
+ }
+ }
+ }
+
+ const name = content?.name || "Unnamed Pipeline";
+
+ console.log(`--------------------------------------------------------------------------------`);
+ console.log(`${name} (${pipeline.id})`);
+ if (pipeline.type === "external") {
+ console.log(` Path: ${pipeline.path}`);
+ }
+ console.log(` Plugins: ${Array.from(plugins).join(", ") || "None"}`);
+ }
+ } catch (error: any) {
+ console.error("Failed to list pipelines:", error.message);
+ }
+}
+
+export async function showPipelineCommand(
+ idOrName: string,
+ options: { detailed?: boolean; userData?: string },
+) {
+ const userDataPath = options.userData || getDefaultUserDataPath();
+ const context = new PipelabContext({ userDataPath });
+
+ try {
+ const projectsConfig = await setupConfigFile("projects", { context });
+ const repo = await projectsConfig.getConfig();
+
+ const pipeline = repo.pipelines.find(
+ (p) =>
+ p.id === idOrName ||
+ (p.type === "internal" && p.configName === idOrName) ||
+ (p.type === "external" && p.path.includes(idOrName)),
+ );
+
+ if (!pipeline) {
+ console.error(`Pipeline "${idOrName}" not found.`);
+ process.exit(1);
+ }
+
+ let content: any;
+ try {
+ if (pipeline.type === "internal") {
+ const path = context.getConfigPath(`${pipeline.configName}.json`);
+ content = JSON.parse(await readFile(path, "utf-8"));
+ } else if (pipeline.type === "external") {
+ content = JSON.parse(await readFile(pipeline.path, "utf-8"));
+ }
+ } catch (e: any) {
+ console.error(`Error: Could not read pipeline file: ${e.message}`);
+ process.exit(1);
+ }
+
+ console.log(`\nPipeline: ${content?.name || "Unnamed"}`);
+ console.log(`ID: ${pipeline.id}`);
+ console.log(`Type: ${pipeline.type}`);
+ if (pipeline.type === "external") {
+ console.log(`Path: ${pipeline.path}`);
+ } else if (pipeline.type === "internal") {
+ console.log(`Config: ${pipeline.configName}.json`);
+ }
+
+ const plugins = new Set();
+ const blockCount = content?.canvas?.blocks?.length || 0;
+ const triggerCount = content?.canvas?.triggers?.length || 0;
+
+ if (content?.canvas?.blocks) {
+ for (const block of content.canvas.blocks) {
+ if (block.origin?.pluginId) {
+ plugins.add(block.origin.pluginId);
+ }
+ }
+ }
+
+ console.log(`Plugins: ${Array.from(plugins).join(", ") || "None"}`);
+ console.log(`Blocks: ${blockCount}`);
+ console.log(`Triggers: ${triggerCount}`);
+
+ if (options.detailed) {
+ console.log(`\n--- Detailed Information ---`);
+ console.log(`Version: ${content?.version || "Unknown"}`);
+ console.log(`Description: ${content?.description || "No description"}`);
+
+ if (content?.variables && content.variables.length > 0) {
+ console.log(`\nVariables:`);
+ for (const v of content.variables) {
+ console.log(` - ${v.name} (${v.type}): ${v.value}`);
+ }
+ } else {
+ console.log(`\nVariables: None`);
+ }
+
+ if (content?.canvas?.blocks && content.canvas.blocks.length > 0) {
+ console.log(`\nBlock Breakdown:`);
+ const counts: Record = {};
+ for (const block of content.canvas.blocks) {
+ const pid = block.origin?.pluginId || "core";
+ counts[pid] = (counts[pid] || 0) + 1;
+ }
+ for (const [pid, count] of Object.entries(counts)) {
+ console.log(` - ${pid}: ${count} blocks`);
+ }
+ }
+
+ const triggers = content?.canvas?.triggers || [];
+ const blocks = content?.canvas?.blocks || [];
+
+ const humanize = (origin: any, customName?: string) => {
+ if (customName) return customName;
+ if (!origin) return "Unknown";
+ const parts = origin.nodeId
+ .split(":")
+ .filter((p: string) => !(p.startsWith("v") && !isNaN(parseInt(p.substring(1)))));
+
+ const nodeName = parts
+ .map((p: string) => p.replace(/-/g, " ").replace(/_/g, " "))
+ .map((p: string) => p.replace(/\b\w/g, (c: string) => c.toUpperCase()))
+ .join(": ");
+
+ return `${nodeName} [${origin.pluginId}]`;
+ };
+
+ console.log(`\nWorkflow:`);
+ if (triggers.length > 0) {
+ for (const t of triggers) {
+ console.log(` (Trigger) ${humanize(t.origin, t.name)}`);
+ }
+ } else {
+ console.log(` (No triggers defined)`);
+ }
+
+ if (blocks.length > 0) {
+ for (let i = 0; i < blocks.length; i++) {
+ const b = blocks[i];
+ const status = b.disabled ? " [DISABLED]" : "";
+ console.log(` β`);
+ console.log(` βΌ`);
+ console.log(` (Action) ${humanize(b.origin, b.name)}${status}`);
+ if (b.description) {
+ console.log(` ${b.description}`);
+ }
+ }
+ } else {
+ console.log(` β`);
+ console.log(` βΌ`);
+ console.log(` (No actions defined)`);
+ }
+ }
+ } catch (error: any) {
+ console.error("Failed to show pipeline:", error.message);
+ process.exit(1);
+ }
+}
+
+export async function deletePipelineCommand(
+ id: string,
+ options: { force?: boolean; userData?: string },
+) {
+ if (!options.force) {
+ console.error(
+ "This is a destructive operation. Please use the --force flag to confirm you want to delete the pipeline.",
+ );
+ process.exit(1);
+ }
+
+ const userDataPath = options.userData || getDefaultUserDataPath();
+ const context = new PipelabContext({ userDataPath });
+
+ try {
+ const projectsConfig = await setupConfigFile("projects", { context });
+ const repo = await projectsConfig.getConfig();
+
+ const index = repo.pipelines.findIndex(
+ (p) => p.id === id || (p.type === "internal" && p.configName === id),
+ );
+
+ if (index === -1) {
+ console.error(`Pipeline "${id}" not found.`);
+ process.exit(1);
+ }
+
+ const pipeline = repo.pipelines[index] as SaveLocation;
+
+ // 1. Delete internal file if applicable
+ if (pipeline.type === "internal") {
+ try {
+ const path = context.getConfigPath(`${pipeline.configName}.json`);
+ await unlink(path);
+ console.log(`Deleted pipeline file: ${pipeline.configName}.json`);
+ } catch (e: any) {
+ if (e.code !== "ENOENT") {
+ console.warn(`Warning: Could not delete pipeline file: ${e.message}`);
+ }
+ }
+ }
+
+ // 2. Remove from projects.json
+ repo.pipelines.splice(index, 1);
+ await projectsConfig.setConfig(repo);
+ console.log(`Successfully removed pipeline "${id}" from Pipelab.`);
+ } catch (error: any) {
+ console.error("Failed to delete pipeline:", error.message);
+ process.exit(1);
+ }
+}
diff --git a/apps/cli/src/commands/setup.ts b/apps/cli/src/commands/setup.ts
new file mode 100644
index 00000000..a0e7f357
--- /dev/null
+++ b/apps/cli/src/commands/setup.ts
@@ -0,0 +1,108 @@
+import * as p from "@clack/prompts";
+import { setTimeout } from "node:timers/promises";
+import { PipelabContext } from "@pipelab/core-node";
+import { setupConfigFile } from "@pipelab/core-node";
+import { AppConfig } from "@pipelab/shared";
+import { getDefaultUserDataPath } from "../paths";
+
+export async function setupCommand(options: { userData?: string }) {
+ p.intro(`Welcome to Pipelab CLI Setup Wizard`);
+
+ const userDataPath = options.userData || getDefaultUserDataPath();
+ const context = new PipelabContext({ userDataPath });
+
+ // 1. Authentication
+ const authType = await p.select({
+ message: "How would you like to authenticate?",
+ options: [
+ { value: "token", label: "Access Token (Recommended)" },
+ { value: "credentials", label: "Email & Password" },
+ { value: "skip", label: "Skip for now" },
+ ],
+ });
+
+ if (p.isCancel(authType)) {
+ p.cancel("Setup cancelled.");
+ process.exit(0);
+ }
+
+ if (authType === "token") {
+ const token = await p.password({
+ message: "Enter your Pipelab Access Token",
+ validate: (value) => {
+ if (!value) return "Token is required";
+ if (value.length < 10) return "Token is too short";
+ },
+ });
+ if (p.isCancel(token)) return;
+
+ const s = p.spinner();
+ s.start("Authenticating with Pipelab...");
+ await setTimeout(1500); // Fake delay
+ s.stop("Authenticated successfully!");
+ }
+
+ // 2. Configuration
+ const settings = await setupConfigFile("settings", { context });
+ const config = await settings.getConfig();
+
+ const configChanges = await p.group({
+ theme: () =>
+ p.select({
+ message: "Select your preferred theme:",
+ initialValue: config.theme,
+ options: [
+ { value: "dark", label: "Dark" },
+ { value: "light", label: "Light" },
+ ],
+ }),
+ locale: () =>
+ p.select({
+ message: "Select your language:",
+ initialValue: config.locale,
+ options: [
+ { value: "en-US", label: "English" },
+ { value: "fr-FR", label: "French" },
+ { value: "pt-BR", label: "Portuguese" },
+ ],
+ }),
+ });
+
+ if (p.isCancel(configChanges)) {
+ p.cancel("Setup aborted.");
+ process.exit(0);
+ }
+
+ // Save config (Fake save for now to avoid side effects in this demo if needed, but let's do it real)
+ await settings.setConfig({
+ ...config,
+ theme: configChanges.theme,
+ locale: configChanges.locale,
+ });
+
+ // 3. Integrations
+ const integrations = await p.multiselect({
+ message: "Which integrations would you like to enable?",
+ options: [
+ { value: "path", label: "Add 'pipelab' to your PATH", hint: "recommended" },
+ { value: "completion", label: "Enable shell autocompletion" },
+ { value: "telemetry", label: "Anonymous telemetry", hint: "helps us improve" },
+ ],
+ });
+
+ if (p.isCancel(integrations)) return;
+
+ if (integrations.length > 0) {
+ const s = p.spinner();
+ s.start("Setting up integrations...");
+ await setTimeout(2000); // Fake delay
+ s.stop("Integrations configured!");
+ }
+
+ p.note(
+ `You're all set! You can now use Pipelab CLI to manage your automation.\n\nType 'pipelab --help' to get started.`,
+ "Setup Complete"
+ );
+
+ p.outro("Happy automating! π");
+}
diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts
new file mode 100644
index 00000000..cb255789
--- /dev/null
+++ b/apps/cli/src/index.ts
@@ -0,0 +1,234 @@
+#!/usr/bin/env node
+import { isDev, runPipelineCommand, serveCommand } from "@pipelab/core-node";
+import { historyCommand } from "./commands/history";
+import { usageCommand, purgeCommand } from "./commands/maintenance";
+import {
+ listPipelinesCommand,
+ deletePipelineCommand,
+ showPipelineCommand,
+} from "./commands/pipelines";
+import { setupCommand } from "./commands/setup";
+import { readFileSync, existsSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { isSupabaseAvailable } from "@pipelab/shared";
+import { config } from "dotenv";
+import { PostHog } from "posthog-node";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+// Only load .env in development as values are bundled in production by tsdown
+if (isDev) {
+ const envPath = join(__dirname, "../../../.env");
+ if (existsSync(envPath)) {
+ config({ path: envPath });
+ }
+}
+
+const isProduction = !isDev && process.env.TEST !== "true";
+
+let posthog: PostHog | undefined;
+if (isProduction && process.env.POSTHOG_API_KEY) {
+ posthog = new PostHog(process.env.POSTHOG_API_KEY, {
+ host: "https://eu.i.posthog.com",
+ });
+}
+import { Command } from "commander";
+import { getDefaultUserDataPath } from "./paths";
+
+// Resolve version from package.json with fallbacks for production
+let version = "0.0.0";
+try {
+ const packageJsonPath = existsSync(join(__dirname, "package.json"))
+ ? join(__dirname, "package.json")
+ : join(__dirname, "..", "package.json");
+
+ if (existsSync(packageJsonPath)) {
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
+ version = packageJson.version;
+ }
+} catch (e) {
+ console.warn("[CLI] Could not resolve version from package.json, using fallback.");
+}
+
+const program = new Command();
+
+program
+ .name("pipelab")
+ .description("The command line interface for Pipelab")
+ .version(version);
+
+if (!isSupabaseAvailable()) {
+ console.warn("\x1b[33m%s\x1b[0m", "Warning: Authentication is currently disabled (Cloud services not configured).");
+}
+
+program
+ .command("serve")
+ .description("Start the standalone WebSocket server")
+ .option("-p, --port ", "Port to listen on", "33753")
+ .option("--user-data ", "Custom user data path")
+ .action(async (options) => {
+ try {
+ options.userData = options.userData || getDefaultUserDataPath();
+ await serveCommand(options, version, __dirname);
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ });
+
+program
+ .command("run ")
+ .description("Run a pipeline from a JSON file")
+ .option("--user-data ", "Custom user data path")
+ .option("--variables ", "JSON string of variables to override")
+ .option("-o, --output ", "Path to write the result file")
+ .option("--cloud", "Run the pipeline in a cloud environment")
+ .action(async (file, options) => {
+ try {
+ options.userData = options.userData || getDefaultUserDataPath();
+ await runPipelineCommand(file, options, version);
+ process.exit(0);
+ } catch (e) {
+ console.error("Pipeline execution failed:", e);
+ process.exit(1);
+ }
+ });
+
+program
+ .command("history [pipeline-id]")
+ .description("View build history for a pipeline")
+ .option("--get ", "Get a specific build entry by ID")
+ .option("--user-data ", "Custom user data path")
+ .option("--limit ", "Limit the number of history entries to show", "10")
+ .action(async (pipelineId, options) => {
+ try {
+ await historyCommand(pipelineId, options);
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ });
+
+program
+ .command("usage")
+ .description("Show build history storage usage")
+ .option("--user-data ", "Custom user data path")
+ .action(async (options) => {
+ try {
+ await usageCommand(options);
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ });
+
+program
+ .command("purge [pipeline-id]")
+ .description("Purge build history")
+ .option("--user-data ", "Custom user data path")
+ .option("-f, --force", "Force the destructive operation")
+ .action(async (pipelineId, options) => {
+ try {
+ await purgeCommand(pipelineId, options);
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ });
+
+program
+ .command("setup")
+ .description("Run the interactive setup wizard")
+ .option("--user-data ", "Custom user data path")
+ .action(async (options) => {
+ try {
+ await setupCommand(options);
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ });
+
+const pipelines = program
+ .command("pipelines")
+ .alias("pipeline")
+ .description("Manage pipelines");
+
+pipelines
+ .command("ls")
+ .alias("list")
+ .description("List all pipelines")
+ .option("--user-data ", "Custom user data path")
+ .action(async (options) => {
+ try {
+ await listPipelinesCommand(options);
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ });
+
+pipelines
+ .command("show ")
+ .alias("shw")
+ .description("Display basic information about a pipeline")
+ .option("--user-data ", "Custom user data path")
+ .action(async (idOrName, options) => {
+ try {
+ await showPipelineCommand(idOrName, { ...options, detailed: false });
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ });
+
+pipelines
+ .command("detail ")
+ .alias("details")
+ .description("Display detailed information about a pipeline")
+ .option("--user-data ", "Custom user data path")
+ .action(async (idOrName, options) => {
+ try {
+ await showPipelineCommand(idOrName, { ...options, detailed: true });
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ });
+
+pipelines
+ .command("rm ")
+ .alias("remove")
+ .alias("delete")
+ .description("Delete a pipeline")
+ .option("--user-data ", "Custom user data path")
+ .option("-f, --force", "Force the destructive operation")
+ .action(async (id, options) => {
+ try {
+ await deletePipelineCommand(id, options);
+ } catch (e) {
+ console.error(e);
+ process.exit(1);
+ }
+ });
+
+program.hook("postAction", (thisCommand) => {
+ if (posthog) {
+ posthog.capture({
+ distinctId: "cli-user",
+ event: "command_executed",
+ properties: {
+ command: thisCommand.name(),
+ args: thisCommand.args,
+ },
+ });
+ }
+});
+
+program.parse(process.argv);
+
+if (!process.argv.slice(2).length) {
+ program.outputHelp();
+}
diff --git a/apps/cli/src/paths.ts b/apps/cli/src/paths.ts
new file mode 100644
index 00000000..29f93fa9
--- /dev/null
+++ b/apps/cli/src/paths.ts
@@ -0,0 +1 @@
+export { getDefaultUserDataPath } from "@pipelab/core-node";
diff --git a/apps/cli/tests/e2e/tests/history.spec.ts b/apps/cli/tests/e2e/tests/history.spec.ts
new file mode 100644
index 00000000..eee75ae2
--- /dev/null
+++ b/apps/cli/tests/e2e/tests/history.spec.ts
@@ -0,0 +1,57 @@
+import { expect, test, describe, afterEach } from "vitest";
+import { writeFile, access, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { createSandbox, runCLI } from "@pipelab/test-utils";
+
+describe("End-to-End: Build History", () => {
+ let sandbox: Awaited>;
+
+ afterEach(async () => {
+ if (sandbox) {
+ await sandbox.remove();
+ }
+ });
+
+ test(
+ "should generate and save a build history when PIPELAB_DISABLE_HISTORY is false",
+ async () => {
+ sandbox = await createSandbox("history-e2e");
+
+ const pipelineId = "test-history-pipeline";
+ const pipeline = {
+ graph: [], // Empty graph will execute successfully
+ projectPath: sandbox.path,
+ projectName: "History E2E Test",
+ pipelineId,
+ };
+
+ const pipelineFile = join(sandbox.path, "pipeline.json");
+ await writeFile(pipelineFile, JSON.stringify(pipeline, null, 2));
+
+ // Run the CLI using the helper but explicitly set disable history to false
+ await runCLI(["run", pipelineFile, "--user-data", sandbox.paths.userData], {
+ env: {
+ PIPELAB_DISABLE_HISTORY: "false",
+ },
+ });
+
+ // The build history should be generated in the user-data folder
+ // Based on BuildHistoryStorage.getPipelinePath, the filename should be pipeline-.json
+ const historyFile = join(sandbox.paths.userData, "build-history", `pipeline-${pipelineId}.json`);
+
+ // Verification: file must exist
+ await expect(access(historyFile)).resolves.not.toThrow();
+
+ const historyContent = await readFile(historyFile, "utf-8");
+ const history = JSON.parse(historyContent);
+
+ expect(Array.isArray(history)).toBe(true);
+ expect(history.length).toBeGreaterThan(0);
+
+ const lastEntry = history[history.length - 1];
+ expect(lastEntry.pipelineId).toBe(pipelineId);
+ expect(lastEntry.status).toBe("completed");
+ },
+ 5 * 60 * 1000,
+ );
+});
diff --git a/apps/cli/tests/e2e/tests/integration.spec.ts b/apps/cli/tests/e2e/tests/integration.spec.ts
new file mode 100644
index 00000000..72a38150
--- /dev/null
+++ b/apps/cli/tests/e2e/tests/integration.spec.ts
@@ -0,0 +1,73 @@
+import { expect, test, describe, afterEach } from "vitest";
+import { writeFile, access, readFile, mkdir } from "node:fs/promises";
+import { join } from "node:path";
+import { createSandbox, runCLI } from "@pipelab/test-utils";
+
+describe("End-to-End: Multi-Plugin Integration Test", () => {
+ let sandbox: Awaited>;
+
+ afterEach(async () => {
+ if (sandbox) {
+ await sandbox.remove();
+ }
+ });
+
+ test(
+ "should run a pipeline with filesystem nodes",
+ async () => {
+ sandbox = await createSandbox("integration-e2e");
+ const { paths } = sandbox;
+
+ const projectSourcePath = join(sandbox.path, "my-app-source");
+ const projectStagingPath = join(sandbox.path, "my-app-staging");
+ await mkdir(projectSourcePath, { recursive: true });
+
+ // Create initial source files
+ await writeFile(
+ join(projectSourcePath, "package.json"),
+ JSON.stringify({ name: "my-app", version: "1.0.0", main: "index.js" }),
+ );
+ await writeFile(join(projectSourcePath, "index.js"), "console.log('hello integration test')");
+
+ const pipeline = {
+ graph: [
+ {
+ uid: "copy-to-staging",
+ name: "Copy to Staging",
+ type: "action",
+ origin: { pluginId: "filesystem", nodeId: "fs:copy" },
+ params: {
+ from: { value: JSON.stringify(projectSourcePath) },
+ to: { value: JSON.stringify(projectStagingPath) },
+ recursive: { value: JSON.stringify(true) },
+ overwrite: { value: JSON.stringify(true) },
+ cleanup: { value: JSON.stringify(false) },
+ },
+ },
+ ],
+ projectPath: sandbox.path,
+ projectName: "Integration E2E Test",
+ };
+
+ const pipelineFile = join(sandbox.path, "pipeline.json");
+ const resultFile = join(sandbox.path, "result.json");
+ await writeFile(pipelineFile, JSON.stringify(pipeline, null, 2));
+
+ // Run the CLI using the helper
+ await runCLI(["run", pipelineFile, "--output", resultFile]);
+
+ const resultJson = JSON.parse(await readFile(resultFile, "utf-8"));
+
+ // Verification
+ expect(resultJson.steps["copy-to-staging"]).toBeDefined();
+
+ const outputs = resultJson.steps["copy-to-staging"].outputs;
+ expect(outputs).toBeDefined();
+ expect(outputs.output).toEqual(projectStagingPath);
+
+ // Verify output exists
+ await expect(access(outputs.output as string)).resolves.not.toThrow();
+ },
+ 5 * 60 * 1000,
+ );
+});
diff --git a/apps/cli/tests/e2e/vitest.config.mts b/apps/cli/tests/e2e/vitest.config.mts
new file mode 100644
index 00000000..56b7ba78
--- /dev/null
+++ b/apps/cli/tests/e2e/vitest.config.mts
@@ -0,0 +1,20 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ fileParallelism: false,
+ testTimeout: 60000,
+ hookTimeout: 60000,
+ maxWorkers: 1,
+ minWorkers: 1,
+ poolOptions: {
+ forks: {
+ singleFork: true,
+ },
+ },
+ include: ["**/*.spec.ts"],
+ root: "tests/e2e",
+ environment: "node",
+ env: { NODE_ENV: "development", PIPELAB_DISABLE_HISTORY: "true" },
+ },
+});
diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json
new file mode 100644
index 00000000..4f5189ad
--- /dev/null
+++ b/apps/cli/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "extends": "../../packages/tsconfig/node.json",
+ "compilerOptions": {
+ "composite": false,
+ "moduleResolution": "bundler",
+ "outDir": "dist",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "baseUrl": ".",
+ "paths": {
+ "@pipelab/shared": ["../../packages/shared/src/index.ts"],
+ "@pipelab/constants": ["../../packages/constants/src/index.ts"],
+ "@pipelab/core-node": ["../../packages/core-node/src/index.ts"],
+ "@pipelab/migration": ["../../packages/migration/src/index.ts"],
+ "@pipelab/*": ["../../packages/shared/src/libs/*"],
+ "@core-node/*": ["../../packages/core-node/src/*"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"],
+ "references": [
+ {
+ "path": "../../packages/constants"
+ },
+ {
+ "path": "../../packages/shared"
+ },
+ {
+ "path": "../../packages/core-node"
+ }
+ ]
+}
diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts
new file mode 100644
index 00000000..483fb909
--- /dev/null
+++ b/apps/cli/tsdown.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "tsdown";
+import { resolve } from "path";
+import { existsSync } from "node:fs";
+import { config } from "dotenv";
+
+const envFile = resolve(import.meta.dirname, "../../.env");
+
+config({ path: envFile });
+
+export default defineConfig({
+ entry: ["src/index.ts"],
+ shims: true,
+ env: {
+ NODE_ENV: process.env.NODE_ENV || "production",
+ SUPABASE_URL: process.env.SUPABASE_URL || "",
+ SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY || "",
+ POSTHOG_API_KEY: process.env.POSTHOG_API_KEY || "",
+ },
+ envFile: existsSync(envFile) ? envFile : undefined,
+ envPrefix: ["SUPABASE_", "POSTHOG_", "NODE_ENV"],
+ deps: {
+ alwaysBundle: [/.*/],
+ },
+});
diff --git a/.editorconfig b/apps/desktop/.editorconfig
similarity index 100%
rename from .editorconfig
rename to apps/desktop/.editorconfig
diff --git a/.hintrc b/apps/desktop/.hintrc
similarity index 100%
rename from .hintrc
rename to apps/desktop/.hintrc
diff --git a/apps/desktop/.mise.toml b/apps/desktop/.mise.toml
new file mode 100644
index 00000000..8754dd96
--- /dev/null
+++ b/apps/desktop/.mise.toml
@@ -0,0 +1,19 @@
+[tools]
+
+[env]
+_.file = '.env'
+
+[tasks.install]
+run = "pnpm install"
+
+[tasks.dev]
+run = "pnpm dev"
+depends = ["install"]
+
+[tasks.package]
+run = "pnpm package"
+depends = ["install"]
+
+[tasks.test-e2e-local]
+run = "pnpm test:e2e"
+depends = ["package"]
diff --git a/apps/desktop/.npmrc b/apps/desktop/.npmrc
new file mode 100644
index 00000000..d67f3748
--- /dev/null
+++ b/apps/desktop/.npmrc
@@ -0,0 +1 @@
+node-linker=hoisted
diff --git a/apps/desktop/CHANGELOG.md b/apps/desktop/CHANGELOG.md
new file mode 100644
index 00000000..c6585545
--- /dev/null
+++ b/apps/desktop/CHANGELOG.md
@@ -0,0 +1,274 @@
+# @pipelab/app
+
+## 2.0.1-latest.43
+
+### Patch Changes
+
+- sdq
+
+## 2.0.1-latest.42
+
+### Patch Changes
+
+- qsd
+
+## 2.0.1-latest.41
+
+### Patch Changes
+
+- hyg
+
+## 2.0.1-latest.40
+
+### Patch Changes
+
+- sdf
+
+## 2.0.1-latest.39
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-latest.38
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-latest.37
+
+### Patch Changes
+
+- qsd
+
+## 2.0.1-latest.36
+
+### Patch Changes
+
+- sdfc
+
+## 2.0.1-latest.35
+
+### Patch Changes
+
+- qsd
+
+## 2.0.1-latest.34
+
+### Patch Changes
+
+- sdq
+
+## 2.0.1-latest.33
+
+### Patch Changes
+
+- ad
+
+## 2.0.1-latest.32
+
+### Patch Changes
+
+- sdqsd
+
+## 2.0.1-latest.31
+
+### Patch Changes
+
+- sqd
+
+## 2.0.1-beta.30
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.29
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.28
+
+### Patch Changes
+
+- df
+
+## 2.0.1-beta.27
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.26
+
+### Patch Changes
+
+- dfg
+
+## 2.0.1-beta.25
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.24
+
+### Patch Changes
+
+- xwc
+
+## 2.0.1-beta.23
+
+### Patch Changes
+
+- sdsd
+
+## 2.0.1-beta.22
+
+### Patch Changes
+
+- gf
+
+## 2.0.1-beta.21
+
+### Patch Changes
+
+- azs
+
+## 2.0.1-beta.20
+
+### Patch Changes
+
+- sqqd
+
+## 2.0.1-beta.19
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.18
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.17
+
+### Patch Changes
+
+- wdf
+
+## 2.0.1-beta.16
+
+### Patch Changes
+
+- sdf
+
+## 2.0.1-beta.15
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.14
+
+### Patch Changes
+
+- rfg
+
+## 2.0.1-beta.13
+
+### Patch Changes
+
+- szd
+
+## 2.0.1-beta.12
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.11
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.10
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.9
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.8
+
+### Patch Changes
+
+- fix
+
+## 2.0.1-beta.7
+
+### Patch Changes
+
+- test
+
+## 2.0.1-beta.6
+
+### Patch Changes
+
+- test
+
+## 2.0.1-beta.5
+
+### Patch Changes
+
+- sd
+
+## 2.0.1-beta.4
+
+### Patch Changes
+
+- tesyt
+
+## 2.0.1-beta.3
+
+### Patch Changes
+
+- last test
+
+## 2.0.1-beta.2
+
+## 2.0.1-beta.1
+
+### Patch Changes
+
+- 9268b1e: test 3
+
+## 2.0.1-beta.0
+
+### Patch Changes
+
+- test
+
+## 2.0.1-beta.0
+
+### Patch Changes
+
+- ec2c760: test 2
+- 9af5915: test
+
+## 2.0.0
+
+### Major Changes
+
+- V2 Release: Major version bump for application packages.
diff --git a/FAQ.md b/apps/desktop/FAQ.md
similarity index 100%
rename from FAQ.md
rename to apps/desktop/FAQ.md
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
new file mode 100644
index 00000000..be87c3fb
--- /dev/null
+++ b/apps/desktop/README.md
@@ -0,0 +1,30 @@
+# @pipelab/app (Desktop)
+
+The Desktop version of Pipelab, built with Electron.
+
+## π Lifecycle & Orchestration
+
+The Desktop application serves as the native shell for the Pipelab ecosystem. Its responsibilities include:
+
+1. **Process Management**: In production, the main process is responsible for spawning and managing the `@pipelab/cli` server as a "sidecar" process.
+2. **Native Bridge**: Provides IPC handlers for native OS features (file system dialogs, system notifications, auto-updates) that are not available to the web renderer.
+3. **UI Container**: Loads and displays the `@pipelab/ui` interface.
+
+### Communication Flow
+
+- **Main to UI**: Via standard Electron IPC.
+- **UI to CLI**: Via WebSockets (even when running inside the desktop app).
+
+## π οΈ Development
+
+### Setup
+
+Ensure you have the root-level `.env` file configured.
+
+### Commands
+
+```bash
+pnpm dev # Starts the Electron app in development mode
+pnpm package # Packages the application for the current platform (production)
+pnpm make # Creates installers (dmg, exe, deb, zip)
+```
diff --git a/assets/build/entitlements.mac.plist b/apps/desktop/assets/build/entitlements.mac.plist
similarity index 100%
rename from assets/build/entitlements.mac.plist
rename to apps/desktop/assets/build/entitlements.mac.plist
diff --git a/assets/build/icon.icns b/apps/desktop/assets/build/icon.icns
similarity index 100%
rename from assets/build/icon.icns
rename to apps/desktop/assets/build/icon.icns
diff --git a/assets/build/icon.ico b/apps/desktop/assets/build/icon.ico
similarity index 100%
rename from assets/build/icon.ico
rename to apps/desktop/assets/build/icon.ico
diff --git a/assets/build/icon.png b/apps/desktop/assets/build/icon.png
similarity index 100%
rename from assets/build/icon.png
rename to apps/desktop/assets/build/icon.png
diff --git a/apps/desktop/assets/build/notarize.cjs b/apps/desktop/assets/build/notarize.cjs
new file mode 100644
index 00000000..0e96c5b9
--- /dev/null
+++ b/apps/desktop/assets/build/notarize.cjs
@@ -0,0 +1,36 @@
+const { notarize } = require("@electron/notarize");
+
+module.exports = async (context) => {
+ if (process.platform !== "darwin") return;
+
+ console.log("aftersign hook triggered, start to notarize app.");
+
+ if (!process.env.CI) {
+ console.log(`skipping notarizing, not in CI.`);
+ return;
+ }
+
+ if (!("APPLE_ID" in process.env && "APPLE_ID_PASS" in process.env)) {
+ console.warn("skipping notarizing, APPLE_ID and APPLE_ID_PASS env variables must be set.");
+ return;
+ }
+
+ const appId = "com.electron.app";
+
+ const { appOutDir } = context;
+
+ const appName = context.packager.appInfo.productFilename;
+
+ try {
+ await notarize({
+ appBundleId: appId,
+ appPath: `${appOutDir}/${appName}.app`,
+ appleId: process.env.APPLE_ID,
+ appleIdPassword: process.env.APPLEIDPASS,
+ });
+ } catch (error) {
+ console.error(error);
+ }
+
+ console.log(`done notarizing ${appId}.`);
+};
diff --git a/crowdin.yml b/apps/desktop/crowdin.yml
similarity index 100%
rename from crowdin.yml
rename to apps/desktop/crowdin.yml
diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts
new file mode 100644
index 00000000..8af855b7
--- /dev/null
+++ b/apps/desktop/forge.config.ts
@@ -0,0 +1,123 @@
+import type { ForgeConfig } from "@electron-forge/shared-types";
+import { MakerSquirrel } from "@electron-forge/maker-squirrel";
+import { MakerZIP } from "@electron-forge/maker-zip";
+import { VitePlugin } from "@electron-forge/plugin-vite";
+import { FusesPlugin } from "@electron-forge/plugin-fuses";
+import { MakerDMG } from "@electron-forge/maker-dmg";
+import { FuseV1Options, FuseVersion } from "@electron/fuses";
+import { name } from "@pipelab/constants";
+import fs from "node:fs/promises";
+import path from "path";
+import { fileURLToPath } from "node:url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+console.log(`[Forge Config] Host arch: ${process.arch}`);
+console.log(`[Forge Config] Target arch (env.TARGET_ARCH): ${process.env.TARGET_ARCH}`);
+console.log(`[Forge Config] npm_config_arch: ${process.env.npm_config_arch}`);
+
+const getStandardOs = (p: string) => ({ win32: "win", darwin: "macos", linux: "linux" })[p] || p;
+
+/**
+ * Renames Forge-generated installers in /out/make.
+ */
+async function renameInstallers(platform: string, arch: string) {
+ const pkgPath = path.join(__dirname, "package.json");
+ const { version } = JSON.parse(await fs.readFile(pkgPath, "utf-8"));
+ const os = getStandardOs(platform);
+ const makeDir = path.join(__dirname, "out/make");
+
+ const exists = await fs
+ .access(makeDir)
+ .then(() => true)
+ .catch(() => false);
+ if (!exists) return;
+
+ const files = await fs.readdir(makeDir, { recursive: true });
+
+ for (const relFile of files) {
+ const file = path.join(makeDir, relFile);
+ if ((await fs.stat(file)).isDirectory()) continue;
+
+ const ext = path.extname(file);
+ const basename = path.basename(file);
+ if ([".zip", ".dmg", ".exe", ".deb", ".rpm"].includes(ext) && !basename.includes("-v")) {
+ const newName = `pipelab-desktop-v${version}-${os}-${arch}${ext}`;
+ await fs.rename(file, path.join(path.dirname(file), newName));
+ }
+ }
+}
+
+const config: ForgeConfig = {
+ packagerConfig: {
+ // @ts-expect-error - Force architecture as Forge CLI sometimes ignores --arch flag in CI
+ arch: process.env.TARGET_ARCH || process.env.npm_config_arch || process.arch,
+ prune: false,
+ appBundleId: "app.pipelab.desktop",
+ asar: true,
+ extraResource: [],
+ name,
+ icon: path.join(__dirname, "assets/build/icon"),
+ extendInfo: {
+ NSAppleEventsUsageDescription: "This app need to run commands through Terminal.",
+ },
+ osxNotarize: {
+ appleId: process.env.APPLE_ID || "",
+ appleIdPassword: process.env.APPLE_ID_PASSWORD || "",
+ teamId: process.env.APPLE_TEAM_ID || "",
+ },
+ osxSign: {
+ identity: `Developer ID Application: Quentin Goinaud (${process.env.APPLE_TEAM_ID})`,
+ hardenedRuntime: true,
+ entitlements: path.join(__dirname, "assets/build/entitlements.mac.plist"),
+ "entitlements-inherit": path.join(__dirname, "assets/build/entitlements.mac.plist"),
+ strictVerify: false,
+ } as any,
+ },
+ makers: [
+ new MakerSquirrel({ name, setupIcon: path.join(__dirname, "assets/build/icon.ico") }),
+ new MakerZIP(undefined, ["linux", "win32"]),
+ new MakerDMG(),
+ ],
+ publishers: [
+ {
+ name: "@electron-forge/publisher-github",
+ config: {
+ repository: { owner: "CynToolkit", name: "pipelab" },
+ prerelease: process.env.PRERELEASE === "true",
+ draft: false,
+ generateReleaseNotes: true,
+ },
+ },
+ ],
+ hooks: {
+ postMake: async (_, makeResults) => {
+ for (const target of new Set(makeResults.map((r) => `${r.platform}:${r.arch}`))) {
+ const [p, a] = target.split(":");
+ await renameInstallers(p, a);
+ }
+ return makeResults;
+ },
+ },
+ plugins: [
+ new VitePlugin({
+ build: [
+ { entry: "src/main.ts", config: "vite.main.config.mts" },
+ { entry: "src/preload.ts", config: "vite.preload.config.mts" },
+ ],
+ renderer: [{ name: "main_window", config: "vite.renderer.config.mts" }],
+ }),
+ new FusesPlugin({
+ version: FuseVersion.V1,
+ [FuseV1Options.RunAsNode]: true,
+ [FuseV1Options.EnableCookieEncryption]: true,
+ [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: true,
+ [FuseV1Options.EnableNodeCliInspectArguments]: false,
+ [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: false,
+ [FuseV1Options.OnlyLoadAppFromAsar]: false,
+ }),
+ ],
+};
+
+export default config;
diff --git a/apps/desktop/forge.env.d.ts b/apps/desktop/forge.env.d.ts
new file mode 100644
index 00000000..ff48335b
--- /dev/null
+++ b/apps/desktop/forge.env.d.ts
@@ -0,0 +1,44 @@
+export {}; // Make this a module
+
+declare global {
+ // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Vite
+ // plugin that tells the Electron app where to look for the Vite-bundled app code (depending on
+ // whether you're running in development or production).
+ const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
+ const MAIN_WINDOW_VITE_NAME: string;
+
+ type ElectronAPI = import("@electron-toolkit/preload").ElectronAPI;
+
+ namespace NodeJS {
+ interface Process {
+ // Used for hot reload after preload scripts.
+ viteDevServers: Record;
+ }
+ }
+
+ interface Window {
+ version: string;
+ electron: ElectronAPI;
+ api: any;
+ isPackaged: boolean;
+ isTest: boolean;
+ isCI: boolean;
+ }
+
+ type VitePluginConfig = ConstructorParameters<
+ typeof import("@electron-forge/plugin-vite").VitePlugin
+ >[0];
+
+ interface VitePluginRuntimeKeys {
+ VITE_DEV_SERVER_URL: `${string}_VITE_DEV_SERVER_URL`;
+ VITE_NAME: `${string}_VITE_NAME`;
+ }
+}
+
+declare module "vite" {
+ interface ConfigEnv {
+ root: string;
+ forgeConfig: VitePluginConfig;
+ forgeConfigSelf: VitePluginConfig[K][number];
+ }
+}
diff --git a/apps/desktop/index.html b/apps/desktop/index.html
new file mode 100644
index 00000000..9666b78f
--- /dev/null
+++ b/apps/desktop/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+ Pipelab
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
new file mode 100644
index 00000000..63294e1e
--- /dev/null
+++ b/apps/desktop/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@pipelab/app",
+ "version": "2.0.1-latest.43",
+ "private": true,
+ "description": "-",
+ "homepage": "https://pipelab.app",
+ "license": "FSL-1.1-MIT",
+ "author": "Armaldio",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/CynToolkit/pipelab.git",
+ "directory": "apps/desktop"
+ },
+ "main": ".vite/build/main.js",
+ "scripts": {
+ "format": "oxfmt .",
+ "lint": "oxlint .",
+ "typecheck": "tsc --noEmit -p tsconfig.json --composite false",
+ "start": "electron-forge start",
+ "start:args": "electron-forge start -- --project ./tests/e2e/fixtures/folder-to-electron.json --action run",
+ "dev": "cross-env NODE_OPTIONS=--enable-source-maps electron-forge start",
+ "package": "cross-env DEBUG='electron-osx-sign*,electron-forge:*' electron-forge package",
+ "make": "electron-forge make",
+ "publish": "electron-forge publish",
+ "make-dev-prerelease": "pnpm version prerelease --preid=dev",
+ "migrate-file": "tsx scripts/migrate-file.mts"
+ },
+ "dependencies": {
+ "electron-squirrel-startup": "1.0.1",
+ "posthog-node": "5.30.6"
+ },
+ "devDependencies": {
+ "@electron-forge/cli": "7.11.1",
+ "@electron-forge/maker-dmg": "7.11.1",
+ "@electron-forge/maker-squirrel": "7.11.1",
+ "@electron-forge/maker-zip": "7.11.1",
+ "@electron-forge/plugin-fuses": "7.11.1",
+ "@electron-forge/plugin-vite": "7.11.1",
+ "@electron-forge/publisher-github": "7.11.1",
+ "@electron-toolkit/preload": "3.0.2",
+ "@electron-toolkit/utils": "4.0.0",
+ "@electron/fuses": "1.8.0",
+ "@electron/notarize": "2.5.0",
+ "@pipelab/constants": "workspace:*",
+ "@pipelab/core-node": "workspace:*",
+ "@pipelab/shared": "workspace:*",
+ "@pipelab/tsconfig": "workspace:*",
+ "@types/electron-squirrel-startup": "1.0.2",
+ "@types/node": "24.12.2",
+ "cross-env": "7.0.3",
+ "electron": "32.1.2",
+ "tsx": "4.19.1",
+ "typescript": "5.8.3",
+ "vite": "6.3.3",
+ "vite-tsconfig-paths": "5.1.4"
+ }
+}
diff --git a/apps/desktop/scripts/headless-electron-tester.mts b/apps/desktop/scripts/headless-electron-tester.mts
new file mode 100644
index 00000000..b72b53a4
--- /dev/null
+++ b/apps/desktop/scripts/headless-electron-tester.mts
@@ -0,0 +1,10 @@
+import { execa } from "execa";
+import { join } from "path";
+import { cwd, stdin } from "process";
+
+await execa({
+ cwd: join(import.meta.dirname, ".."),
+ stdin: "inherit",
+ stdout: "inherit",
+ stderr: "inherit",
+})`./assets/electron/template/app/node_modules/.bin/electron-forge start -- --headless --port=3000`;
diff --git a/apps/desktop/scripts/migrate-file.mts b/apps/desktop/scripts/migrate-file.mts
new file mode 100644
index 00000000..befdd9be
--- /dev/null
+++ b/apps/desktop/scripts/migrate-file.mts
@@ -0,0 +1,40 @@
+import { savedFileMigrator } from "@pipelab/shared";
+import * as fs from "fs/promises";
+import { join } from "path";
+import { tmpdir } from "os";
+import { nanoid } from "nanoid";
+import { execa } from "execa";
+
+const args = process.argv.slice(2);
+if (args.length !== 1) {
+ console.error("Usage: node script.ts ");
+ process.exit(1);
+}
+
+const filePath = args[0];
+
+async function migrateFile() {
+ try {
+ // const tmpFile = join(tmpdir(), nanoid() + '.json')
+
+ const data = await fs.readFile(filePath, "utf8");
+ const json = JSON.parse(data);
+ const migratedData = await savedFileMigrator.migrate(json);
+ const str = JSON.stringify(migratedData, null, 2);
+ console.log("data", data);
+ console.log("str", str);
+ await fs.writeFile(filePath, str, "utf8");
+ console.log("File successfully migrated to the latest version of the model.");
+
+ // await execa('delta', [filePath, tmpFile], {
+ // env: {
+ // DELTA_FEATURES: '+side-by-side'
+ // }
+ // })
+ } catch (error) {
+ console.error(`Error migrating the file: ${error}`);
+ process.exit(1);
+ }
+}
+
+await migrateFile();
diff --git a/apps/desktop/scripts/test-update-server.mts b/apps/desktop/scripts/test-update-server.mts
new file mode 100644
index 00000000..42e5e4e5
--- /dev/null
+++ b/apps/desktop/scripts/test-update-server.mts
@@ -0,0 +1,101 @@
+import http from "node:http";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PORT = 8080;
+
+// Path to the 'make' directory where forge puts build artifacts
+const MAKE_DIR = path.resolve(__dirname, "..", "out", "make");
+
+const server = http.createServer((req, res) => {
+ console.log(`[Mock Server] Requested: ${req.url}`);
+
+ const url = new URL(req.url || "/", `http://localhost:${PORT}`);
+ const pathname = url.pathname;
+
+ // 1. Handle Squirrel.Windows RELEASES file
+ if (pathname === "/RELEASES" || pathname.endsWith("/RELEASES")) {
+ // Try to find the RELEASES file in the squirrel directory
+ const squirrelDir = path.join(MAKE_DIR, "squirrel.windows", "x64");
+ const releasesPath = path.join(squirrelDir, "RELEASES");
+
+ if (fs.existsSync(releasesPath)) {
+ res.writeHead(200, { "Content-Type": "text/plain" });
+ fs.createReadStream(releasesPath).pipe(res);
+ return;
+ }
+ }
+
+ // 2. Handle macOS Update JSON (Native autoUpdater protocol)
+ if (pathname === "/update/macos") {
+ // This is a simplified mock for macOS native protocol
+ // It expects a JSON with a 'url' property if an update is available
+ // or a 204 No Content if no update.
+ const version = process.env.APP_VERSION || "1.0.1";
+ const dmgPath = path.join(MAKE_DIR, "zip", "darwin", "x64", `pipelab-v${version}.zip`);
+
+ if (fs.existsSync(dmgPath)) {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ url: `http://localhost:${PORT}/download/macos/pipelab-v${version}.zip`,
+ }),
+ );
+ } else {
+ res.writeHead(204);
+ res.end();
+ }
+ return;
+ }
+
+ // 3. Serve static files (.nupkg, .zip, .exe, etc.)
+ // We search recursively in the MAKE_DIR for the file basename
+ const basename = path.basename(pathname);
+ const findFile = (dir: string): string | null => {
+ const files = fs.readdirSync(dir);
+ for (const file of files) {
+ const fullPath = path.join(dir, file);
+ if (fs.statSync(fullPath).isDirectory()) {
+ const found = findFile(fullPath);
+ if (found) return found;
+ } else if (file === basename) {
+ return fullPath;
+ }
+ }
+ return null;
+ };
+
+ try {
+ const filePath = findFile(MAKE_DIR);
+ if (filePath) {
+ const ext = path.extname(filePath);
+ const contentType =
+ {
+ ".nupkg": "application/zip",
+ ".zip": "application/zip",
+ ".exe": "application/x-msdownload",
+ ".dmg": "application/x-apple-diskimage",
+ }[ext] || "application/octet-stream";
+
+ res.writeHead(200, { "Content-Type": contentType });
+ fs.createReadStream(filePath).pipe(res);
+ return;
+ }
+ } catch (e) {
+ console.error(`[Mock Server] Error searching for file: ${e}`);
+ }
+
+ res.writeHead(404);
+ res.end("Not Found");
+});
+
+server.listen(PORT, () => {
+ console.log(`\nπ Mock Update Server running at http://localhost:${PORT}`);
+ console.log(`π Serving artifacts from: ${MAKE_DIR}`);
+ console.log(`\nInstructions for Windows VM:`);
+ console.log(`1. Identify your Host IP (e.g., 10.0.2.2 or 192.168.x.x)`);
+ console.log(`2. Run Pipelab in VM with env: APP_UPDATE_URL=http://:${PORT}`);
+ console.log(`\nPress Ctrl+C to stop.\n`);
+});
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
new file mode 100644
index 00000000..5bab15c6
--- /dev/null
+++ b/apps/desktop/src/main.ts
@@ -0,0 +1,254 @@
+import { app, shell, BrowserWindow, dialog, autoUpdater, screen, protocol, net } from "electron";
+import { join, resolve, dirname } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { platform } from "node:os";
+import { electronApp, optimizer, is } from "@electron-toolkit/utils";
+import { startServer, stopServer } from "./main/server-process";
+import { websocketPort, uiDevPort } from "@pipelab/constants";
+import { registerIpcHandlers } from "./main/ipc-handlers";
+import { getDefaultUserDataPath } from "@pipelab/core-node";
+import started from "electron-squirrel-startup";
+import { PostHog } from "posthog-node";
+
+const isProduction = app.isPackaged && process.env.TEST !== "true";
+
+let posthog: PostHog | undefined;
+if (isProduction) {
+ posthog = new PostHog(process.env.POSTHOG_API_KEY || "", {
+ host: "https://eu.i.posthog.com",
+ });
+}
+
+process.on("uncaughtException", (error) => {
+ console.error("Uncaught Exception in Main Process:", error);
+ if (posthog) {
+ posthog.capture({
+ distinctId: "desktop-main-process",
+ event: "uncaught_exception",
+ properties: {
+ message: error.message,
+ stack: error.stack,
+ platform: process.platform,
+ version: app.getVersion(),
+ },
+ });
+ }
+});
+
+app.setPath("userData", join(getDefaultUserDataPath(), "desktop"));
+
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: "media",
+ privileges: {
+ secure: true,
+ standard: true,
+ supportFetchAPI: true,
+ corsEnabled: true,
+ stream: true,
+ },
+ },
+]);
+
+function getIconPath() {
+ let ext = ".png";
+ if (platform() === "win32") ext = ".ico";
+ else if (platform() === "darwin") ext = ".icns";
+ return join("./assets", "build", `icon${ext}`);
+}
+
+if (process.platform === "win32" && process.env.TEST !== "true" && app.isPackaged) {
+ if (started) {
+ app.quit();
+ }
+}
+
+let mainWindow: BrowserWindow | undefined;
+
+function createWindow(): void {
+ const displays = screen.getAllDisplays();
+ const externalDisplay = displays.find((display) => {
+ return display.bounds.x !== 0 || display.bounds.y !== 0;
+ });
+
+ const position =
+ externalDisplay && is.dev
+ ? {
+ x: externalDisplay.bounds.x + 50,
+ y: externalDisplay.bounds.y + 50,
+ }
+ : {};
+
+ mainWindow = new BrowserWindow({
+ width: 1280,
+ height: 720,
+ show: false,
+ icon: getIconPath(),
+ autoHideMenuBar: true,
+ ...position,
+ webPreferences: {
+ preload: join(__dirname, "preload.cjs"),
+ sandbox: false,
+ devTools: is.dev,
+ },
+ });
+
+ if (is.dev) {
+ mainWindow.webContents.openDevTools();
+ }
+
+ mainWindow.on("close", function () {
+ app.quit();
+ });
+
+ mainWindow.webContents.setWindowOpenHandler((details) => {
+ shell.openExternal(details.url);
+ return { action: "deny" };
+ });
+}
+
+if (is.dev && process.platform === "win32") {
+ app.setAsDefaultProtocolClient("pipelab", process.execPath, [resolve(process.argv[1])]);
+} else {
+ app.setAsDefaultProtocolClient("pipelab");
+}
+
+const sendUpdateStatus = (status: string) => {
+ mainWindow?.webContents.send("update:set-status", {
+ data: { status },
+ requestId: "shell-update",
+ });
+};
+
+app.whenReady().then(async () => {
+ if (!is.dev || process.env.APP_UPDATE_URL) {
+ const updateUrl =
+ process.env.APP_UPDATE_URL ||
+ "https://github.com/CynToolkit/pipelab/releases/latest/download";
+
+ console.log(`[Update] Setting up auto-updater with feed URL: ${updateUrl}`);
+
+ autoUpdater.setFeedURL({
+ url: updateUrl,
+ headers: {
+ "Cache-Control": "no-cache",
+ },
+ });
+
+ autoUpdater.on("checking-for-update", () => {
+ console.log("[Update] Checking for update...");
+ sendUpdateStatus("checking-for-update");
+ });
+ autoUpdater.on("update-available", () => {
+ console.log("[Update] Update available!");
+ sendUpdateStatus("update-available");
+ });
+ autoUpdater.on("update-not-available", () => {
+ console.log("[Update] Update not available.");
+ sendUpdateStatus("update-not-available");
+ });
+
+ autoUpdater.on("update-downloaded", (event, releaseNotes, releaseName) => {
+ console.log("[Update] Update downloaded:", releaseName);
+ sendUpdateStatus("update-downloaded");
+
+ const dialogOpts: Electron.MessageBoxOptions = {
+ type: "info",
+ buttons: ["Restart", "Later"],
+ title: "Application Update",
+ message: process.platform === "win32" ? releaseNotes : releaseName,
+ detail: "A new version has been downloaded. Restart the application to apply the updates.",
+ };
+
+ dialog.showMessageBox(dialogOpts).then((returnValue) => {
+ if (returnValue.response === 0) autoUpdater.quitAndInstall();
+ });
+ });
+
+ autoUpdater.on("error", (message) => {
+ sendUpdateStatus("error");
+ console.error("[Update] There was a problem updating the application:", message);
+ });
+ }
+
+ electronApp.setAppUserModelId("com.pipelab");
+ createWindow();
+
+ if (mainWindow) {
+ registerIpcHandlers();
+
+ // Show a splash screen/loading state while waiting for the server
+ if (is.dev) {
+ // In dev, we might already have the dev server up
+ mainWindow.loadURL(`http://localhost:${uiDevPort}`);
+ } else {
+ // In prod, load the local bundled index.html as a splash screen
+ // The Forge Vite plugin exposes these globals
+ if (typeof MAIN_WINDOW_VITE_DEV_SERVER_URL !== "undefined") {
+ mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
+ } else {
+ mainWindow.loadFile(join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
+ }
+ }
+
+ mainWindow.once("ready-to-show", () => {
+ mainWindow?.show();
+ mainWindow?.maximize();
+ });
+ }
+
+ // Start the background server (this might include downloading the CLI on first run)
+ try {
+ console.info("[Main] Starting standalone server...");
+ await startServer();
+ console.info("[Main] Standalone server is ready");
+
+ // Once server is ready, load the real UI
+ if (!is.dev) {
+ console.info(`[Main] Loading production UI from localhost:${websocketPort}`);
+ mainWindow?.loadURL(`http://localhost:${websocketPort}`);
+ }
+ } catch (error) {
+ console.error("Failed to start standalone server:", error);
+ dialog.showErrorBox(
+ "Startup Error",
+ "Failed to start the background server. This is required for Pipelab to function.\n\n" +
+ (error instanceof Error ? error.message : String(error))
+ );
+ app.quit();
+ return;
+ }
+
+ if (app.isPackaged) {
+ setTimeout(() => {
+ autoUpdater.checkForUpdates();
+ }, 10000);
+ }
+
+ protocol.handle("media", (request) => {
+ const path = decodeURIComponent(request.url.replace(/^media:\/\/+/, "/"));
+ return net.fetch(pathToFileURL(path).toString());
+ });
+
+ app.on("browser-window-created", (_, window) => {
+ optimizer.watchWindowShortcuts(window);
+ });
+
+
+ app.on("activate", function () {
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
+ });
+});
+
+app.on("window-all-closed", async () => {
+ if (process.platform !== "darwin") {
+ stopServer();
+ app.quit();
+ }
+});
+
+app.on("before-quit", async (event) => {
+ event.preventDefault();
+ stopServer();
+ app.exit(0);
+});
diff --git a/apps/desktop/src/main/ipc-handlers.ts b/apps/desktop/src/main/ipc-handlers.ts
new file mode 100644
index 00000000..a628fd25
--- /dev/null
+++ b/apps/desktop/src/main/ipc-handlers.ts
@@ -0,0 +1,36 @@
+import { ipcMain, dialog, BrowserWindow, shell } from "electron";
+
+export const registerIpcHandlers = () => {
+ console.log("[Main] Registering IPC handlers");
+
+ const OPEN_DIALOG_CHANNEL = "dialog:showOpenDialog";
+ const SAVE_DIALOG_CHANNEL = "dialog:showSaveDialog";
+
+ ipcMain.removeHandler(OPEN_DIALOG_CHANNEL);
+ ipcMain.handle(OPEN_DIALOG_CHANNEL, async (event, options) => {
+ const win = BrowserWindow.fromWebContents(event.sender) || undefined;
+ const result = await dialog.showOpenDialog(win!, options);
+ return {
+ canceled: result.canceled,
+ filePaths: result.filePaths,
+ };
+ });
+
+ ipcMain.removeHandler(SAVE_DIALOG_CHANNEL);
+ ipcMain.handle(SAVE_DIALOG_CHANNEL, async (event, options) => {
+ const win = BrowserWindow.fromWebContents(event.sender) || undefined;
+ const result = await dialog.showSaveDialog(win!, options);
+ return {
+ canceled: result.canceled,
+ filePath: result.filePath,
+ };
+ });
+
+ ipcMain.handle("shell:openExternal", async (event, url) => {
+ return await shell.openExternal(url);
+ });
+
+ ipcMain.handle("shell:showItemInFolder", (event, path) => {
+ shell.showItemInFolder(path);
+ });
+};
diff --git a/apps/desktop/src/main/server-process.ts b/apps/desktop/src/main/server-process.ts
new file mode 100644
index 00000000..f0ca9d67
--- /dev/null
+++ b/apps/desktop/src/main/server-process.ts
@@ -0,0 +1,122 @@
+import { spawn, ChildProcess } from "node:child_process";
+import { join, dirname } from "node:path";
+import { app } from "electron";
+import { is } from "@electron-toolkit/utils";
+import http from "node:http";
+import fs from "node:fs";
+import { websocketPort, uiDevPort, getUiDevServerFatalError } from "@pipelab/constants";
+import { fetchPipelabCli, projectRoot, PipelabContext, getDefaultUserDataPath } from "@pipelab/core-node";
+
+let serverProcess: ChildProcess | null = null;
+
+const isUp = (port: number, delay = 1000, shouldContinue?: () => boolean, silent = false): Promise =>
+ new Promise((resolve) => {
+ const attempt = () => {
+ const req = http.get(`http://localhost:${port}`, (res) => {
+ res.resume();
+ if (!silent) console.info(`[Server Check] Server is up on port ${port}`);
+ resolve(true);
+ });
+
+ req.on("error", () => {
+ if (!shouldContinue || shouldContinue()) {
+ if (!silent) {
+ console.info(`[Server Check] Waiting for server on port ${port}...`);
+ }
+ setTimeout(attempt, delay);
+ } else {
+ if (!silent) {
+ console.error(`[Server Check] Server check aborted for port ${port}`);
+ }
+ resolve(false);
+ }
+ });
+ };
+ attempt();
+ });
+
+export const startServer = async () => {
+ if (serverProcess) {
+ return;
+ }
+
+ const userDataPath = getDefaultUserDataPath();
+
+ // 0. In dev mode, ensure UI dev server is running BEFORE anything else
+ if (is.dev) {
+ let retries = 5;
+ const isUIUp = await isUp(uiDevPort, 500, () => retries-- > 0, true);
+ if (!isUIUp) {
+ console.error(getUiDevServerFatalError(uiDevPort));
+ throw new Error("UI dev server not found. App cannot start in development mode.");
+ }
+ }
+
+ // 1. Check if server is already running
+ let initialRetries = 1;
+ const alreadyUp = await isUp(websocketPort, 500, () => initialRetries-- > 0, true);
+ if (alreadyUp) {
+ console.info(`[Server] Server already running on port ${websocketPort}`);
+ return;
+ }
+
+ if (is.dev) {
+ console.info(" [DEVELOPMENT MODE] CLI server is starting automatically.");
+ }
+
+ // 2. Resolve the CLI
+ const context = new PipelabContext({ userDataPath });
+ const { entryPoint, isLocal, packageDir } = await fetchPipelabCli("latest", { context });
+
+ let serverPath = process.execPath;
+ let args = [entryPoint, "serve"];
+
+ if (isLocal && entryPoint.endsWith(".ts")) {
+ console.info(
+ `[Server] Local CLI detected at ${packageDir}, starting with hot-reload (tsx watch)`,
+ );
+ const tsxPath = projectRoot ? join(projectRoot, "node_modules", ".bin", "tsx") : "tsx";
+ // When using tsx, we still use Electron as the runner
+ args = [tsxPath, "watch", entryPoint, "serve"];
+ } else {
+ console.info(`[Server] Starting CLI server from: ${entryPoint}`);
+ }
+
+ let isServerRunning = true;
+ serverProcess = spawn(serverPath, args, {
+ env: {
+ ...process.env,
+ ELECTRON_RUN_AS_NODE: "1",
+ NODE_ENV: is.dev ? "development" : "production",
+ PORT: websocketPort.toString(),
+ IS_SERVER: "true",
+ },
+ stdio: ["inherit", "inherit", "inherit"],
+ });
+
+ serverProcess.on("error", (err) => console.error("ERROR: Failed to spawn server:", err));
+ serverProcess.stdout?.on("data", (d) => console.info(`[Server] ${d.toString().trim()}`));
+ serverProcess.stderr?.on("data", (d) => console.error(`[Server Error] ${d.toString().trim()}`));
+
+ serverProcess.on("close", (code) => {
+ console.info(`Server process exited with code ${code}`);
+ serverProcess = null;
+ isServerRunning = false;
+ });
+
+ // Wait for the server to be listening on the port
+ console.log(`[Server] Waiting for server on port ${websocketPort}...`);
+ // Wait indefinitely as long as the server process is running
+ const up = await isUp(websocketPort, 1000, () => isServerRunning);
+ if (!up) {
+ throw new Error(`Server failed to start on port ${websocketPort} (process exited)`);
+ }
+ console.log(`[Server] CLI server is listening!`);
+};
+
+export const stopServer = () => {
+ if (serverProcess) {
+ serverProcess.kill();
+ serverProcess = null;
+ }
+};
diff --git a/apps/desktop/src/preload.d.ts b/apps/desktop/src/preload.d.ts
new file mode 100644
index 00000000..ab1657ae
--- /dev/null
+++ b/apps/desktop/src/preload.d.ts
@@ -0,0 +1,8 @@
+import { ElectronAPI } from "@electron-toolkit/preload";
+
+declare global {
+ interface Window {
+ electron: ElectronAPI;
+ api: unknown;
+ }
+}
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
new file mode 100644
index 00000000..0095b8ba
--- /dev/null
+++ b/apps/desktop/src/preload.ts
@@ -0,0 +1,52 @@
+import { contextBridge, ipcRenderer } from "electron";
+import { electronAPI } from "@electron-toolkit/preload";
+import { version } from "../package.json";
+
+// Custom APIs for renderer
+// TODO: unify window and contextBridge
+
+// Use `contextBridge` APIs to expose Electron APIs to
+// renderer only if context isolation is enabled, otherwise
+// just add to the DOM global.
+if (process.contextIsolated) {
+ try {
+ contextBridge.exposeInMainWorld("electron", electronAPI);
+
+ const versions = {
+ electron: process.versions.electron,
+ chrome: process.versions.chrome,
+ node: process.versions.node,
+ app: version,
+ };
+ console.log("[Preload] Exposing versions:", versions);
+
+ contextBridge.exposeInMainWorld("pipelab", {
+ versions,
+ showOpenDialog: (options: any) => ipcRenderer.invoke("dialog:showOpenDialog", options),
+ showSaveDialog: (options: any) => ipcRenderer.invoke("dialog:showSaveDialog", options),
+ openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
+ showItemInFolder: (path: string) => ipcRenderer.invoke("shell:showItemInFolder", path),
+ });
+ contextBridge.exposeInMainWorld("version", version);
+ contextBridge.exposeInMainWorld("isPackaged", process.env.NODE_ENV !== "development");
+ } catch (error) {
+ console.error(error);
+ }
+} else {
+ window.electron = electronAPI;
+ const versions = {
+ electron: process.versions.electron,
+ chrome: process.versions.chrome,
+ node: process.versions.node,
+ app: version,
+ };
+ window.pipelab = {
+ versions,
+ showOpenDialog: (options: any) => ipcRenderer.invoke("dialog:showOpenDialog", options),
+ showSaveDialog: (options: any) => ipcRenderer.invoke("dialog:showSaveDialog", options),
+ openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
+ showItemInFolder: (path: string) => ipcRenderer.invoke("shell:showItemInFolder", path),
+ };
+ window.version = version;
+ window.isPackaged = process.env.NODE_ENV !== "development";
+}
diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json
new file mode 100644
index 00000000..d860e9ce
--- /dev/null
+++ b/apps/desktop/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "extends": "@pipelab/tsconfig/commonjs.json",
+ "compilerOptions": {
+ "emitDeclarationOnly": true,
+ "baseUrl": ".",
+ "outDir": "dist",
+ "inlineSourceMap": true,
+ "paths": {
+ "@pipelab/shared": ["../../packages/shared/src/index.ts"],
+ "@pipelab/constants": ["../../packages/constants/src/index.ts"],
+ "@pipelab/migration": ["../../packages/migration/src"],
+ "@pipelab/*": ["../../packages/shared/src/libs/*"]
+ }
+ },
+ "include": ["src/**/*", "tests/**/*", "*.ts", "*.mts", "package.json"],
+ "exclude": [
+ "node_modules",
+ "./assets/tauri/template/app/src-tauri",
+ "dist",
+ "out",
+ "scripts",
+ "./assets"
+ ],
+ "references": [
+ {
+ "path": "../../packages/constants"
+ },
+ {
+ "path": "../../packages/shared"
+ }
+ ]
+}
diff --git a/apps/desktop/vite.base.config.mts b/apps/desktop/vite.base.config.mts
new file mode 100644
index 00000000..0bb17acd
--- /dev/null
+++ b/apps/desktop/vite.base.config.mts
@@ -0,0 +1,103 @@
+import { builtinModules } from "node:module";
+import type { AddressInfo } from "node:net";
+import { loadEnv, type ConfigEnv, type Plugin, type UserConfig } from "vite";
+import pkg from "./package.json" with { type: "json" };
+
+export const builtins = ["electron", ...builtinModules.map((m) => [m, `node:${m}`]).flat()];
+
+export const external = [
+ ...builtins,
+ ...Object.keys("dependencies" in pkg ? (pkg.dependencies as Record) : {}),
+];
+
+export function getBuildConfig(env: ConfigEnv<"build">): UserConfig {
+ const { root, mode, command } = env;
+
+ return {
+ root,
+ mode,
+ build: {
+ // Prevent multiple builds from interfering with each other.
+ emptyOutDir: false,
+ // π§ Multiple builds may conflict.
+ outDir: ".vite/build",
+ watch: command === "serve" ? {} : null,
+ minify: command === "build",
+ },
+ clearScreen: false,
+ };
+}
+
+export function getDefineKeys(names: string[]) {
+ const define: { [name: string]: VitePluginRuntimeKeys } = {};
+
+ return names.reduce((acc, name) => {
+ const NAME = name.toUpperCase();
+ const keys: VitePluginRuntimeKeys = {
+ VITE_DEV_SERVER_URL: `${NAME}_VITE_DEV_SERVER_URL`,
+ VITE_NAME: `${NAME}_VITE_NAME`,
+ };
+
+ return { ...acc, [name]: keys };
+ }, define);
+}
+
+export function getBuildDefine(env: ConfigEnv<"build">) {
+ const { command, forgeConfig } = env;
+ const names =
+ forgeConfig?.renderer?.filter(({ name }) => name != null).map(({ name }) => name!) || [];
+ const defineKeys = getDefineKeys(names);
+ const define = Object.entries(defineKeys).reduce(
+ (acc, [name, keys]) => {
+ const { VITE_DEV_SERVER_URL, VITE_NAME } = keys;
+ const def = {
+ [VITE_DEV_SERVER_URL]:
+ command === "serve" ? JSON.stringify(process.env[VITE_DEV_SERVER_URL]) : undefined,
+ [VITE_NAME]: JSON.stringify(name),
+ };
+ return { ...acc, ...def };
+ },
+ {} as Record,
+ );
+
+ return define;
+}
+
+export function pluginExposeRenderer(name: string): Plugin {
+ const { VITE_DEV_SERVER_URL } = getDefineKeys([name])[name];
+
+ return {
+ name: "@electron-forge/plugin-vite:expose-renderer",
+ configureServer(server) {
+ process.viteDevServers ??= {};
+ // Expose server for preload scripts hot reload.
+ process.viteDevServers[name] = server;
+
+ server.httpServer?.once("listening", () => {
+ const addressInfo = server.httpServer!.address() as AddressInfo;
+ // Expose env constant for main process use.
+ process.env[VITE_DEV_SERVER_URL] = `http://localhost:${addressInfo?.port}`;
+ });
+ },
+ };
+}
+
+export function pluginHotRestart(command: "reload" | "restart"): Plugin {
+ return {
+ name: "@electron-forge/plugin-vite:hot-restart",
+ closeBundle() {
+ if (command === "reload") {
+ if (process.viteDevServers) {
+ for (const server of Object.values(process.viteDevServers)) {
+ // Preload scripts hot reload.
+ server.ws.send({ type: "full-reload" });
+ }
+ }
+ } else {
+ // Main process hot restart.
+ // https://github.com/electron/forge/blob/v7.2.0/packages/api/core/src/api/start.ts#L216-L223
+ process.stdin.emit("data", "rs");
+ }
+ },
+ };
+}
diff --git a/apps/desktop/vite.main.config.mts b/apps/desktop/vite.main.config.mts
new file mode 100644
index 00000000..1fe4f1a1
--- /dev/null
+++ b/apps/desktop/vite.main.config.mts
@@ -0,0 +1,70 @@
+import type { ConfigEnv, UserConfig } from "vite";
+import { defineConfig, loadEnv, mergeConfig } from "vite";
+import { getBuildConfig, getBuildDefine, external, pluginHotRestart } from "./vite.base.config.mts";
+import tsconfigPaths from "vite-tsconfig-paths";
+import { resolve } from "path";
+
+// https://vitejs.dev/config
+export default defineConfig((env) => {
+ const forgeEnv = env as ConfigEnv<"build">;
+ const { forgeConfigSelf } = forgeEnv;
+ const define = getBuildDefine(forgeEnv);
+ const rootPath = resolve(__dirname, "../../");
+ const environment = loadEnv(env.mode, rootPath, "");
+
+ const plugins = [
+ pluginHotRestart("restart"),
+ tsconfigPaths({
+ projects: ["./tsconfig.json"],
+ }),
+ ];
+
+ // check if we are in a tag
+ const tag = process.env.GITHUB_REF?.includes("refs/tags/");
+ console.log("tag", tag);
+
+ const config: UserConfig = {
+ envDir: rootPath,
+ build: {
+ sourcemap: true,
+ rollupOptions: {
+ input: forgeConfigSelf?.entry || "src/main.ts",
+ output: {
+ format: "cjs",
+ entryFileNames: "[name].js",
+ chunkFileNames: "[name].js",
+ assetFileNames: "[name].[ext]",
+ },
+ external: [
+ ...external.filter((dep) => {
+ const isPipelab = dep.startsWith("@pipelab/");
+ const isElectronToolkit = dep.startsWith("@electron-toolkit/");
+ const isSquirrel = dep === "electron-squirrel-startup";
+ const isPosthog = dep === "posthog-node";
+ return !isPipelab && !isElectronToolkit && !isSquirrel && !isPosthog;
+ }),
+ "bufferutil",
+ "utf-8-validate",
+ ],
+ },
+ },
+ plugins,
+ define: {
+ ...define,
+ "process.env.POSTHOG_API_KEY": JSON.stringify(environment.POSTHOG_API_KEY),
+ "process.env.SUPABASE_URL": JSON.stringify(environment.SUPABASE_URL),
+ "process.env.SUPABASE_ANON_KEY": JSON.stringify(environment.SUPABASE_ANON_KEY),
+ },
+ ssr: {
+ // Ensure we target Node.js for the main process
+ target: "node",
+ },
+ resolve: {
+ // Load the Node.js entry.
+ mainFields: ["module", "jsnext:main", "jsnext"],
+ conditions: ["node"],
+ },
+ };
+
+ return mergeConfig(getBuildConfig(forgeEnv), config);
+});
diff --git a/apps/desktop/vite.preload.config.mts b/apps/desktop/vite.preload.config.mts
new file mode 100644
index 00000000..7cf77ef7
--- /dev/null
+++ b/apps/desktop/vite.preload.config.mts
@@ -0,0 +1,37 @@
+import type { ConfigEnv, UserConfig } from "vite";
+import { defineConfig, mergeConfig } from "vite";
+import { getBuildConfig, external, pluginHotRestart } from "./vite.base.config.mts";
+import tsconfigPaths from "vite-tsconfig-paths";
+import { resolve } from "path";
+
+// https://vitejs.dev/config
+export default defineConfig((env) => {
+ const forgeEnv = env as ConfigEnv<"build">;
+ const { forgeConfigSelf } = forgeEnv;
+ const config: UserConfig = {
+ build: {
+ rollupOptions: {
+ external: external.filter((dep) => !dep.startsWith("@pipelab/")),
+ // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
+ input: forgeConfigSelf?.entry || "src/preload.ts",
+ output: {
+ format: "cjs",
+ // It should not be split chunks.
+ inlineDynamicImports: true,
+ entryFileNames: "[name].cjs",
+ chunkFileNames: "[name].js",
+ assetFileNames: "[name].[ext]",
+ },
+ },
+ },
+ plugins: [
+ pluginHotRestart("reload"),
+ tsconfigPaths({
+ projects: ["./tsconfig.json"],
+ }),
+ ],
+ resolve: {},
+ };
+
+ return mergeConfig(getBuildConfig(forgeEnv), config);
+});
diff --git a/apps/desktop/vite.renderer.config.mts b/apps/desktop/vite.renderer.config.mts
new file mode 100644
index 00000000..0ca6f959
--- /dev/null
+++ b/apps/desktop/vite.renderer.config.mts
@@ -0,0 +1,33 @@
+import type { ConfigEnv, UserConfig } from "vite";
+import { defineConfig, loadEnv } from "vite";
+import { pluginExposeRenderer } from "./vite.base.config.mts";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+export default defineConfig((env) => {
+ const forgeEnv = env as ConfigEnv<"renderer">;
+ const { root, mode, forgeConfigSelf } = forgeEnv;
+ const name = forgeConfigSelf.name ?? "";
+
+ const plugins = [
+ pluginExposeRenderer(name),
+ tsconfigPaths({
+ projects: ["./tsconfig.json"],
+ }),
+ ];
+
+ return {
+ root,
+ mode,
+ base: "./",
+ build: {
+ outDir: `.vite/renderer/${name}`,
+ sourcemap: true,
+ },
+ server: {
+ port: 5183,
+ strictPort: true,
+ },
+ plugins,
+ clearScreen: false,
+ } as UserConfig;
+});
diff --git a/apps/ui/.gitignore b/apps/ui/.gitignore
new file mode 100644
index 00000000..53c37a16
--- /dev/null
+++ b/apps/ui/.gitignore
@@ -0,0 +1 @@
+dist
\ No newline at end of file
diff --git a/apps/ui/CHANGELOG.md b/apps/ui/CHANGELOG.md
new file mode 100644
index 00000000..9ecbd4fd
--- /dev/null
+++ b/apps/ui/CHANGELOG.md
@@ -0,0 +1,398 @@
+# @pipelab/ui
+
+## 2.0.1-latest.44
+
+### Patch Changes
+
+- sdq
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.41
+ - @pipelab/shared@2.0.1-latest.42
+
+## 2.0.1-latest.43
+
+### Patch Changes
+
+- qsd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.40
+ - @pipelab/shared@2.0.1-latest.41
+
+## 2.0.1-latest.42
+
+### Patch Changes
+
+- hyg
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.39
+ - @pipelab/shared@2.0.1-latest.40
+
+## 2.0.1-latest.41
+
+### Patch Changes
+
+- sdf
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.38
+ - @pipelab/shared@2.0.1-latest.39
+
+## 2.0.1-latest.40
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.37
+ - @pipelab/shared@2.0.1-latest.38
+
+## 2.0.1-latest.39
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.36
+ - @pipelab/shared@2.0.1-latest.37
+
+## 2.0.1-latest.38
+
+### Patch Changes
+
+- qsd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.35
+ - @pipelab/shared@2.0.1-latest.36
+
+## 2.0.1-latest.37
+
+### Patch Changes
+
+- sdfc
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.34
+ - @pipelab/shared@2.0.1-latest.35
+
+## 2.0.1-latest.36
+
+### Patch Changes
+
+- qsd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.33
+ - @pipelab/shared@2.0.1-latest.34
+
+## 2.0.1-latest.35
+
+### Patch Changes
+
+- sdq
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.32
+ - @pipelab/shared@2.0.1-latest.33
+
+## 2.0.1-latest.34
+
+### Patch Changes
+
+- ad
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.31
+ - @pipelab/shared@2.0.1-latest.32
+
+## 2.0.1-latest.33
+
+### Patch Changes
+
+- sdqsd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.30
+ - @pipelab/shared@2.0.1-latest.31
+
+## 2.0.1-latest.32
+
+### Patch Changes
+
+- sqd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-latest.29
+ - @pipelab/shared@2.0.1-latest.30
+
+## 2.0.1-beta.31
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.28
+ - @pipelab/shared@2.0.1-beta.29
+
+## 2.0.1-beta.30
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.27
+ - @pipelab/shared@2.0.1-beta.28
+
+## 2.0.1-beta.29
+
+### Patch Changes
+
+- df
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.26
+ - @pipelab/shared@2.0.1-beta.27
+
+## 2.0.1-beta.28
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.25
+ - @pipelab/shared@2.0.1-beta.26
+
+## 2.0.1-beta.27
+
+### Patch Changes
+
+- dfg
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.24
+ - @pipelab/shared@2.0.1-beta.25
+
+## 2.0.1-beta.26
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.23
+ - @pipelab/shared@2.0.1-beta.24
+
+## 2.0.1-beta.25
+
+### Patch Changes
+
+- xwc
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.22
+ - @pipelab/shared@2.0.1-beta.23
+
+## 2.0.1-beta.24
+
+### Patch Changes
+
+- sdsd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.21
+ - @pipelab/shared@2.0.1-beta.22
+
+## 2.0.1-beta.23
+
+### Patch Changes
+
+- gf
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.20
+ - @pipelab/shared@2.0.1-beta.21
+
+## 2.0.1-beta.22
+
+### Patch Changes
+
+- azs
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.19
+ - @pipelab/shared@2.0.1-beta.20
+
+## 2.0.1-beta.21
+
+### Patch Changes
+
+- sqqd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.18
+ - @pipelab/shared@2.0.1-beta.19
+
+## 2.0.1-beta.20
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.17
+ - @pipelab/shared@2.0.1-beta.18
+
+## 2.0.1-beta.19
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.16
+ - @pipelab/shared@2.0.1-beta.17
+
+## 2.0.1-beta.18
+
+### Patch Changes
+
+- wdf
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.15
+ - @pipelab/shared@2.0.1-beta.16
+
+## 2.0.1-beta.17
+
+### Patch Changes
+
+- sdf
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.14
+ - @pipelab/shared@2.0.1-beta.15
+
+## 2.0.1-beta.16
+
+## 2.0.1-beta.15
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.13
+ - @pipelab/shared@2.0.1-beta.14
+
+## 2.0.1-beta.14
+
+### Patch Changes
+
+- rfg
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.12
+ - @pipelab/shared@2.0.1-beta.13
+
+## 2.0.1-beta.13
+
+### Patch Changes
+
+- szd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.11
+ - @pipelab/shared@2.0.1-beta.12
+
+## 2.0.1-beta.12
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.10
+ - @pipelab/shared@2.0.1-beta.11
+
+## 2.0.1-beta.11
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.9
+ - @pipelab/shared@2.0.1-beta.10
+
+## 2.0.1-beta.10
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.8
+ - @pipelab/shared@2.0.1-beta.9
+
+## 2.0.1-beta.9
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.7
+ - @pipelab/shared@2.0.1-beta.8
+
+## 2.0.1-beta.8
+
+## 2.0.1-beta.7
+
+### Patch Changes
+
+- test
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.6
+ - @pipelab/shared@2.0.1-beta.7
+
+## 2.0.1-beta.6
+
+### Patch Changes
+
+- test
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.5
+ - @pipelab/shared@2.0.1-beta.6
+
+## 2.0.1-beta.5
+
+### Patch Changes
+
+- sd
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.4
+ - @pipelab/shared@2.0.1-beta.5
+
+## 2.0.1-beta.4
+
+### Patch Changes
+
+- tesyt
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.3
+ - @pipelab/shared@2.0.1-beta.4
+
+## 2.0.1-beta.3
+
+### Patch Changes
+
+- last test
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.2
+ - @pipelab/shared@2.0.1-beta.3
+
+## 2.0.1-beta.2
+
+### Patch Changes
+
+- Updated dependencies
+ - @pipelab/shared@2.0.1-beta.2
+
+## 2.0.1-beta.1
+
+### Patch Changes
+
+- 9268b1e: test 3
+- Updated dependencies [9268b1e]
+ - @pipelab/constants@1.0.1-beta.1
+ - @pipelab/shared@2.0.1-beta.1
+
+## 2.0.1-beta.0
+
+### Patch Changes
+
+- test
+- Updated dependencies
+ - @pipelab/constants@1.0.1-beta.0
+ - @pipelab/shared@2.0.1-beta.0
+
+## 2.0.1-beta.0
+
+## 2.0.0
+
+### Major Changes
+
+- V2 Release: Major version bump for application packages.
diff --git a/apps/ui/README.md b/apps/ui/README.md
new file mode 100644
index 00000000..1d977c6a
--- /dev/null
+++ b/apps/ui/README.md
@@ -0,0 +1,34 @@
+# @pipelab/ui (The Interface)
+
+The main visual interface for Pipelab, built with Vue 3 and PrimeVue.
+
+## π¨ Role & Connectivity
+
+The UI is a "thin client" designed to visualize and edit automation pipelines. It does not execute logic directly; instead, it orchestrates the `@pipelab/cli` engine.
+
+### Connection Discovery
+
+When the UI starts, it attempts to connect to the Pipelab Engine (CLI process):
+
+1. **Discovery**: It looks for a running engine on the configured port (default: `33753`).
+2. **Streaming**: Once connected, it receives real-time execution logs and progress updates via WebSockets.
+3. **Authentication**: Syncs its authentication state with the engine's Supabase instance.
+
+## π οΈ Development
+
+### Tech Stack
+
+- **Framework**: Vue 3 (Composition API)
+- **UI Library**: PrimeVue v4
+- **State**: Pinia
+- **Icons**: PrimeIcons / Lucide
+
+### Commands
+
+```bash
+pnpm dev # Starts the Vite development server (port 5173)
+pnpm build # Generates the production assets in the /dist folder
+```
+
+> [!NOTE]
+> During development, the UI expects the `@pipelab/cli` to be running separately (via `pnpm dev` at the root).
diff --git a/apps/ui/auto-imports.d.ts b/apps/ui/auto-imports.d.ts
new file mode 100644
index 00000000..f6e2bab3
--- /dev/null
+++ b/apps/ui/auto-imports.d.ts
@@ -0,0 +1,88 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+// biome-ignore lint: disable
+export {}
+declare global {
+ const EffectScope: typeof import('vue')['EffectScope']
+ const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
+ const computed: typeof import('vue')['computed']
+ const createApp: typeof import('vue')['createApp']
+ const createPinia: typeof import('pinia')['createPinia']
+ const customRef: typeof import('vue')['customRef']
+ const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
+ const defineComponent: typeof import('vue')['defineComponent']
+ const defineStore: typeof import('pinia')['defineStore']
+ const effectScope: typeof import('vue')['effectScope']
+ const getActivePinia: typeof import('pinia')['getActivePinia']
+ const getCurrentInstance: typeof import('vue')['getCurrentInstance']
+ const getCurrentScope: typeof import('vue')['getCurrentScope']
+ const h: typeof import('vue')['h']
+ const inject: typeof import('vue')['inject']
+ const isProxy: typeof import('vue')['isProxy']
+ const isReactive: typeof import('vue')['isReactive']
+ const isReadonly: typeof import('vue')['isReadonly']
+ const isRef: typeof import('vue')['isRef']
+ const mapActions: typeof import('pinia')['mapActions']
+ const mapGetters: typeof import('pinia')['mapGetters']
+ const mapState: typeof import('pinia')['mapState']
+ const mapStores: typeof import('pinia')['mapStores']
+ const mapWritableState: typeof import('pinia')['mapWritableState']
+ const markRaw: typeof import('vue')['markRaw']
+ const nextTick: typeof import('vue')['nextTick']
+ const onActivated: typeof import('vue')['onActivated']
+ const onBeforeMount: typeof import('vue')['onBeforeMount']
+ const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
+ const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
+ const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
+ const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
+ const onDeactivated: typeof import('vue')['onDeactivated']
+ const onErrorCaptured: typeof import('vue')['onErrorCaptured']
+ const onMounted: typeof import('vue')['onMounted']
+ const onRenderTracked: typeof import('vue')['onRenderTracked']
+ const onRenderTriggered: typeof import('vue')['onRenderTriggered']
+ const onScopeDispose: typeof import('vue')['onScopeDispose']
+ const onServerPrefetch: typeof import('vue')['onServerPrefetch']
+ const onUnmounted: typeof import('vue')['onUnmounted']
+ const onUpdated: typeof import('vue')['onUpdated']
+ const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
+ const provide: typeof import('vue')['provide']
+ const reactive: typeof import('vue')['reactive']
+ const readonly: typeof import('vue')['readonly']
+ const ref: typeof import('vue')['ref']
+ const resolveComponent: typeof import('vue')['resolveComponent']
+ const setActivePinia: typeof import('pinia')['setActivePinia']
+ const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
+ const shallowReactive: typeof import('vue')['shallowReactive']
+ const shallowReadonly: typeof import('vue')['shallowReadonly']
+ const shallowRef: typeof import('vue')['shallowRef']
+ const storeToRefs: typeof import('pinia')['storeToRefs']
+ const toRaw: typeof import('vue')['toRaw']
+ const toRef: typeof import('vue')['toRef']
+ const toRefs: typeof import('vue')['toRefs']
+ const toValue: typeof import('vue')['toValue']
+ const triggerRef: typeof import('vue')['triggerRef']
+ const unref: typeof import('vue')['unref']
+ const useAttrs: typeof import('vue')['useAttrs']
+ const useCssModule: typeof import('vue')['useCssModule']
+ const useCssVars: typeof import('vue')['useCssVars']
+ const useId: typeof import('vue')['useId']
+ const useLink: typeof import('vue-router')['useLink']
+ const useModel: typeof import('vue')['useModel']
+ const useRoute: typeof import('vue-router')['useRoute']
+ const useRouter: typeof import('vue-router')['useRouter']
+ const useSlots: typeof import('vue')['useSlots']
+ const useTemplateRef: typeof import('vue')['useTemplateRef']
+ const watch: typeof import('vue')['watch']
+ const watchEffect: typeof import('vue')['watchEffect']
+ const watchPostEffect: typeof import('vue')['watchPostEffect']
+ const watchSyncEffect: typeof import('vue')['watchSyncEffect']
+}
+// for type re-export
+declare global {
+ // @ts-ignore
+ export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
+ import('vue')
+}
diff --git a/apps/ui/components.d.ts b/apps/ui/components.d.ts
new file mode 100644
index 00000000..d4f3016f
--- /dev/null
+++ b/apps/ui/components.d.ts
@@ -0,0 +1,82 @@
+/* eslint-disable */
+// @ts-nocheck
+// Generated by unplugin-vue-components
+// Read more: https://github.com/vuejs/core/pull/3399
+export {}
+
+/* prettier-ignore */
+declare module 'vue' {
+ export interface GlobalComponents {
+ AddNodeButton: typeof import('./src/components/AddNodeButton.vue')['default']
+ AddTriggerButton: typeof import('./src/components/AddTriggerButton.vue')['default']
+ BuildDetailsModal: typeof import('./src/components/BuildDetailsModal.vue')['default']
+ BuildHistoryDialog: typeof import('./src/components/BuildHistoryDialog.vue')['default']
+ BuildHistoryItem: typeof import('./src/components/BuildHistoryItem.vue')['default']
+ BuildHistoryList: typeof import('./src/components/BuildHistoryList.vue')['default']
+ BuildHistoryView: typeof import('./src/components/BuildHistoryView.vue')['default']
+ BuildStatusBadge: typeof import('./src/components/BuildStatusBadge.vue')['default']
+ Button: typeof import('primevue/button')['default']
+ Checkbox: typeof import('primevue/checkbox')['default']
+ Chip: typeof import('primevue/chip')['default']
+ ColorPicker: typeof import('./src/components/ColorPicker.vue')['default']
+ Column: typeof import('primevue/column')['default']
+ ConfirmDialog: typeof import('primevue/confirmdialog')['default']
+ ConfirmPopup: typeof import('primevue/confirmpopup')['default']
+ ConnectingPage: typeof import('./src/components/ConnectingPage.vue')['default']
+ DataTable: typeof import('primevue/datatable')['default']
+ DevBenefitsOverride: typeof import('./src/components/DevBenefitsOverride.vue')['default']
+ Dialog: typeof import('primevue/dialog')['default']
+ DisconnectedPage: typeof import('./src/components/DisconnectedPage.vue')['default']
+ Divider: typeof import('primevue/divider')['default']
+ Drawer: typeof import('primevue/drawer')['default']
+ Dropdown: typeof import('primevue/dropdown')['default']
+ EditorNodeAction: typeof import('./src/components/nodes/EditorNodeAction.vue')['default']
+ EditorNodeCondition: typeof import('./src/components/nodes/EditorNodeCondition.vue')['default']
+ EditorNodeDummy: typeof import('./src/components/nodes/EditorNodeDummy.vue')['default']
+ EditorNodeEvent: typeof import('./src/components/nodes/EditorNodeEvent.vue')['default']
+ EditorNodeEventEmpty: typeof import('./src/components/nodes/EditorNodeEventEmpty.vue')['default']
+ EditorNodeLoop: typeof import('./src/components/nodes/EditorNodeLoop.vue')['default']
+ FileInput: typeof import('./src/components/FileInput.vue')['default']
+ IconField: typeof import('primevue/iconfield')['default']
+ Inplace: typeof import('primevue/inplace')['default']
+ InputGroup: typeof import('primevue/inputgroup')['default']
+ InputIcon: typeof import('primevue/inputicon')['default']
+ InputNumber: typeof import('primevue/inputnumber')['default']
+ InputText: typeof import('primevue/inputtext')['default']
+ Layout: typeof import('./src/components/Layout.vue')['default']
+ Listbox: typeof import('primevue/listbox')['default']
+ Menu: typeof import('primevue/menu')['default']
+ Message: typeof import('primevue/message')['default']
+ Panel: typeof import('primevue/panel')['default']
+ ParamEditor: typeof import('./src/components/nodes/ParamEditor.vue')['default']
+ ParamEditorBody: typeof import('./src/components/nodes/ParamEditorBody.vue')['default']
+ Password: typeof import('primevue/password')['default']
+ PluginIcon: typeof import('./src/components/nodes/PluginIcon.vue')['default']
+ ProgressBar: typeof import('primevue/progressbar')['default']
+ ProgressSpinner: typeof import('primevue/progressspinner')['default']
+ RouterLink: typeof import('vue-router')['RouterLink']
+ RouterView: typeof import('vue-router')['RouterView']
+ ScenarioListItem: typeof import('./src/components/ScenarioListItem.vue')['default']
+ ScenarioListItemFile: typeof import('./src/components/ScenarioListItemFile.vue')['default']
+ ScenarioListItemRecent: typeof import('./src/components/ScenarioListItemRecent.vue')['default']
+ Select: typeof import('primevue/select')['default']
+ SelectButton: typeof import('primevue/selectbutton')['default']
+ Settings: typeof import('./src/components/Settings.vue')['default']
+ Skeleton: typeof import('primevue/skeleton')['default']
+ SubscriptionLoadingIndicator: typeof import('./src/components/SubscriptionLoadingIndicator.vue')['default']
+ Tab: typeof import('primevue/tab')['default']
+ TabList: typeof import('primevue/tablist')['default']
+ TabPanel: typeof import('primevue/tabpanel')['default']
+ TabPanels: typeof import('primevue/tabpanels')['default']
+ Tabs: typeof import('primevue/tabs')['default']
+ TabView: typeof import('primevue/tabview')['default']
+ Toast: typeof import('primevue/toast')['default']
+ ToggleSwitch: typeof import('primevue/toggleswitch')['default']
+ UpgradeDialog: typeof import('./src/components/UpgradeDialog.vue')['default']
+ UpgradeNowButton: typeof import('./src/components/UpgradeNowButton.vue')['default']
+ WebFilePicker: typeof import('./src/components/WebFilePicker.vue')['default']
+ }
+ export interface ComponentCustomProperties {
+ Tooltip: typeof import('primevue/tooltip')['default']
+ }
+}
diff --git a/apps/ui/index.html b/apps/ui/index.html
new file mode 100644
index 00000000..8931f2ff
--- /dev/null
+++ b/apps/ui/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ Pipelab UI
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ui/package.json b/apps/ui/package.json
new file mode 100644
index 00000000..f8e59d1f
--- /dev/null
+++ b/apps/ui/package.json
@@ -0,0 +1,91 @@
+{
+ "name": "@pipelab/ui",
+ "version": "2.0.1-latest.44",
+ "private": true,
+ "license": "FSL-1.1-MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/CynToolkit/pipelab.git",
+ "directory": "apps/ui"
+ },
+ "type": "module",
+ "main": "src/main.ts",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json --composite false",
+ "preview": "vite preview",
+ "format": "oxfmt .",
+ "lint": "oxlint ."
+ },
+ "dependencies": {
+ "@codemirror/autocomplete": "6.18.1",
+ "@codemirror/commands": "6.6.2",
+ "@codemirror/lang-javascript": "6.2.2",
+ "@codemirror/lang-liquid": "6.2.1",
+ "@codemirror/lint": "6.8.2",
+ "@codemirror/state": "6.4.1",
+ "@codemirror/view": "6.34.0",
+ "@floating-ui/vue": "1.1.5",
+ "@jitl/quickjs-wasmfile-release-sync": "0.31.0",
+ "@mdi/font": "7.4.47",
+ "@pipelab/constants": "workspace:*",
+ "@pipelab/shared": "workspace:*",
+ "@primevue/themes": "4.1.1",
+ "@trpc/client": "10.45.2",
+ "@vee-validate/valibot": "4.13.2",
+ "@vuelidate/core": "2.0.3",
+ "@vuelidate/validators": "2.0.4",
+ "@vueuse/components": "11.1.0",
+ "@vueuse/core": "11.1.0",
+ "@vueuse/router": "11.1.0",
+ "change-case": "5.4.4",
+ "date-fns": "4.1.0",
+ "dompurify": "3.1.7",
+ "driver.js": "1.4.0",
+ "es-toolkit": "1.25.2",
+ "fancy-ansi": "0.1.3",
+ "geist": "1.3.1",
+ "get-value": "3.0.1",
+ "klona": "2.0.6",
+ "mutative": "1.0.11",
+ "nanoid": "5.0.7",
+ "path-browserify": "1.0.1",
+ "pinia": "2.2.4",
+ "pinia-plugin-persistedstate": "4.1.1",
+ "polished": "4.3.1",
+ "posthog-js": "1.215.0",
+ "primeflex": "4.0.0",
+ "primeicons": "7.0.0",
+ "primevue": "4.5.4",
+ "quickjs-emscripten": "0.31.0",
+ "quickjs-emscripten-sync": "1.6.0",
+ "set-value": "4.1.0",
+ "string-strip-html": "13.4.8",
+ "thememirror": "2.0.1",
+ "tinykeys": "2.1.0",
+ "ts-pattern": "5.5.0",
+ "tslog": "4.9.3",
+ "type-fest": "4.26.1",
+ "valibot": "0.42.1",
+ "vee-validate": "4.14.4",
+ "vue": "3.5.13",
+ "vue-dompurify-html": "5.1.0",
+ "vue-i18n": "10",
+ "vue-router": "4.4.5"
+ },
+ "devDependencies": {
+ "@pipelab/tsconfig": "workspace:*",
+ "@primevue/auto-import-resolver": "4.1.1",
+ "@types/node": "24.12.2",
+ "@vitejs/devtools": "0.1.13",
+ "@vitejs/plugin-vue": "6.0.5",
+ "sass-embedded": "^1.83.4",
+ "typescript": "5.8.3",
+ "unplugin-auto-import": "0.18.3",
+ "unplugin-vue-components": "0.27.4",
+ "vite": "8.0.8",
+ "vite-plugin-vue-devtools": "8.1.1",
+ "vue-tsc": "2.1.6"
+ }
+}
diff --git a/apps/ui/src/App.vue b/apps/ui/src/App.vue
new file mode 100644
index 00000000..75a5b4ec
--- /dev/null
+++ b/apps/ui/src/App.vue
@@ -0,0 +1,309 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/Root.vue b/apps/ui/src/Root.vue
similarity index 78%
rename from src/renderer/Root.vue
rename to apps/ui/src/Root.vue
index 014536e6..b0b1d134 100644
--- a/src/renderer/Root.vue
+++ b/apps/ui/src/Root.vue
@@ -3,7 +3,7 @@
diff --git a/src/renderer/agent/log.ts b/apps/ui/src/agent/log.ts
similarity index 100%
rename from src/renderer/agent/log.ts
rename to apps/ui/src/agent/log.ts
diff --git a/src/renderer/agent/trpc.ts b/apps/ui/src/agent/trpc.ts
similarity index 74%
rename from src/renderer/agent/trpc.ts
rename to apps/ui/src/agent/trpc.ts
index 9ba7243a..56fa3b0f 100644
--- a/src/renderer/agent/trpc.ts
+++ b/apps/ui/src/agent/trpc.ts
@@ -1,4 +1,4 @@
-import { initTRPC } from '@trpc/server';
+import { initTRPC } from "@trpc/server";
/**
* Initialization of tRPC backend
@@ -11,4 +11,4 @@ const t = initTRPC.create();
* that can be used throughout the router
*/
export const router = t.router;
-export const publicProcedure = t.procedure;
\ No newline at end of file
+export const publicProcedure = t.procedure;
diff --git a/apps/ui/src/agent/utils/json.ts b/apps/ui/src/agent/utils/json.ts
new file mode 100644
index 00000000..85a02202
--- /dev/null
+++ b/apps/ui/src/agent/utils/json.ts
@@ -0,0 +1,7 @@
+export const safeParse = (str: string): T | undefined => {
+ try {
+ return JSON.parse(str);
+ } catch (e) {
+ return undefined;
+ }
+};
diff --git a/src/renderer/assets/css/styles.less b/apps/ui/src/assets/css/styles.less
similarity index 94%
rename from src/renderer/assets/css/styles.less
rename to apps/ui/src/assets/css/styles.less
index 2cf09d25..3a08b314 100644
--- a/src/renderer/assets/css/styles.less
+++ b/apps/ui/src/assets/css/styles.less
@@ -5,12 +5,12 @@ body {
Roboto,
-apple-system,
BlinkMacSystemFont,
- 'Helvetica Neue',
- 'Segoe UI',
- 'Oxygen',
- 'Ubuntu',
- 'Cantarell',
- 'Open Sans',
+ "Helvetica Neue",
+ "Segoe UI",
+ "Oxygen",
+ "Ubuntu",
+ "Cantarell",
+ "Open Sans",
sans-serif;
color: #86a5b1;
background-color: #2f3241;
@@ -67,7 +67,7 @@ a:hover {
float: none;
clear: both;
overflow: hidden;
- font-family: 'Menlo', 'Lucida Console', monospace;
+ font-family: "Menlo", "Lucida Console", monospace;
color: #c2f5ff;
line-height: 1;
transition: all 0.3s;
diff --git a/src/renderer/assets/icons.svg b/apps/ui/src/assets/icons.svg
similarity index 100%
rename from src/renderer/assets/icons.svg
rename to apps/ui/src/assets/icons.svg
diff --git a/src/auto-imports.d.ts b/apps/ui/src/auto-imports.d.ts
similarity index 73%
rename from src/auto-imports.d.ts
rename to apps/ui/src/auto-imports.d.ts
index d2c795cf..704c3707 100644
--- a/src/auto-imports.d.ts
+++ b/apps/ui/src/auto-imports.d.ts
@@ -5,5 +5,5 @@
// Generated by unplugin-auto-import
export {}
declare global {
- const Steps: typeof import('primevue/steps')['Steps']
+ const Steps: (typeof import("primevue/steps"))["Steps"];
}
diff --git a/apps/ui/src/components/AddNodeButton.model.ts b/apps/ui/src/components/AddNodeButton.model.ts
new file mode 100644
index 00000000..ab6b43ac
--- /dev/null
+++ b/apps/ui/src/components/AddNodeButton.model.ts
@@ -0,0 +1,15 @@
+import { PipelabNode, Event, RendererPluginDefinition } from "@pipelab/shared";
+
+export interface AddNodeEvent {
+ node: PipelabNode;
+ plugin: RendererPluginDefinition;
+ path: string[];
+ insertAt: number;
+}
+
+export interface AddTriggerEvent {
+ trigger: Event;
+ plugin: RendererPluginDefinition;
+ path: string[];
+ insertAt: number;
+}
diff --git a/src/renderer/components/AddNodeButton.vue b/apps/ui/src/components/AddNodeButton.vue
similarity index 76%
rename from src/renderer/components/AddNodeButton.vue
rename to apps/ui/src/components/AddNodeButton.vue
index 58aaa699..7d4059b2 100644
--- a/src/renderer/components/AddNodeButton.vue
+++ b/apps/ui/src/components/AddNodeButton.vue
@@ -8,7 +8,7 @@
size="small"
:pt="{
root: { style: { fontSize: '10px', width: '24px', height: '24px' } },
- icon: { style: { fontSize: '10px' } }
+ icon: { style: { fontSize: '10px' } },
}"
@click="addNode"
>
@@ -66,7 +66,8 @@
@@ -99,7 +100,7 @@
@@ -38,40 +38,40 @@
diff --git a/apps/ui/src/components/FileInput.vue b/apps/ui/src/components/FileInput.vue
new file mode 100644
index 00000000..f561a0fd
--- /dev/null
+++ b/apps/ui/src/components/FileInput.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/Layout.vue b/apps/ui/src/components/Layout.vue
similarity index 53%
rename from src/renderer/components/Layout.vue
rename to apps/ui/src/components/Layout.vue
index b03bb937..3f9b58aa 100644
--- a/src/renderer/components/Layout.vue
+++ b/apps/ui/src/components/Layout.vue
@@ -2,22 +2,62 @@