mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Merge remote-tracking branch 'origin/main' into probable-fold
This commit is contained in:
commit
abab126ea7
1357 changed files with 153898 additions and 43887 deletions
14
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
14
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
|
|
@ -64,13 +64,15 @@ body:
|
|||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / screenshots (optional)
|
||||
description: |
|
||||
If relevant, paste any error output, console logs, or screenshots.
|
||||
For daemon logs, run `pnpm tools-dev logs --json`.
|
||||
label: Logs (optional)
|
||||
description: Paste any error output or console logs. For daemon logs, run `pnpm tools-dev logs --json`.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots (optional)
|
||||
description: Drag and drop or paste images here to show the visual bug.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
|
|
|
|||
10
.github/actionlint.yaml
vendored
Normal file
10
.github/actionlint.yaml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
paths:
|
||||
.github/workflows/*.lock.yml:
|
||||
ignore:
|
||||
- 'shellcheck reported issue in this script: SC2016:.+'
|
||||
- 'shellcheck reported issue in this script: SC2086:.+'
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- agent-pr-explore
|
||||
- nexu-win
|
||||
- release-beta
|
||||
34
.github/actions/setup-playwright/action.yml
vendored
Normal file
34
.github/actions/setup-playwright/action.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: Setup Playwright
|
||||
description: Restore Playwright browser cache and install browsers
|
||||
|
||||
inputs:
|
||||
package-json-path:
|
||||
description: Path to package.json containing @playwright/test or playwright devDependency
|
||||
required: true
|
||||
install-command:
|
||||
description: Command used to install browsers
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(node -p "const pkg = require('./${{ inputs.package-json-path }}'); const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; (deps['@playwright/test'] || deps.playwright || '').replace(/[^0-9.]/g,'')")
|
||||
if [ -z "$version" ]; then
|
||||
echo "Could not resolve Playwright version from ${{ inputs.package-json-path }}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browser binaries
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
shell: bash
|
||||
run: ${{ inputs.install-command }}
|
||||
41
.github/actions/setup-workspace/action.yml
vendored
Normal file
41
.github/actions/setup-workspace/action.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: Setup workspace
|
||||
description: Restore pnpm cache and install dependencies
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version to install
|
||||
required: false
|
||||
default: '24'
|
||||
pnpm-version:
|
||||
description: pnpm version to install
|
||||
required: false
|
||||
default: '10.33.2'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: ${{ inputs.pnpm-version }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
105
.github/scripts/agent-pr-explore-local.sh
vendored
Executable file
105
.github/scripts/agent-pr-explore-local.sh
vendored
Executable file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
.github/scripts/agent-pr-explore-local.sh <pr-number>
|
||||
|
||||
Runs the Docker-isolated PR exploration path from a local or self-hosted
|
||||
machine, without relying on a GitHub Actions workflow being present on main.
|
||||
|
||||
Required on the host:
|
||||
docker, gh, jq, node/npm, expect-cli@0.1.3
|
||||
|
||||
Optional environment:
|
||||
BASE_REPO=nexu-io/open-design
|
||||
RUNNER_TEMP=/tmp/od-agent-pr-explore-local
|
||||
OD_EXPECT_TIMEOUT_SECONDS=1200
|
||||
OD_SANDBOX_CPUS=4
|
||||
OD_SANDBOX_MEMORY=8g
|
||||
OD_ALLOW_NPX_EXPECT_CLI=1
|
||||
OD_TRACE_R2_UPLOAD=1
|
||||
R2_ACCOUNT_ID=...
|
||||
R2_ACCESS_KEY_ID=...
|
||||
R2_SECRET_ACCESS_KEY=...
|
||||
R2_BUCKET=...
|
||||
R2_PUBLIC_ORIGIN=https://...
|
||||
USAGE
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pr_number="${1:-${PR_NUMBER:-}}"
|
||||
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
base_repo="${BASE_REPO:-nexu-io/open-design}"
|
||||
runner_temp="${RUNNER_TEMP:-/tmp/od-agent-pr-explore-local}"
|
||||
|
||||
for command_name in docker gh jq node; do
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo "::error::$command_name is required on the mini/local runner" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
if ! GH_TOKEN="$(gh auth token 2>/dev/null)"; then
|
||||
echo "::error::GH_TOKEN is not set and gh auth token failed. Run gh auth login or export GH_TOKEN." >&2
|
||||
exit 1
|
||||
fi
|
||||
export GH_TOKEN
|
||||
fi
|
||||
|
||||
if ! command -v expect-cli >/dev/null 2>&1 && [ "${OD_ALLOW_NPX_EXPECT_CLI:-0}" != "1" ]; then
|
||||
echo "::error::expect-cli is not installed. Install it on the mini with: npm install -g expect-cli@${OD_EXPECT_CLI_VERSION:-0.1.3}" >&2
|
||||
echo " For a one-off smoke run, set OD_ALLOW_NPX_EXPECT_CLI=1 to use the pinned npx fallback." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$runner_temp"
|
||||
|
||||
pr_json="$(gh pr view "$pr_number" --repo "$base_repo" --json state,isDraft,headRefOid,baseRefOid,headRepositoryOwner,headRepository)"
|
||||
state="$(jq -r '.state' <<<"$pr_json")"
|
||||
draft="$(jq -r '.isDraft' <<<"$pr_json")"
|
||||
head_sha="$(jq -r '.headRefOid' <<<"$pr_json")"
|
||||
base_sha="$(jq -r '.baseRefOid' <<<"$pr_json")"
|
||||
head_repo="$(jq -r '.headRepositoryOwner.login + "/" + .headRepository.name' <<<"$pr_json")"
|
||||
|
||||
if [ "$state" != "OPEN" ]; then
|
||||
echo "::error::Refusing to explore PR $pr_number because state is $state." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$draft" != "false" ]; then
|
||||
echo "::error::Refusing to explore draft PR $pr_number." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$head_sha" =~ ^[0-9a-f]{40}$ && "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Invalid PR SHA metadata for PR $pr_number." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running agent PR exploration locally"
|
||||
echo " PR: $base_repo#$pr_number"
|
||||
echo " Head: $head_repo@$head_sha"
|
||||
echo " Base SHA: $base_sha"
|
||||
echo " Temp root: $runner_temp"
|
||||
|
||||
PR_NUMBER="$pr_number" \
|
||||
HEAD_SHA="$head_sha" \
|
||||
HEAD_REPO="$head_repo" \
|
||||
BASE_REPO="$base_repo" \
|
||||
BASE_SHA="$base_sha" \
|
||||
RUNNER_TEMP="$runner_temp" \
|
||||
GH_TOKEN="$GH_TOKEN" \
|
||||
.github/scripts/agent-pr-explore-sandbox.sh
|
||||
|
||||
echo
|
||||
echo "Artifacts:"
|
||||
echo " $runner_temp/agent-pr-explore-sandbox/artifacts"
|
||||
1510
.github/scripts/agent-pr-explore-sandbox.sh
vendored
Executable file
1510
.github/scripts/agent-pr-explore-sandbox.sh
vendored
Executable file
File diff suppressed because it is too large
Load diff
209
.github/scripts/provision-agent-pr-explore-runner.sh
vendored
Executable file
209
.github/scripts/provision-agent-pr-explore-runner.sh
vendored
Executable file
|
|
@ -0,0 +1,209 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Provision / repair the self-hosted agent-pr-explore runner.
|
||||
#
|
||||
# The runner that powers `.github/workflows/agent-pr-explore-sandbox.yml` is a
|
||||
# self-hosted macOS host. Several pieces of its setup are layered on top of the
|
||||
# base toolchain and are easy to lose on a rebuild (most importantly the
|
||||
# codex-acp pin -- see below). This script makes that layer reproducible and
|
||||
# idempotent: run it on the runner, any time, to bring it back to a working
|
||||
# state. It never prints or embeds secrets.
|
||||
#
|
||||
# Run as the runner user (e.g. `mashu`) on the runner host:
|
||||
# bash provision-agent-pr-explore-runner.sh
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# MANUAL prerequisites this script does NOT do (one-time, need a human/secret):
|
||||
#
|
||||
# A. Base toolchain (user-local, no sudo). Install once into ~/agent-pr-explore-bin
|
||||
# + ~/.npm-global if missing: docker CLI, colima, lima, node, npm, gh,
|
||||
# expect-cli. Then start colima (give the VM real resources):
|
||||
# colima start --runtime docker --cpu 8 --memory 13 --disk 80 \
|
||||
# --vm-type=vz --mount-type=virtiofs --network-address=false
|
||||
# (Playwright Chromium for the host user is auto-installed by the sandbox
|
||||
# script's fallback on first run.)
|
||||
#
|
||||
# B. Codex ChatGPT login (interactive OAuth, cannot be scripted):
|
||||
# codex login # complete ChatGPT auth in a browser
|
||||
# On a headless box, log in on a workstation and copy ~/.codex/auth.json here.
|
||||
# This script verifies login status and warns if absent.
|
||||
#
|
||||
# C. Register the read-only deploy key (printed by this script) on the repo:
|
||||
# gh api repos/${BASE_REPO}/keys -X POST -f title='agent-pr-explore runner' \
|
||||
# -f key="$(cat ~/.ssh/od_agent_deploy.pub)" -F read_only=true
|
||||
# (Needs repo admin. Required so the host can SSH-fetch PR source — the one
|
||||
# git transport GFW does not reset.)
|
||||
#
|
||||
# D. Register + service-install the GitHub Actions runner (token-based):
|
||||
# ./config.sh --url https://github.com/${BASE_REPO} --token <RUNNER_TOKEN> \
|
||||
# --labels self-hosted,agent-pr-explore --name macmini-agent-pr-explore
|
||||
# then install it as a launchd service so it survives reboot.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -uo pipefail
|
||||
|
||||
# --- config (override via env) -----------------------------------------------
|
||||
BASE_REPO="${BASE_REPO:-nexu-io/open-design}"
|
||||
CODEX_MODEL="${CODEX_MODEL:-gpt-5.4}"
|
||||
ACP_VERSION="${ACP_VERSION:-0.15.0}"
|
||||
ACP_ARCH_PKG="${ACP_ARCH_PKG:-@zed-industries/codex-acp-darwin-arm64}" # match the runner arch
|
||||
NPM_MIRROR="${NPM_MIRROR:-https://registry.npmmirror.com}"
|
||||
DEPLOY_KEY="${DEPLOY_KEY:-$HOME/.ssh/od_agent_deploy}"
|
||||
MIRROR_DIR="${OD_SANDBOX_REPO_MIRROR:-$HOME/.cache/agent-pr-explore/open-design.git}"
|
||||
TOOLS_DIR="$HOME/agent-pr-explore-tools"
|
||||
export PATH="$TOOLS_DIR/lima-2.1.1/bin:$HOME/agent-pr-explore-bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
|
||||
ok() { printf ' \033[32m✔\033[0m %s\n' "$*"; }
|
||||
warn() { printf ' \033[33m⚠\033[0m %s\n' "$*"; }
|
||||
step() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
|
||||
|
||||
# --- 0. sanity: base tools present -------------------------------------------
|
||||
step "0. base toolchain"
|
||||
missing=0
|
||||
for c in node npm docker expect-cli; do
|
||||
if command -v "$c" >/dev/null 2>&1; then ok "$c: $(command -v "$c")"; else warn "$c MISSING — see manual step A"; missing=1; fi
|
||||
done
|
||||
[ "$missing" = 1 ] && warn "install the missing base tools first (manual step A), then re-run."
|
||||
|
||||
# --- 1. codex CLI ------------------------------------------------------------
|
||||
step "1. codex CLI"
|
||||
if command -v codex >/dev/null 2>&1; then
|
||||
ok "codex present: $(codex --version 2>&1 | head -1)"
|
||||
else
|
||||
warn "installing @openai/codex via mirror…"
|
||||
npm_config_registry="$NPM_MIRROR" npm install -g @openai/codex >/dev/null 2>&1 \
|
||||
&& ok "codex installed: $(codex --version 2>&1 | head -1)" || warn "codex install FAILED"
|
||||
fi
|
||||
|
||||
# --- 2. codex model pin (ChatGPT account rejects -codex / gpt-5 models) -------
|
||||
step "2. codex model pin -> $CODEX_MODEL"
|
||||
mkdir -p "$HOME/.codex"
|
||||
cfg="$HOME/.codex/config.toml"
|
||||
touch "$cfg"
|
||||
if grep -q "^model *= *\"$CODEX_MODEL\"" "$cfg"; then
|
||||
ok "config.toml already pins model = \"$CODEX_MODEL\""
|
||||
elif grep -q '^model *=' "$cfg"; then
|
||||
# Replace just the model line in place; leave any other settings intact.
|
||||
tmp="$(mktemp)" && sed "s|^model *=.*|model = \"$CODEX_MODEL\"|" "$cfg" > "$tmp" && mv "$tmp" "$cfg"
|
||||
ok "updated model -> \"$CODEX_MODEL\" (other config.toml settings preserved)"
|
||||
else
|
||||
printf 'model = "%s"\n' "$CODEX_MODEL" >> "$cfg"
|
||||
ok "appended model = \"$CODEX_MODEL\" to config.toml"
|
||||
fi
|
||||
|
||||
# --- 3. codex login (verify only; interactive — manual step B) ---------------
|
||||
step "3. codex login (ChatGPT OAuth)"
|
||||
if codex login status 2>&1 | grep -qi 'logged in'; then
|
||||
ok "$(codex login status 2>&1 | head -1)"
|
||||
else
|
||||
warn "codex NOT logged in — run 'codex login' (manual step B) or copy ~/.codex/auth.json here."
|
||||
fi
|
||||
|
||||
# --- 4. codex-acp pin (CRITICAL: expect-cli bundles 0.10 which is incompatible
|
||||
# with ChatGPT-account auth; reinstalling expect-cli reverts this). -----
|
||||
step "4. codex-acp pin -> $ACP_VERSION (the fragile one)"
|
||||
zed="$(npm root -g 2>/dev/null)/expect-cli/node_modules/@zed-industries"
|
||||
cur="$(cat "$zed/codex-acp/package.json" 2>/dev/null | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -1)"
|
||||
if [ "$cur" = "$ACP_VERSION" ]; then
|
||||
ok "codex-acp already $ACP_VERSION"
|
||||
elif [ -d "$zed" ]; then
|
||||
warn "codex-acp is '$cur' — pinning to $ACP_VERSION"
|
||||
tmp="$(mktemp -d)"; ( cd "$tmp" && npm_config_registry="$NPM_MIRROR" npm pack \
|
||||
"@zed-industries/codex-acp@$ACP_VERSION" "$ACP_ARCH_PKG@$ACP_VERSION" >/dev/null 2>&1 )
|
||||
for pair in "codex-acp:@zed-industries/codex-acp" "$(basename "$ACP_ARCH_PKG"):$ACP_ARCH_PKG"; do
|
||||
dir="${pair%%:*}"; tgz="$(ls "$tmp"/*"${dir}"-"$ACP_VERSION".tgz 2>/dev/null | head -1)"
|
||||
[ -z "$tgz" ] && { warn "tarball for $dir not fetched"; continue; }
|
||||
mkdir -p "$tmp/x_$dir"; tar -xzf "$tgz" -C "$tmp/x_$dir"
|
||||
rm -rf "$zed/$dir"/* && cp -a "$tmp/x_$dir/package/." "$zed/$dir/"
|
||||
done
|
||||
chmod +x "$zed/$(basename "$ACP_ARCH_PKG")/bin/"* 2>/dev/null || true
|
||||
rm -rf "$tmp"
|
||||
now="$(cat "$zed/codex-acp/package.json" 2>/dev/null | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -1)"
|
||||
[ "$now" = "$ACP_VERSION" ] && ok "codex-acp now $ACP_VERSION" || warn "codex-acp pin FAILED (still $now)"
|
||||
else
|
||||
warn "expect-cli not found at $zed — install expect-cli first (manual step A)."
|
||||
fi
|
||||
|
||||
# --- 5. deploy key (generate if missing; registration is manual step C) ------
|
||||
step "5. SSH deploy key"
|
||||
if [ -f "$DEPLOY_KEY" ]; then
|
||||
ok "deploy key present: $DEPLOY_KEY"
|
||||
else
|
||||
# On a fresh-rebuild host ~/.ssh often does not exist yet; create it first so
|
||||
# ssh-keygen doesn't fail with "No such file or directory".
|
||||
mkdir -p "$(dirname "$DEPLOY_KEY")" && chmod 700 "$(dirname "$DEPLOY_KEY")" 2>/dev/null || true
|
||||
if ssh-keygen -t ed25519 -N "" -C "agent-pr-explore-deploy@$(hostname)" -f "$DEPLOY_KEY" >/dev/null; then
|
||||
ok "generated $DEPLOY_KEY"
|
||||
else
|
||||
warn "ssh-keygen failed — deploy key NOT created; mirror bootstrap will not work until fixed."
|
||||
fi
|
||||
fi
|
||||
if [ -f "$DEPLOY_KEY.pub" ]; then
|
||||
warn "ensure this pubkey is a READ-ONLY deploy key on $BASE_REPO (manual step C):"
|
||||
echo " $(cat "$DEPLOY_KEY.pub")"
|
||||
fi
|
||||
|
||||
# --- 6. base repo git mirror (so per-PR fetches are small deltas) ------------
|
||||
step "6. git mirror"
|
||||
export GIT_SSH_COMMAND="ssh -i $DEPLOY_KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20"
|
||||
if [ -d "$MIRROR_DIR" ] && git --git-dir="$MIRROR_DIR" rev-parse HEAD >/dev/null 2>&1; then
|
||||
ok "mirror present ($(du -sh "$MIRROR_DIR" 2>/dev/null | cut -f1)); refreshing main…"
|
||||
git --git-dir="$MIRROR_DIR" fetch --no-tags --depth=1 origin main >/dev/null 2>&1 && ok "main refreshed" || warn "mirror refresh failed (network?)"
|
||||
else
|
||||
mkdir -p "$(dirname "$MIRROR_DIR")"
|
||||
warn "seeding mirror (one-time, ~150MB over SSH)…"
|
||||
git clone --bare --depth=1 --single-branch --branch main "git@github.com:${BASE_REPO}.git" "$MIRROR_DIR" >/dev/null 2>&1 \
|
||||
&& ok "mirror seeded" || warn "mirror clone FAILED (deploy key registered? network?)"
|
||||
fi
|
||||
mkdir -p "$HOME/.cache/agent-pr-explore/pnpm-store" "$HOME/.cache/agent-pr-explore/reports"
|
||||
ok "pnpm-store + reports dirs ready"
|
||||
|
||||
# --- 7. base image refresh helper + weekly cron ------------------------------
|
||||
step "7. sandbox image refresh helper + cron"
|
||||
mkdir -p "$TOOLS_DIR"
|
||||
cat > "$TOOLS_DIR/refresh-sandbox-image.sh" <<'RSH'
|
||||
#!/usr/bin/env bash
|
||||
# Best-effort refresh of the sandbox base image. The sandbox script skips
|
||||
# `docker pull` when the image is cached (the runner's docker.io access is
|
||||
# flaky), so this is the decoupled refresh path; it never fails the host.
|
||||
set -uo pipefail
|
||||
export PATH="$HOME/agent-pr-explore-tools/lima-2.1.1/bin:$HOME/agent-pr-explore-bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
image="${OD_SANDBOX_IMAGE:-node:24-bookworm}"
|
||||
ts() { date "+%Y-%m-%dT%H:%M:%S%z"; }
|
||||
echo "[$(ts)] refresh start: $image"
|
||||
colima status >/dev/null 2>&1 || { echo "[$(ts)] colima down; skip"; exit 0; }
|
||||
before="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null || echo none)"
|
||||
if docker pull "$image"; then
|
||||
after="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null || echo none)"
|
||||
[ "$before" != "$after" ] && { echo "[$(ts)] refreshed $before -> $after"; docker image prune -f >/dev/null 2>&1 || true; } || echo "[$(ts)] up to date"
|
||||
else
|
||||
echo "[$(ts)] pull failed (registry unreachable?); keeping cached $before"
|
||||
fi
|
||||
echo "[$(ts)] done"
|
||||
RSH
|
||||
chmod +x "$TOOLS_DIR/refresh-sandbox-image.sh"
|
||||
ok "wrote $TOOLS_DIR/refresh-sandbox-image.sh"
|
||||
cron_line="17 4 * * 0 $TOOLS_DIR/refresh-sandbox-image.sh >> $TOOLS_DIR/image-refresh.log 2>&1"
|
||||
if crontab -l 2>/dev/null | grep -qF "refresh-sandbox-image.sh"; then
|
||||
ok "weekly refresh cron already installed"
|
||||
else
|
||||
{ crontab -l 2>/dev/null; echo "# agent-pr-explore weekly base-image refresh"; echo "$cron_line"; } | crontab - && ok "installed weekly refresh cron"
|
||||
fi
|
||||
|
||||
# --- 8. readiness self-check helper ------------------------------------------
|
||||
step "8. readiness self-check helper"
|
||||
cat > "$HOME/check-agent-ready.sh" <<'CHK'
|
||||
#!/usr/bin/env bash
|
||||
# Quick readiness check: VPN reaches chatgpt backend + Codex responds.
|
||||
export PATH="$HOME/agent-pr-explore-tools/lima-2.1.1/bin:$HOME/agent-pr-explore-bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
ok=1
|
||||
echo "1. chatgpt backend: $(curl -sS -m 15 -o /dev/null -w '%{http_code}' https://chatgpt.com/backend-api/ 2>/dev/null || echo FAIL) (403/200 = reachable)"
|
||||
echo "2. codex model: $(grep '^model' "$HOME/.codex/config.toml" 2>/dev/null)"
|
||||
echo "3. codex-acp: $(cat "$(npm root -g)/expect-cli/node_modules/@zed-industries/codex-acp/package.json" 2>/dev/null | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -1)"
|
||||
out="$(perl -e 'alarm shift; exec @ARGV' 90 codex exec --skip-git-repo-check 'reply with exactly READY_OK' 2>&1)"
|
||||
if printf '%s' "$out" | grep -q READY_OK; then echo "4. codex: ✅ responds"; else echo "4. codex: ❌ no response"; ok=0; fi
|
||||
[ "$ok" = 1 ] && echo "==> READY ✅" || echo "==> NOT READY ❌"
|
||||
CHK
|
||||
chmod +x "$HOME/check-agent-ready.sh"
|
||||
ok "wrote ~/check-agent-ready.sh"
|
||||
|
||||
step "done — run ~/check-agent-ready.sh after VPN/login to confirm"
|
||||
29
.github/scripts/release/assets/win.ps1
vendored
29
.github/scripts/release/assets/win.ps1
vendored
|
|
@ -8,21 +8,37 @@ foreach ($name in @("CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN", "RELEASE_CHANNEL", "
|
|||
|
||||
$assetSuffix = if ($null -eq $env:WINDOWS_ASSET_SUFFIX) { "" } else { $env:WINDOWS_ASSET_SUFFIX }
|
||||
$versionPathSuffix = if ($null -eq $env:ASSET_VERSION_SUFFIX) { "" } else { $env:ASSET_VERSION_SUFFIX }
|
||||
$includeZip = if ([string]::IsNullOrWhiteSpace($env:WINDOWS_INCLUDE_ZIP)) { $true } else { $env:WINDOWS_INCLUDE_ZIP -ne "false" }
|
||||
$releaseDir = Join-Path $env:RUNNER_TEMP "release-assets"
|
||||
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
|
||||
|
||||
$sourceInstaller = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/${env:TOOLS_PACK_NAMESPACE}/builder/Open Design-${env:TOOLS_PACK_NAMESPACE}-setup.exe"
|
||||
$builderDir = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/${env:TOOLS_PACK_NAMESPACE}/builder"
|
||||
$sourceInstaller = Join-Path $builderDir "Open Design-${env:TOOLS_PACK_NAMESPACE}-setup.exe"
|
||||
$sourceZip = Join-Path $builderDir "Open Design-${env:TOOLS_PACK_NAMESPACE}-portable.zip"
|
||||
if (!(Test-Path $sourceInstaller)) {
|
||||
throw "expected installer not found at $sourceInstaller"
|
||||
}
|
||||
if ($includeZip -and !(Test-Path $sourceZip)) {
|
||||
throw "expected portable zip not found at $sourceZip (build with --to all or --to zip, or set WINDOWS_INCLUDE_ZIP=false to skip)"
|
||||
}
|
||||
|
||||
$versionedInstaller = "open-design-${env:RELEASE_VERSION}$assetSuffix-win-x64-setup.exe"
|
||||
$checksumFile = "$versionedInstaller.sha256"
|
||||
Copy-Item $sourceInstaller (Join-Path $releaseDir $versionedInstaller)
|
||||
$versionedZip = "open-design-${env:RELEASE_VERSION}$assetSuffix-win-x64-portable.zip"
|
||||
$installerChecksumFile = "$versionedInstaller.sha256"
|
||||
$zipChecksumFile = "$versionedZip.sha256"
|
||||
|
||||
Copy-Item $sourceInstaller (Join-Path $releaseDir $versionedInstaller)
|
||||
$installerPath = Join-Path $releaseDir $versionedInstaller
|
||||
$hash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash $versionedInstaller" | Set-Content -Path (Join-Path $releaseDir $checksumFile)
|
||||
$installerHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$installerHash $versionedInstaller" | Set-Content -Path (Join-Path $releaseDir $installerChecksumFile)
|
||||
|
||||
if ($includeZip) {
|
||||
Copy-Item $sourceZip (Join-Path $releaseDir $versionedZip)
|
||||
$zipPath = Join-Path $releaseDir $versionedZip
|
||||
$zipHash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$zipHash $versionedZip" | Set-Content -Path (Join-Path $releaseDir $zipChecksumFile)
|
||||
}
|
||||
|
||||
$installerBytes = [System.IO.File]::ReadAllBytes($installerPath)
|
||||
$installerSha512 = [System.Convert]::ToBase64String([System.Security.Cryptography.SHA512]::Create().ComputeHash($installerBytes))
|
||||
$installerSize = (Get-Item $installerPath).Length
|
||||
|
|
@ -39,6 +55,9 @@ $releaseNotes = if ([string]::IsNullOrWhiteSpace($env:RELEASE_NOTES)) {
|
|||
} else {
|
||||
$env:RELEASE_NOTES
|
||||
}
|
||||
# latest.yml is the electron-updater auto-update feed; it only references the
|
||||
# NSIS installer because the portable zip is a manual-download convenience and
|
||||
# is not consumed by the in-app updater.
|
||||
@(
|
||||
"version: `"${env:RELEASE_VERSION}`""
|
||||
'files:'
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ fi
|
|||
|
||||
artifacts_file="$(mktemp)"
|
||||
trap 'rm -f "$artifacts_file"' EXIT
|
||||
artifact_name_regex="${ARTIFACT_NAME_REGEX:-}"
|
||||
cleanup_description="${ARTIFACT_CLEANUP_DESCRIPTION:-intermediate Actions artifacts after publish}"
|
||||
|
||||
gh api --paginate \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
|
|
@ -32,6 +34,9 @@ while IFS=$'\t' read -r artifact_id artifact_name; do
|
|||
if [ -z "$artifact_id" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ -n "$artifact_name_regex" ] && ! [[ "$artifact_name" =~ $artifact_name_regex ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Deleting workflow artifact $artifact_name ($artifact_id)"
|
||||
gh api \
|
||||
|
|
@ -41,6 +46,11 @@ while IFS=$'\t' read -r artifact_id artifact_name; do
|
|||
deleted_count=$((deleted_count + 1))
|
||||
done < "$artifacts_file"
|
||||
|
||||
if [ "$deleted_count" -eq 0 ] && [ -n "$artifact_name_regex" ]; then
|
||||
echo "No workflow artifacts matched ARTIFACT_NAME_REGEX=$artifact_name_regex for run $GITHUB_RUN_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Deleted $deleted_count workflow artifacts from run $GITHUB_RUN_ID"
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
|
|
@ -48,6 +58,6 @@ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|||
echo ""
|
||||
echo "### Workflow artifacts"
|
||||
echo ""
|
||||
echo "Deleted $deleted_count intermediate Actions artifacts after publish."
|
||||
echo "Deleted $deleted_count $cleanup_description."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
|
|
|||
12
.github/scripts/release/r2/publish-platform.ts
vendored
12
.github/scripts/release/r2/publish-platform.ts
vendored
|
|
@ -175,10 +175,18 @@ if (platform === "mac") {
|
|||
} else if (platform === "win") {
|
||||
const suffix = optional("WIN_ASSET_SUFFIX", assetVersionSuffix);
|
||||
const installer = `open-design-${releaseVersion}${suffix}-win-x64-setup.exe`;
|
||||
const portableZip = `open-design-${releaseVersion}${suffix}-win-x64-portable.zip`;
|
||||
const includeZip = optional("WIN_INCLUDE_ZIP", "true") !== "false";
|
||||
const artifacts = { installer: fileEntry(installer, contentType(installer)) };
|
||||
const assetNames = [installer, `${installer}.sha256`, "latest.yml"];
|
||||
if (includeZip) {
|
||||
artifacts.portableZip = fileEntry(portableZip, contentType(portableZip));
|
||||
assetNames.push(portableZip, `${portableZip}.sha256`);
|
||||
}
|
||||
config = {
|
||||
arch: "x64",
|
||||
artifacts: { installer: fileEntry(installer, contentType(installer)) },
|
||||
assetNames: [installer, `${installer}.sha256`, "latest.yml"],
|
||||
artifacts,
|
||||
assetNames,
|
||||
feed: {
|
||||
latestUrl: publicUrl(latestPrefix, "latest.yml"),
|
||||
name: "latest.yml",
|
||||
|
|
|
|||
28
.github/scripts/release/r2/publish.sh
vendored
28
.github/scripts/release/r2/publish.sh
vendored
|
|
@ -109,8 +109,17 @@ mac_zip="open-design-$RELEASE_VERSION$asset_version_suffix-mac-arm64.zip"
|
|||
mac_intel_dmg="open-design-$RELEASE_VERSION$mac_intel_asset_suffix-mac-x64.dmg"
|
||||
mac_intel_zip="open-design-$RELEASE_VERSION$mac_intel_asset_suffix-mac-x64.zip"
|
||||
win_installer="open-design-$RELEASE_VERSION$win_asset_suffix-win-x64-setup.exe"
|
||||
win_portable_zip="open-design-$RELEASE_VERSION$win_asset_suffix-win-x64-portable.zip"
|
||||
linux_appimage="open-design-$RELEASE_VERSION$linux_asset_suffix-linux-x64.AppImage"
|
||||
metadata_path="$release_root/metadata.json"
|
||||
win_include_zip="${WIN_INCLUDE_ZIP:-true}"
|
||||
case "$win_include_zip" in
|
||||
true | false) ;;
|
||||
*)
|
||||
echo "unsupported WIN_INCLUDE_ZIP: $win_include_zip" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$ENABLE_MAC" = "true" ]; then
|
||||
upload "$release_root/mac/$mac_dmg" "$version_prefix/$mac_dmg" "application/x-apple-diskimage" "public, max-age=31536000, immutable"
|
||||
|
|
@ -139,6 +148,13 @@ if [ "$ENABLE_WIN" = "true" ]; then
|
|||
echo "win_installer_url=$public_origin/$version_prefix/$win_installer"
|
||||
echo "win_feed_url=$public_origin/$latest_prefix/latest.yml"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if [ "$win_include_zip" = "true" ]; then
|
||||
upload "$release_root/win/$win_portable_zip" "$version_prefix/$win_portable_zip" "application/zip" "public, max-age=31536000, immutable"
|
||||
upload "$release_root/win/$win_portable_zip.sha256" "$version_prefix/$win_portable_zip.sha256" "text/plain; charset=utf-8" "public, max-age=31536000, immutable"
|
||||
{
|
||||
echo "win_portable_zip_url=$public_origin/$version_prefix/$win_portable_zip"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_MAC_INTEL" = "true" ]; then
|
||||
|
|
@ -177,6 +193,8 @@ MAC_ZIP="$mac_zip" \
|
|||
MAC_INTEL_DMG="$mac_intel_dmg" \
|
||||
MAC_INTEL_ZIP="$mac_intel_zip" \
|
||||
WIN_INSTALLER="$win_installer" \
|
||||
WIN_PORTABLE_ZIP="$win_portable_zip" \
|
||||
WIN_INCLUDE_ZIP="$win_include_zip" \
|
||||
LINUX_APPIMAGE="$linux_appimage" \
|
||||
MAC_ARTIFACT_MODE="$mac_artifact_mode" \
|
||||
METADATA_PATH="$metadata_path" \
|
||||
|
|
@ -236,6 +254,12 @@ if (enabled("ENABLE_MAC")) {
|
|||
};
|
||||
}
|
||||
if (enabled("ENABLE_WIN")) {
|
||||
const winArtifacts = {
|
||||
installer: fileEntry("win", env.WIN_INSTALLER, "application/vnd.microsoft.portable-executable"),
|
||||
};
|
||||
if (env.WIN_INCLUDE_ZIP !== "false") {
|
||||
winArtifacts.portableZip = fileEntry("win", env.WIN_PORTABLE_ZIP, "application/zip");
|
||||
}
|
||||
platforms.win = {
|
||||
arch: "x64",
|
||||
enabled: true,
|
||||
|
|
@ -245,9 +269,7 @@ if (enabled("ENABLE_WIN")) {
|
|||
url: url(versionPrefix, "latest.yml"),
|
||||
},
|
||||
signed: false,
|
||||
artifacts: {
|
||||
installer: fileEntry("win", env.WIN_INSTALLER, "application/vnd.microsoft.portable-executable"),
|
||||
},
|
||||
artifacts: winArtifacts,
|
||||
};
|
||||
}
|
||||
if (enabled("ENABLE_LINUX")) {
|
||||
|
|
|
|||
6
.github/scripts/release/r2/summary.sh
vendored
6
.github/scripts/release/r2/summary.sh
vendored
|
|
@ -65,6 +65,7 @@ if (platforms.mac.enabled) {
|
|||
if (platforms.win.enabled) {
|
||||
platforms.win.artifacts = {
|
||||
installer: optional("R2_WIN_INSTALLER_URL"),
|
||||
portableZip: optional("R2_WIN_PORTABLE_ZIP_URL"),
|
||||
};
|
||||
platforms.win.feed = optional("R2_WIN_FEED_URL");
|
||||
platforms.win.e2e = platformReport("win");
|
||||
|
|
@ -142,7 +143,10 @@ const platformRows = [
|
|||
[
|
||||
"Windows x64",
|
||||
platformStatus(platforms.win, "Published"),
|
||||
linkList([{ label: "Installer", url: platforms.win.artifacts?.installer }]),
|
||||
linkList([
|
||||
{ label: "Installer", url: platforms.win.artifacts?.installer },
|
||||
{ label: "Portable ZIP", url: platforms.win.artifacts?.portableZip },
|
||||
]),
|
||||
link("latest.yml", platforms.win.feed),
|
||||
],
|
||||
[
|
||||
|
|
|
|||
3
.github/scripts/release/r2/verify.sh
vendored
3
.github/scripts/release/r2/verify.sh
vendored
|
|
@ -134,6 +134,9 @@ if [ "$ENABLE_WIN" = "true" ]; then
|
|||
grep -F "version: \"$RELEASE_VERSION\"" "$downloaded_feed"
|
||||
grep -F "$R2_WIN_INSTALLER_URL" "$downloaded_feed"
|
||||
curl -fsSI "$R2_WIN_INSTALLER_URL" >/dev/null
|
||||
if [ -n "${R2_WIN_PORTABLE_ZIP_URL:-}" ]; then
|
||||
curl -fsSI "$R2_WIN_PORTABLE_ZIP_URL" >/dev/null
|
||||
fi
|
||||
require_report_file "win/manifest.json"
|
||||
require_report_file "win/screenshots/open-design-win-smoke.png"
|
||||
require_report_file "win/suite-result.json"
|
||||
|
|
|
|||
28
.github/workflows/actionlint.yml
vendored
Normal file
28
.github/workflows/actionlint.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: actionlint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/**
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/**
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: Lint GitHub Actions workflows
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Check workflow files
|
||||
uses: docker://rhysd/actionlint:1.7.12
|
||||
with:
|
||||
args: -color
|
||||
330
.github/workflows/agent-pr-explore-sandbox.yml
vendored
Normal file
330
.github/workflows/agent-pr-explore-sandbox.yml
vendored
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
name: agent-pr-explore-sandbox
|
||||
|
||||
# Trusted-orchestrator workflow for PR exploration on the self-hosted runner.
|
||||
# Triggered by manual workflow_dispatch, or by a maintainer commenting
|
||||
# "/explore" on a PR (write-access gated). It deliberately does not auto-run on
|
||||
# every PR, so ordinary PRs do not accumulate waiting checks; PR code only ever
|
||||
# runs inside the Docker sandbox without secrets.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: Pull request number to explore.
|
||||
required: true
|
||||
type: string
|
||||
skip_comment:
|
||||
description: Skip posting the exploration report comment on the PR (the report is still uploaded as an artifact). Use for validation/dry runs.
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
# A maintainer commenting "/explore" on a PR explores that PR. issue_comment
|
||||
# runs in the base (trusted) context with secrets, so the job `if` gate below
|
||||
# restricts it to users with write access; untrusted PR code still runs only
|
||||
# inside the Docker sandbox without secrets.
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: agent-pr-explore-sandbox-${{ github.event.issue.number || inputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sandbox:
|
||||
name: Sandbox PR runtime
|
||||
runs-on: [self-hosted, agent-pr-explore]
|
||||
# No environment approval gate: both triggers are already write-access
|
||||
# gated (workflow_dispatch needs repo write; the "/explore" comment path is
|
||||
# gated on author_association below), and PR code runs only in the sandbox.
|
||||
# R2 credentials are repo-level secrets, so they remain available here.
|
||||
timeout-minutes: 45
|
||||
# Manual dispatch is always allowed. A "/explore" PR comment is allowed only
|
||||
# from a user with write access (OWNER/MEMBER/COLLABORATOR), never on issues.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
startsWith(github.event.comment.body, '/explore') &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
|
||||
|
||||
steps:
|
||||
- name: Acknowledge /explore command
|
||||
if: ${{ github.event_name == 'issue_comment' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# 👀 on the command comment = picked up.
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" -f content=eyes --silent || true
|
||||
# Post a placeholder carrying the report marker; the report step later
|
||||
# PATCHes this same comment, so the run produces one evolving comment.
|
||||
marker="<!-- agent-pr-explore-sandbox:${PR_NUMBER} -->"
|
||||
body="🔍 Exploring this PR in an isolated sandbox — [follow the run]($RUN_URL). The report will replace this comment when it is ready.
|
||||
|
||||
$marker"
|
||||
existing="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$marker\"))) | .id" | tail -n 1)"
|
||||
if [ -n "$existing" ]; then
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -X PATCH -f body="$body" --silent || true
|
||||
else
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" --silent || true
|
||||
fi
|
||||
|
||||
- name: Fetch trusted base script
|
||||
# Only the self-contained sandbox script is needed on the trusted host;
|
||||
# PR code is checked out inside Docker. A full actions/checkout of this
|
||||
# large repo stalled on the self-hosted runner before the agent ever
|
||||
# ran, so fetch just the one trusted file via the API instead. The ref
|
||||
# is pinned to the trusted base/dispatch commit, never PR head.
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TRUSTED_REF: ${{ github.event.pull_request.base.sha || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .github/scripts
|
||||
gh api \
|
||||
-H 'Accept: application/vnd.github.raw' \
|
||||
"repos/$GITHUB_REPOSITORY/contents/.github/scripts/agent-pr-explore-sandbox.sh?ref=$TRUSTED_REF" \
|
||||
> .github/scripts/agent-pr-explore-sandbox.sh
|
||||
chmod +x .github/scripts/agent-pr-explore-sandbox.sh
|
||||
echo "Fetched trusted agent-pr-explore-sandbox.sh at $TRUSTED_REF"
|
||||
|
||||
- name: Resolve PR metadata
|
||||
id: pr
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_PR_NUMBER: ${{ inputs.pr_number || github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! [[ "$EVENT_PR_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Invalid PR number: $EVENT_PR_NUMBER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
state="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state --jq '.state')"
|
||||
draft="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')"
|
||||
author="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login')"
|
||||
head_sha="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid --jq '.headRefOid')"
|
||||
head_repo="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRepositoryOwner,headRepository --jq '.headRepositoryOwner.login + "/" + .headRepository.name')"
|
||||
base_repo="$GITHUB_REPOSITORY"
|
||||
base_sha="$(gh pr view "$EVENT_PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json baseRefOid --jq '.baseRefOid')"
|
||||
|
||||
if [ "$state" != "OPEN" ]; then
|
||||
echo "::error::Refusing to explore PR $EVENT_PR_NUMBER because state is $state."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$draft" != "false" ]; then
|
||||
echo "::error::Refusing to explore draft PR $EVENT_PR_NUMBER."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$base_repo" != "$GITHUB_REPOSITORY" ]; then
|
||||
echo "::error::Unexpected base repo $base_repo."
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$head_sha" =~ ^[0-9a-f]{40}$ && "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Invalid PR SHA metadata."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "number=$EVENT_PR_NUMBER"
|
||||
echo "author=$author"
|
||||
echo "head_sha=$head_sha"
|
||||
echo "head_repo=$head_repo"
|
||||
echo "base_sha=$base_sha"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run expect against Docker-isolated PR app
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
||||
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
BASE_SHA: ${{ steps.pr.outputs.base_sha }}
|
||||
OD_SANDBOX_CPUS: "4"
|
||||
OD_SANDBOX_MEMORY: "8g"
|
||||
OD_SANDBOX_READY_TIMEOUT_SECONDS: "900"
|
||||
OD_EXPECT_TIMEOUT_SECONDS: "1200"
|
||||
OD_EXPECT_CONTEXT_MAX_BYTES: "120000"
|
||||
OD_TRACE_R2_UPLOAD: "1"
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID || secrets.CLOUDFLARE_R2_RELEASES_AK }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY || secrets.CLOUDFLARE_R2_RELEASES_SK }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET || secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
|
||||
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN || vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN || secrets.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT || secrets.CLOUDFLARE_R2_RELEASES_URL }}
|
||||
run: .github/scripts/agent-pr-explore-sandbox.sh
|
||||
|
||||
- name: Upload sandbox artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: agent-pr-explore-sandbox-${{ steps.pr.outputs.number }}-${{ steps.pr.outputs.head_sha }}
|
||||
# The trace zips (~28MB) and videos are already published to R2; keep
|
||||
# them out of the GitHub artifact so it stays small (report + logs).
|
||||
path: |
|
||||
${{ runner.temp }}/agent-pr-explore-sandbox/artifacts/
|
||||
!${{ runner.temp }}/agent-pr-explore-sandbox/artifacts/**/*.zip
|
||||
!${{ runner.temp }}/agent-pr-explore-sandbox/artifacts/**/*.webm
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment exploration report
|
||||
if: ${{ always() && !inputs.skip_comment }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
||||
PR_AUTHOR: ${{ steps.pr.outputs.author }}
|
||||
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
report="$RUNNER_TEMP/agent-pr-explore-sandbox/artifacts/agent-pr-exploration-report.md"
|
||||
if [ ! -s "$report" ]; then
|
||||
echo "No agent exploration report was produced; skipping PR comment."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
marker="<!-- agent-pr-explore-sandbox:${PR_NUMBER} -->"
|
||||
body_file="$(mktemp)"
|
||||
{
|
||||
cat "$report"
|
||||
echo
|
||||
echo "$marker"
|
||||
} > "$body_file"
|
||||
|
||||
comment_id="$(
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate \
|
||||
--jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$marker\"))) | .id" \
|
||||
| tail -n 1
|
||||
)"
|
||||
body="$(cat "$body_file")"
|
||||
if [ -n "$comment_id" ]; then
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" -X PATCH -f body="$body" --silent
|
||||
else
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" --silent
|
||||
fi
|
||||
|
||||
pr_author_lc="$(printf '%s' "$PR_AUTHOR" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$pr_author_lc" in
|
||||
nettee|mrcfps|alchemistklk|siri-ray)
|
||||
;;
|
||||
*)
|
||||
echo "PR author $PR_AUTHOR is not configured for inline agent reports; skipping inline review comment."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
files_json="$(mktemp)"
|
||||
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" > "$files_json"
|
||||
inline_target="$(
|
||||
FILES_JSON="$files_json" node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const pages = JSON.parse(fs.readFileSync(process.env.FILES_JSON, "utf8"));
|
||||
const files = pages.flat();
|
||||
|
||||
function firstAddedLine(file) {
|
||||
if (typeof file.patch !== "string") return null;
|
||||
let newLine = null;
|
||||
for (const line of file.patch.split("\n")) {
|
||||
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
||||
if (hunk) {
|
||||
newLine = Number(hunk[1]);
|
||||
continue;
|
||||
}
|
||||
if (newLine == null) continue;
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) return newLine;
|
||||
if (!line.startsWith("-")) newLine += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file || file.status === "removed") continue;
|
||||
const line = firstAddedLine(file);
|
||||
if (line != null) {
|
||||
process.stdout.write(JSON.stringify({ path: file.filename, line }));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
)"
|
||||
if [ -z "$inline_target" ]; then
|
||||
echo "No added diff line was available for an inline agent report; skipping inline review comment."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
inline_path="$(node -e 'const target = JSON.parse(process.argv[1]); process.stdout.write(target.path)' "$inline_target")"
|
||||
inline_line="$(node -e 'const target = JSON.parse(process.argv[1]); process.stdout.write(String(target.line))' "$inline_target")"
|
||||
inline_marker="<!-- agent-pr-explore-inline:${PR_NUMBER} -->"
|
||||
inline_body_file="$(mktemp)"
|
||||
{
|
||||
cat "$report"
|
||||
echo
|
||||
echo "$inline_marker"
|
||||
} > "$inline_body_file"
|
||||
inline_body="$(cat "$inline_body_file")"
|
||||
|
||||
inline_comment_id="$(
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \
|
||||
--jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$inline_marker\"))) | .id" \
|
||||
| tail -n 1
|
||||
)"
|
||||
if [ -n "$inline_comment_id" ]; then
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/comments/$inline_comment_id" -X PATCH -f body="$inline_body" --silent
|
||||
else
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" \
|
||||
-f body="$inline_body" \
|
||||
-f commit_id="$HEAD_SHA" \
|
||||
-f path="$inline_path" \
|
||||
-F line="$inline_line" \
|
||||
-f side=RIGHT \
|
||||
--silent
|
||||
fi
|
||||
|
||||
- name: React done on /explore command
|
||||
if: ${{ success() && github.event_name == 'issue_comment' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
run: gh api "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" -f content=rocket --silent || true
|
||||
|
||||
- name: Report /explore command failure
|
||||
if: ${{ failure() && github.event_name == 'issue_comment' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# If a report was produced before the non-zero exit, the report step
|
||||
# already posted it — leave it rather than clobbering useful output.
|
||||
if [ -s "$RUNNER_TEMP/agent-pr-explore-sandbox/artifacts/agent-pr-exploration-report.md" ]; then
|
||||
echo "A report exists despite the non-zero exit; leaving it in place."
|
||||
exit 0
|
||||
fi
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions" -f content="-1" --silent || true
|
||||
marker="<!-- agent-pr-explore-sandbox:${PR_NUMBER} -->"
|
||||
body="⚠️ Exploration run failed before producing a report — see [the run]($RUN_URL).
|
||||
|
||||
$marker"
|
||||
existing="$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.user.type == \"Bot\" and (.body | contains(\"$marker\"))) | .id" | tail -n 1)"
|
||||
if [ -n "$existing" ]; then
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -X PATCH -f body="$body" --silent || true
|
||||
else
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -f body="$body" --silent || true
|
||||
fi
|
||||
36
.github/workflows/agent-pr-explore.lock.yml
vendored
Normal file
36
.github/workflows/agent-pr-explore.lock.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Placeholder for PR Explore Agent.
|
||||
#
|
||||
# GitHub only reliably triggers pull_request workflows once a workflow
|
||||
# file with the same path exists on the default branch. The real
|
||||
# generated gh-aw workflow is introduced by PR #2604; this placeholder
|
||||
# is intentionally inert and can be replaced by the compiled workflow
|
||||
# when that PR merges.
|
||||
name: "PR Explore Agent - placeholder"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "apps/web/**"
|
||||
- "apps/landing-page/**"
|
||||
- "design-templates/open-design-landing/**"
|
||||
- "skills/**"
|
||||
- "design-systems/**"
|
||||
- "craft/**"
|
||||
- "templates/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- ".github/workflows/agent-pr-explore.lock.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
placeholder:
|
||||
name: Placeholder
|
||||
if: ${{ github.event_name == 'agent_pr_explore_placeholder_never' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "agent-pr-explore placeholder only"
|
||||
16
.github/workflows/blog-3day-report.yml
vendored
16
.github/workflows/blog-3day-report.yml
vendored
|
|
@ -87,9 +87,11 @@ jobs:
|
|||
if [ -n "$FEISHU_BLOG_DIGEST_WEBHOOK" ]; then
|
||||
feishu=true
|
||||
fi
|
||||
echo "gsc=$gsc" >> "$GITHUB_OUTPUT"
|
||||
echo "bot=$bot" >> "$GITHUB_OUTPUT"
|
||||
echo "feishu=$feishu" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "gsc=$gsc"
|
||||
echo "bot=$bot"
|
||||
echo "feishu=$feishu"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### Blog 3-day report configuration"
|
||||
echo "- GSC auth configured: \`$gsc\`"
|
||||
|
|
@ -122,16 +124,16 @@ jobs:
|
|||
GSC_SERVICE_ACCOUNT_KEY: ${{ secrets.GSC_SERVICE_ACCOUNT_KEY }}
|
||||
run: |
|
||||
mkdir -p .blog-indexing
|
||||
flags=""
|
||||
flags=()
|
||||
if [ -n "${{ github.event.inputs.today }}" ]; then
|
||||
flags="$flags --today ${{ github.event.inputs.today }}"
|
||||
flags+=(--today "${{ github.event.inputs.today }}")
|
||||
fi
|
||||
if [ "${{ github.event.inputs.skip_inspect }}" = "true" ]; then
|
||||
flags="$flags --no-inspect"
|
||||
flags+=(--no-inspect)
|
||||
fi
|
||||
pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/report-3day.ts \
|
||||
--summary-out ../../.blog-indexing/blog-traffic-digest-summary.json \
|
||||
$flags
|
||||
"${flags[@]}"
|
||||
# Surface the latest section in the run summary for quick review.
|
||||
{
|
||||
echo "### Latest digest section"
|
||||
|
|
|
|||
8
.github/workflows/blog-indexing-monitor.yml
vendored
8
.github/workflows/blog-indexing-monitor.yml
vendored
|
|
@ -251,7 +251,9 @@ jobs:
|
|||
run: |
|
||||
existing=$(gh issue list --repo nexu-io/open-design --state open --search 'in:title "Blog indexing — URLs stalled in Search Console"' --json number --jq '.[0].number // empty')
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo nexu-io/open-design --body 'All previously stalled URLs have reached `indexed` status. Closing automatically. — `blog-indexing-monitor`'
|
||||
body_file="$RUNNER_TEMP/blog-indexing-monitor-stall-close.md"
|
||||
printf '%s\n' "All previously stalled URLs have reached \`indexed\` status. Closing automatically. — \`blog-indexing-monitor\`" > "$body_file"
|
||||
gh issue comment "$existing" --repo nexu-io/open-design --body-file "$body_file"
|
||||
gh issue close "$existing" --repo nexu-io/open-design
|
||||
fi
|
||||
|
||||
|
|
@ -262,7 +264,9 @@ jobs:
|
|||
run: |
|
||||
existing=$(gh issue list --repo nexu-io/open-design --state open --search 'in:title "Blog traffic — indexed posts with zero impressions"' --json number --jq '.[0].number // empty')
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo nexu-io/open-design --body 'All previously low-traffic tracked URLs now have impressions or no longer match the escalation window. Closing automatically. — `blog-indexing-monitor`'
|
||||
body_file="$RUNNER_TEMP/blog-indexing-monitor-low-traffic-close.md"
|
||||
printf '%s\n' "All previously low-traffic tracked URLs now have impressions or no longer match the escalation window. Closing automatically. — \`blog-indexing-monitor\`" > "$body_file"
|
||||
gh issue comment "$existing" --repo nexu-io/open-design --body-file "$body_file"
|
||||
gh issue close "$existing" --repo nexu-io/open-design
|
||||
fi
|
||||
|
||||
|
|
|
|||
43
.github/workflows/blog-indexing-on-deploy.yml
vendored
43
.github/workflows/blog-indexing-on-deploy.yml
vendored
|
|
@ -1,6 +1,8 @@
|
|||
name: blog-indexing-on-deploy
|
||||
|
||||
# Runs after every successful `landing-page-deploy` on main. The job is
|
||||
# Runs after every successful `landing-page-production` promotion. Staging
|
||||
# deploys (`landing-page-staging`) intentionally do NOT trigger indexing, so
|
||||
# the test environment is never submitted to search engines. The job is
|
||||
# idempotent and follows the blog-indexing-automation skill:
|
||||
#
|
||||
# 1. Detect blog URLs added/modified in the deploy
|
||||
|
|
@ -16,7 +18,7 @@ name: blog-indexing-on-deploy
|
|||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['landing-page-deploy']
|
||||
workflows: ['landing-page-production']
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
|
@ -29,7 +31,10 @@ on:
|
|||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# `contents: write` lets the job advance the `blog-indexed-prod` tag after a
|
||||
# successful run so the next promotion diffs from exactly where this one
|
||||
# stopped. The status PR uses a separate GitHub App token (below).
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: blog-indexing-on-deploy
|
||||
|
|
@ -147,8 +152,21 @@ jobs:
|
|||
mkdir -p .blog-indexing
|
||||
BASE="${{ github.event.inputs.base_sha || '' }}"
|
||||
if [ -z "$BASE" ]; then
|
||||
BASE="$(git rev-parse HEAD^)"
|
||||
# Diff from the last production deploy we already indexed, tracked
|
||||
# by the `blog-indexed-prod` tag. Production is a manual promotion
|
||||
# that may bundle several merged posts, so a HEAD^ diff would miss
|
||||
# all but the last commit. The tag is advanced at the end of a
|
||||
# successful run (see "Advance indexed-production pointer").
|
||||
git fetch --no-tags origin "+refs/tags/blog-indexed-prod:refs/tags/blog-indexed-prod" 2>/dev/null || true
|
||||
BASE="$(git rev-parse --verify --quiet 'refs/tags/blog-indexed-prod^{commit}' || true)"
|
||||
if [ -n "$BASE" ]; then
|
||||
echo "Diffing from last indexed production commit (blog-indexed-prod): $BASE"
|
||||
else
|
||||
BASE="$(git rev-parse HEAD^)"
|
||||
echo "No blog-indexed-prod tag yet; bootstrapping from HEAD^: $BASE"
|
||||
fi
|
||||
fi
|
||||
echo "base=$BASE" >> "$GITHUB_OUTPUT"
|
||||
pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/detect-changed-urls.ts \
|
||||
--base "$BASE" \
|
||||
--head HEAD \
|
||||
|
|
@ -264,3 +282,20 @@ jobs:
|
|||
Generated by `.github/workflows/blog-indexing-on-deploy.yml`. The
|
||||
sidecar `docs/blog-indexing-status.json` is the canonical state;
|
||||
the markdown file is rendered from it.
|
||||
|
||||
# Advance the pointer LAST, only after every post-deploy step above
|
||||
# (detect, verify, submit, inspect, analytics, render, status PR)
|
||||
# succeeded. `success()` is false if any earlier step failed, so a
|
||||
# failure leaves the tag where it was and the next production run
|
||||
# re-processes the same range rather than silently skipping posts.
|
||||
# Skipped steps (count == 0, or GSC/bot not configured) do not count as
|
||||
# failures, so the pointer still advances over an empty/partial range.
|
||||
# Restricted to the real production-deploy trigger; an ad-hoc manual
|
||||
# dispatch must not move the production baseline.
|
||||
- name: Advance indexed-production pointer
|
||||
if: success() && github.event_name == 'workflow_run'
|
||||
run: |
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
git tag -f blog-indexed-prod "$HEAD_SHA"
|
||||
git push -f origin "refs/tags/blog-indexed-prod"
|
||||
echo "Advanced blog-indexed-prod -> $HEAD_SHA"
|
||||
|
|
|
|||
457
.github/workflows/ci.yml
vendored
457
.github/workflows/ci.yml
vendored
|
|
@ -2,6 +2,7 @@ name: ci
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
# Release validation is owned by the release workflows rather than this CI
|
||||
# workflow: `release-stable` has a verify job before publishing, and
|
||||
# `release-beta` builds from its selected release commit. Keep this trigger
|
||||
|
|
@ -73,19 +74,20 @@ jobs:
|
|||
if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
|
||||
tools_pack_tests_required=true
|
||||
fi
|
||||
if [[ "$file" == "package.json" || "$file" == "apps/"*/"package.json" || "$file" == "packages/"*/"package.json" || "$file" == "tools/"*/"package.json" || "$file" == "e2e/package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" ]]; then
|
||||
if [[ "$file" == "package.json" || "$file" == "apps/daemon/package.json" || "$file" == "apps/web/package.json" || "$file" == "apps/desktop/package.json" || "$file" == "apps/packaged/package.json" || "$file" == "packages/"*/"package.json" || "$file" == "tools/"*/"package.json" || "$file" == "e2e/package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" ]]; then
|
||||
daemon_tests_required=true
|
||||
web_tests_required=true
|
||||
tools_dev_tests_required=true
|
||||
tools_pack_tests_required=true
|
||||
fi
|
||||
if [[ "$file" == "package.json" || "$file" == "apps/"*/"package.json" || "$file" == "packages/"*/"package.json" || "$file" == "tools/"*/"package.json" || "$file" == "e2e/package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == "flake.nix" || "$file" == "flake.lock" || "$file" == "nix/"* || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/nix-check.yml" ]]; then
|
||||
# Keep this filter in sync with flake.nix daemonWorkspacePaths / webWorkspacePaths.
|
||||
if [[ "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == "flake.nix" || "$file" == "flake.lock" || "$file" == "nix/"* || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/nix-check.yml" || "$file" == ".github/workflows/nix-hash-autofix.yml" || "$file" == "apps/daemon/"* || "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/registry-protocol/"* || "$file" == "packages/agui-adapter/"* || "$file" == "packages/plugin-runtime/"* || "$file" == "packages/sidecar-proto/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/platform/"* || "$file" == "packages/diagnostics/"* || "$file" == "packages/host/"* || "$file" == "assets/"* || "$file" == "plugins/"* || "$file" == "skills/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* || "$file" == "prompt-templates/"* || "$file" == "scripts/update-nix-pnpm-deps-hash.ts" ]]; then
|
||||
nix_validation_required=true
|
||||
fi
|
||||
case "$file" in
|
||||
*.md|*.mdx|*.txt|LICENSE|.gitignore|.editorconfig|.vscode/*|.idea/*|docs/*|.github/ISSUE_TEMPLATE/*|.github/CODEOWNERS)
|
||||
;;
|
||||
apps/landing-page/*|flake.nix|flake.lock|nix/*|.github/workflows/nix-check.yml|.github/workflows/landing-page-ci.yml|.github/workflows/landing-page-deploy.yml|.github/workflows/blog-indexing-on-deploy.yml|.github/workflows/blog-indexing-monitor.yml)
|
||||
apps/landing-page/*|flake.nix|flake.lock|nix/*|.github/workflows/nix-check.yml|.github/workflows/landing-page-ci.yml|.github/workflows/landing-page-staging.yml|.github/workflows/landing-page-production.yml|.github/workflows/blog-indexing-on-deploy.yml|.github/workflows/blog-indexing-monitor.yml|.github/workflows/blog-3day-report.yml|.github/workflows/seo-daily-report.yml|.github/workflows/actionlint.yml|.github/workflows/visual-pr-capture.yml|.github/workflows/visual-pr-comment.yml)
|
||||
;;
|
||||
*)
|
||||
workspace_validation_required=true
|
||||
|
|
@ -106,6 +108,15 @@ jobs:
|
|||
|| [ "$tools_pack_tests_required" = "true" ]; then
|
||||
workspace_validation_required=true
|
||||
fi
|
||||
elif [ "${{ github.event_name }}" = "push" ]; then
|
||||
daemon_tests_required=true
|
||||
web_tests_required=true
|
||||
tools_dev_tests_required=true
|
||||
tools_pack_tests_required=true
|
||||
# Main already runs .github/workflows/nix-check.yml, so keep this
|
||||
# workflow's push path focused on the non-Nix workspace signal.
|
||||
nix_validation_required=false
|
||||
workspace_validation_required=true
|
||||
else
|
||||
daemon_tests_required=true
|
||||
web_tests_required=true
|
||||
|
|
@ -141,9 +152,97 @@ jobs:
|
|||
experimental-features = nix-command flakes
|
||||
accept-flake-config = true
|
||||
|
||||
- name: Setup Node for Nix hash refresh
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: nix flake check
|
||||
id: flake_check
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs --keep-going
|
||||
|
||||
- name: Generate Nix hash refresh patch
|
||||
id: hash_refresh
|
||||
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out_dir="$RUNNER_TEMP/nix-hash-refresh"
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
status="update-failed"
|
||||
if node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts >"$out_dir/update.log" 2>&1; then
|
||||
if git diff --quiet --exit-code -- nix/pnpm-deps.nix; then
|
||||
status="no-change"
|
||||
else
|
||||
git diff -- nix/pnpm-deps.nix >"$out_dir/nix-pnpm-deps.patch"
|
||||
cp nix/pnpm-deps.nix "$out_dir/pnpm-deps.nix"
|
||||
status="patch-generated"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '{"status":"%s","runId":%s,"prNumber":%s,"headSha":"%s"}\n' \
|
||||
"$status" \
|
||||
'${{ github.run_id }}' \
|
||||
'${{ github.event.pull_request.number }}' \
|
||||
'${{ github.event.pull_request.head.sha }}' >"$out_dir/metadata.json"
|
||||
|
||||
echo "status=$status" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload Nix hash refresh artifact
|
||||
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' && steps.hash_refresh.outputs.status != 'no-change' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nix-hash-refresh
|
||||
path: ${{ runner.temp }}/nix-hash-refresh
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Summarize Nix hash refresh guidance
|
||||
if: ${{ github.event_name == 'pull_request' && steps.flake_check.outcome == 'failure' }}
|
||||
env:
|
||||
HASH_REFRESH_STATUS: ${{ steps.hash_refresh.outputs.status }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${HASH_REFRESH_STATUS:-not-run}" in
|
||||
patch-generated)
|
||||
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
|
||||
## Generated Nix hash refresh
|
||||
|
||||
CI regenerated a patch for `nix/pnpm-deps.nix` and uploaded it as the
|
||||
`nix-hash-refresh` artifact for this run.
|
||||
|
||||
- same-repo PRs: the follow-up `nix-hash-autofix` workflow will try to
|
||||
push the hash-only patch back to the PR branch automatically;
|
||||
- fork PRs: a PR comment will include the patch and artifact guidance.
|
||||
EOF
|
||||
;;
|
||||
no-change)
|
||||
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
|
||||
## Nix hash refresh unavailable
|
||||
|
||||
`nix flake check` failed, but `nix/pnpm-deps.nix` did not change after
|
||||
running the hash refresh helper. Inspect the Nix build logs for a
|
||||
non-hash failure.
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
cat >> "$GITHUB_STEP_SUMMARY" <<'EOF'
|
||||
## Nix hash refresh failed
|
||||
|
||||
`nix flake check` failed and the helper could not generate a hash-only
|
||||
patch. See the `nix-hash-refresh` artifact for `update.log`.
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Fail when Nix validation fails
|
||||
if: ${{ steps.flake_check.outcome == 'failure' }}
|
||||
run: exit 1
|
||||
|
||||
preflight:
|
||||
name: Preflight
|
||||
needs: [change_scopes]
|
||||
|
|
@ -155,30 +254,8 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup workspace
|
||||
uses: ./.github/actions/setup-workspace
|
||||
|
||||
# `scripts/postinstall.mjs` only prebuilds package/tool entrypoints that
|
||||
# are needed immediately after install for linked bins and shared
|
||||
|
|
@ -212,8 +289,8 @@ jobs:
|
|||
- name: Check i18n structure
|
||||
run: pnpm i18n:check
|
||||
|
||||
core_tests:
|
||||
name: Core package tests
|
||||
workspace_unit_tests:
|
||||
name: Workspace unit tests
|
||||
needs: [change_scopes]
|
||||
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -223,77 +300,16 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
- name: Setup workspace
|
||||
uses: ./.github/actions/setup-workspace
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Core package tests
|
||||
- name: Workspace unit tests
|
||||
run: |
|
||||
pnpm --filter @open-design/contracts test
|
||||
pnpm --filter @open-design/host test
|
||||
pnpm --filter @open-design/platform test
|
||||
pnpm --filter @open-design/sidecar test
|
||||
pnpm --filter @open-design/sidecar-proto test
|
||||
|
||||
tools_workspace_tests:
|
||||
name: Tools workspace tests
|
||||
needs: [change_scopes]
|
||||
if: ${{ needs.change_scopes.outputs.tools_dev_tests_required == 'true' || needs.change_scopes.outputs.tools_pack_tests_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Tools workspace smoke tests
|
||||
run: |
|
||||
if [ "${{ needs.change_scopes.outputs.tools_dev_tests_required }}" = "true" ]; then
|
||||
pnpm --filter @open-design/tools-dev test
|
||||
fi
|
||||
|
|
@ -302,50 +318,24 @@ jobs:
|
|||
fi
|
||||
|
||||
daemon_workspace_tests:
|
||||
name: Daemon workspace tests (${{ matrix.shard }}/2)
|
||||
name: Daemon workspace tests
|
||||
needs: [change_scopes]
|
||||
if: ${{ needs.change_scopes.outputs.daemon_tests_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup workspace
|
||||
uses: ./.github/actions/setup-workspace
|
||||
|
||||
- name: Prebuild daemon entrypoint declarations
|
||||
run: pnpm --filter @open-design/daemon build
|
||||
|
||||
- name: Daemon workspace tests
|
||||
run: pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts --shard=${{ matrix.shard }}/2
|
||||
run: pnpm --filter @open-design/daemon test
|
||||
|
||||
web_workspace_tests:
|
||||
name: Web workspace tests
|
||||
|
|
@ -358,30 +348,8 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup workspace
|
||||
uses: ./.github/actions/setup-workspace
|
||||
|
||||
- name: Prebuild web sidecar declarations
|
||||
run: pnpm --filter @open-design/web build:sidecar
|
||||
|
|
@ -389,164 +357,26 @@ jobs:
|
|||
- name: Web workspace tests
|
||||
run: pnpm --filter @open-design/web test
|
||||
|
||||
app_tests:
|
||||
name: App workspace tests
|
||||
browser_tests:
|
||||
name: Browser tests
|
||||
needs:
|
||||
- change_scopes
|
||||
- tools_workspace_tests
|
||||
- daemon_workspace_tests
|
||||
- web_workspace_tests
|
||||
if: ${{ always() && needs.change_scopes.result == 'success' && (needs.change_scopes.outputs.tools_dev_tests_required == 'true' || needs.change_scopes.outputs.tools_pack_tests_required == 'true' || needs.change_scopes.outputs.daemon_tests_required == 'true' || needs.change_scopes.outputs.web_tests_required == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check app workspace test jobs
|
||||
env:
|
||||
NEEDS_JSON: ${{ toJSON(needs) }}
|
||||
TOOLS_REQUIRED: ${{ needs.change_scopes.outputs.tools_dev_tests_required == 'true' || needs.change_scopes.outputs.tools_pack_tests_required == 'true' }}
|
||||
DAEMON_REQUIRED: ${{ needs.change_scopes.outputs.daemon_tests_required }}
|
||||
WEB_REQUIRED: ${{ needs.change_scopes.outputs.web_tests_required }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$NEEDS_JSON" | jq .
|
||||
failures=()
|
||||
if [ "$TOOLS_REQUIRED" = "true" ] && [ "$(echo "$NEEDS_JSON" | jq -r '.tools_workspace_tests.result')" != "success" ]; then
|
||||
failures+=("tools_workspace_tests=$(echo "$NEEDS_JSON" | jq -r '.tools_workspace_tests.result')")
|
||||
fi
|
||||
if [ "$DAEMON_REQUIRED" = "true" ] && [ "$(echo "$NEEDS_JSON" | jq -r '.daemon_workspace_tests.result')" != "success" ]; then
|
||||
failures+=("daemon_workspace_tests=$(echo "$NEEDS_JSON" | jq -r '.daemon_workspace_tests.result')")
|
||||
fi
|
||||
if [ "$WEB_REQUIRED" = "true" ] && [ "$(echo "$NEEDS_JSON" | jq -r '.web_workspace_tests.result')" != "success" ]; then
|
||||
failures+=("web_workspace_tests=$(echo "$NEEDS_JSON" | jq -r '.web_workspace_tests.result')")
|
||||
fi
|
||||
if [ "${#failures[@]}" -gt 0 ]; then
|
||||
printf 'App workspace validation failed:\n'
|
||||
printf '%s\n' "${failures[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
e2e_vitest:
|
||||
name: E2E vitest
|
||||
needs: [change_scopes]
|
||||
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Some Vitest-based smoke tests drive the browser directly through
|
||||
# @playwright/test. Restore browser binaries without saving from CI runs;
|
||||
# the key follows the @playwright/test version so browser revisions
|
||||
# update with package bumps.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
version=$(node -p "require('./e2e/package.json').devDependencies['@playwright/test'].replace(/[^0-9.]/g,'')")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Playwright browser cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm -C e2e exec playwright install --with-deps chromium
|
||||
|
||||
- name: E2E vitest
|
||||
run: pnpm --filter @open-design/e2e test
|
||||
|
||||
ui_e2e_critical:
|
||||
name: Playwright critical (${{ matrix.group }})
|
||||
needs: [change_scopes]
|
||||
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- group: core
|
||||
grep_flag: --grep-invert
|
||||
grep_pattern: home starters|home hero
|
||||
- group: starters
|
||||
grep_flag: --grep
|
||||
grep_pattern: home starters|home hero
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
- name: Setup workspace
|
||||
uses: ./.github/actions/setup-workspace
|
||||
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Restore Playwright browser binaries without saving from CI runs. The
|
||||
# key follows the @playwright/test version so browser revisions update
|
||||
# with package bumps.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
version=$(node -p "require('./e2e/package.json').devDependencies['@playwright/test'].replace(/[^0-9.]/g,'')")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Playwright browser cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm -C e2e exec playwright install --with-deps chromium
|
||||
package-json-path: e2e/package.json
|
||||
install-command: pnpm -C e2e exec playwright install --with-deps chromium
|
||||
|
||||
- name: Prebuild workspace type declarations
|
||||
run: |
|
||||
|
|
@ -554,10 +384,13 @@ jobs:
|
|||
pnpm --filter @open-design/desktop build
|
||||
pnpm --filter @open-design/web build:sidecar
|
||||
|
||||
- name: E2E vitest
|
||||
run: pnpm --filter @open-design/e2e test
|
||||
|
||||
- name: Playwright critical
|
||||
run: |
|
||||
pnpm -C e2e exec tsx scripts/playwright.ts clean
|
||||
pnpm -C e2e exec playwright test -c playwright.config.ts ${{ matrix.grep_flag }} '${{ matrix.grep_pattern }}' ui/critical-smoke.test.ts ui/entry-chrome-flows.test.ts
|
||||
pnpm -C e2e exec playwright test -c playwright.config.ts ui/critical-smoke.test.ts ui/entry-chrome-flows.test.ts
|
||||
|
||||
build_workspaces:
|
||||
name: Build workspaces
|
||||
|
|
@ -570,30 +403,8 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Setup workspace
|
||||
uses: ./.github/actions/setup-workspace
|
||||
|
||||
- name: Prebuild workspace type declarations
|
||||
run: |
|
||||
|
|
@ -615,10 +426,10 @@ jobs:
|
|||
- change_scopes
|
||||
- preflight
|
||||
- nix_validation
|
||||
- core_tests
|
||||
- app_tests
|
||||
- e2e_vitest
|
||||
- ui_e2e_critical
|
||||
- workspace_unit_tests
|
||||
- daemon_workspace_tests
|
||||
- web_workspace_tests
|
||||
- browser_tests
|
||||
- build_workspaces
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
85
.github/workflows/contributor-card-bot.yml
vendored
85
.github/workflows/contributor-card-bot.yml
vendored
|
|
@ -6,10 +6,8 @@ name: Contributor Card Bot
|
|||
#
|
||||
# Intentionally NOT included: pull_request_review, pull_request_review_comment,
|
||||
# issue_comment. GitHub withholds repository secrets from these events when
|
||||
# they originate on forked PRs, which is precisely the path most external
|
||||
# contributor activity takes; the bot requires BOT_APP_* secrets to authenticate,
|
||||
# so wiring those events here would fail-closed exactly for the contributors we
|
||||
# want to recognize. They can be re-added later via a workflow_run handoff.
|
||||
# they originate on forked PRs, so the relay secret would fail-closed there too.
|
||||
# They can be re-added later via a workflow_run handoff.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
|
@ -24,21 +22,16 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
# Serialize all bot runs across the whole repository. The bot reads-then-writes
|
||||
# `data/contributor-card-state.json`; running events in parallel let multiple
|
||||
# runs read the same SHA and only the first PUT succeeds, the rest fail with a
|
||||
# 409 Conflict. They also let the same actor receive duplicate cards when a
|
||||
# burst of events fires before the first state write lands. A single repo-wide
|
||||
# group with `cancel-in-progress: false` queues runs and processes them in
|
||||
# arrival order, so every event still gets a card and the state file is never
|
||||
# stale on write.
|
||||
# Serialize all relay runs across the whole repository. The Cloudflare worker
|
||||
# owns durable state now, but queuing still keeps identical GitHub events from
|
||||
# racing each other and producing duplicate comments during bursty merge windows.
|
||||
concurrency:
|
||||
group: contributor-card-bot
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
recognize:
|
||||
name: Render and post contributor card
|
||||
name: Relay contributor event to Cloudflare worker
|
||||
if: |
|
||||
github.repository == 'nexu-io/open-design' &&
|
||||
(
|
||||
|
|
@ -49,32 +42,46 @@ jobs:
|
|||
github.event_name == 'workflow_dispatch'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Checkout contributor bot
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: nexu-io/open-design-bot-sandbox
|
||||
ref: main
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install bot dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run contributor bot
|
||||
- name: Relay event payload
|
||||
env:
|
||||
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
||||
BOT_APP_INSTALLATION_ID: ${{ secrets.BOT_APP_INSTALLATION_ID }}
|
||||
BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
run: pnpm exec tsx scripts/action-handler.ts
|
||||
CONTRIBUTOR_CARD_WORKER_URL: ${{ secrets.CONTRIBUTOR_CARD_WORKER_URL }}
|
||||
CONTRIBUTOR_CARD_WORKER_SECRET: ${{ secrets.CONTRIBUTOR_CARD_WORKER_SECRET }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ACTION: ${{ github.event.action }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
GITHUB_RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
GITHUB_EVENT_PATH: ${{ github.event_path }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
|
||||
payload = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
|
||||
envelope = {
|
||||
"repository": os.environ["GITHUB_REPOSITORY"],
|
||||
"eventName": os.environ["GITHUB_EVENT_NAME"],
|
||||
"action": os.environ.get("GITHUB_EVENT_ACTION") or payload.get("action"),
|
||||
"deliveryId": f"{os.environ['GITHUB_RUN_ID']}-{os.environ['GITHUB_RUN_ATTEMPT']}",
|
||||
"triggeredAt": __import__("datetime").datetime.utcnow().isoformat(timespec="milliseconds") + "Z",
|
||||
"payload": payload,
|
||||
}
|
||||
Path("body.json").write_text(json.dumps(envelope, separators=(",", ":")))
|
||||
PY
|
||||
python - <<'PY'
|
||||
import hashlib, hmac, os
|
||||
from pathlib import Path
|
||||
|
||||
body = Path("body.json").read_bytes()
|
||||
digest = hmac.new(os.environ["CONTRIBUTOR_CARD_WORKER_SECRET"].encode(), body, hashlib.sha256).hexdigest()
|
||||
Path("signature.txt").write_text(f"sha256={digest}")
|
||||
PY
|
||||
curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-open-design-signature: $(cat signature.txt)" \
|
||||
--data-binary @body.json \
|
||||
"$CONTRIBUTOR_CARD_WORKER_URL/api/github/events"
|
||||
|
|
|
|||
57
.github/workflows/fork-pr-workflow-approval.yml
vendored
Normal file
57
.github/workflows/fork-pr-workflow-approval.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
name: fork-pr-workflow-approval
|
||||
|
||||
# This workflow runs in the trusted base-repository context. It never checks out
|
||||
# or executes the fork head; the TypeScript policy script only reads PR metadata
|
||||
# through the GitHub API and approves pending pull_request runs when the touched
|
||||
# paths are inside the low-risk source allowlist. It only approves low-privilege
|
||||
# pull_request workflows (`ci`, visual verify, and strict web-source visual
|
||||
# capture); privileged
|
||||
# workflow_run / release / deploy workflows stay on manual gates.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review, edited]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: Pull request number to evaluate.
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: Log decisions without approving workflow runs.
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: fork-pr-workflow-approval-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
if: github.repository == 'nexu-io/open-design' && (github.event_name != 'pull_request_target' || github.event.action != 'edited' || github.event.changes.base != null)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted base code
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Evaluate and approve low-risk fork PR workflows
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
DRY_RUN: ${{ inputs.dry_run || false }}
|
||||
run: node --experimental-strip-types scripts/approve-fork-pr-workflows.ts
|
||||
162
.github/workflows/landing-page-ci.yml
vendored
162
.github/workflows/landing-page-ci.yml
vendored
|
|
@ -5,7 +5,8 @@ on:
|
|||
paths:
|
||||
# Workflow files
|
||||
- .github/workflows/landing-page-ci.yml
|
||||
- .github/workflows/landing-page-deploy.yml
|
||||
- .github/workflows/landing-page-staging.yml
|
||||
- .github/workflows/landing-page-production.yml
|
||||
- .github/workflows/blog-indexing-on-deploy.yml
|
||||
- .github/workflows/blog-indexing-monitor.yml
|
||||
# Landing page sources
|
||||
|
|
@ -19,16 +20,29 @@ on:
|
|||
- design-systems/**
|
||||
- craft/**
|
||||
- templates/**
|
||||
# Plugin manifests power the bundled-plugin catalog and the new
|
||||
# `_lib/bundled-plugins.ts` reader; CI must rerun when their
|
||||
# `title_i18n` / `description_i18n` maps or other fields change.
|
||||
- plugins/**
|
||||
# Workspace plumbing
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
# Merge queue trigger so PRs that touch the same paths can clear
|
||||
# `Validate landing page` / `Strict PR visual tests` while queued.
|
||||
# Without this branch ruleset blocks merges (the queue waits forever
|
||||
# for a check name that never gets dispatched against the merge_group
|
||||
# ref), which is the exact deadlock observed during the 5/26 release
|
||||
# window.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/landing-page-ci.yml
|
||||
- .github/workflows/landing-page-deploy.yml
|
||||
- .github/workflows/landing-page-staging.yml
|
||||
- .github/workflows/landing-page-production.yml
|
||||
- .github/workflows/blog-indexing-on-deploy.yml
|
||||
- .github/workflows/blog-indexing-monitor.yml
|
||||
- apps/landing-page/**
|
||||
|
|
@ -37,6 +51,7 @@ on:
|
|||
- design-systems/**
|
||||
- craft/**
|
||||
- templates/**
|
||||
- plugins/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
|
|
@ -44,6 +59,10 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to post/update the preview-URL comment on the PR. Fork PRs run
|
||||
# with a read-only token regardless, so the preview steps below are gated
|
||||
# to same-repo branches.
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: landing-page-ci-${{ github.event.pull_request.number || github.ref }}
|
||||
|
|
@ -61,37 +80,26 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.33.2
|
||||
- name: Setup workspace
|
||||
uses: ./.github/actions/setup-workspace
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
- name: Cache generated previews
|
||||
id: previews-cache
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
path: apps/landing-page/public/previews
|
||||
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'apps/landing-page/scripts/fallback-preview-card.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**', 'plugins/_official/**') }}
|
||||
restore-keys: |
|
||||
landing-page-previews-${{ runner.os }}-
|
||||
|
||||
# Cache the Playwright browser binaries between runs. The cache key
|
||||
# is pinned to the playwright version we depend on (kept in
|
||||
# apps/landing-page/package.json) so a bump invalidates correctly.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
|
||||
package-json-path: apps/landing-page/package.json
|
||||
install-command: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
|
||||
|
||||
- name: Typecheck landing page
|
||||
run: pnpm --filter @open-design/landing-page typecheck
|
||||
|
|
@ -103,16 +111,30 @@ jobs:
|
|||
# launch failure or a 100%-failure run exits non-zero so the
|
||||
# build stops instead of silently shipping zero thumbnails.
|
||||
- name: Generate skill + template previews
|
||||
# Exact previews-cache hit ⇒ public/previews already holds the correct
|
||||
# thumbnails, skip the slow Playwright render. A restore-keys partial
|
||||
# hit keeps cache-hit false, so we still regenerate — no stale-thumbnail
|
||||
# drift.
|
||||
if: steps.previews-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm --filter @open-design/landing-page previews
|
||||
|
||||
# No PUBLIC_GA_MEASUREMENT_ID for PR/CI builds: the per-PR preview must
|
||||
# not report into the production GA property. OD_LANDING_NOINDEX=1 keeps
|
||||
# the PR preview (pr-<n>.open-design-landing-staging.pages.dev) out of
|
||||
# search engines. Only `landing-page-production` builds without these.
|
||||
- name: Build landing page
|
||||
run: pnpm --filter @open-design/landing-page build
|
||||
env:
|
||||
OD_LANDING_NOINDEX: '1'
|
||||
run: pnpm --filter @open-design/landing-page build:static
|
||||
|
||||
- name: Lint changed blog SEO
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}"
|
||||
if [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
|
||||
BASE="HEAD^"
|
||||
BASE="${{ github.event.pull_request.base.sha || github.event.before || '' }}"
|
||||
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
|
||||
# merge_group (and first-push) events have no base SHA. Resolve a
|
||||
# concrete commit instead of passing the literal "HEAD^", which the
|
||||
# blog-indexing scripts' assertSafeGitRef rejects (no "^" allowed).
|
||||
BASE="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/lint-blog-seo.ts \
|
||||
--base "$BASE" \
|
||||
|
|
@ -121,9 +143,12 @@ jobs:
|
|||
|
||||
- name: Guard blog URL changes
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha || github.event.before || 'HEAD^' }}"
|
||||
if [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
|
||||
BASE="HEAD^"
|
||||
BASE="${{ github.event.pull_request.base.sha || github.event.before || '' }}"
|
||||
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
|
||||
# merge_group (and first-push) events have no base SHA. Resolve a
|
||||
# concrete commit instead of passing the literal "HEAD^", which the
|
||||
# blog-indexing scripts' assertSafeGitRef rejects (no "^" allowed).
|
||||
BASE="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
pnpm --filter @open-design/landing-page exec tsx scripts/blog-indexing/check-blog-url-changes.ts \
|
||||
--base "$BASE" \
|
||||
|
|
@ -162,3 +187,74 @@ jobs:
|
|||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
|
||||
# --- PR preview deploy -------------------------------------------------
|
||||
# Publish this PR's built site to its own preview URL in the STAGING
|
||||
# project (`--branch=pr-<number>`) so reviewers see the rendered result
|
||||
# before merge. It lands in the staging project, never the production
|
||||
# project. Gated to same-repo branches: fork PRs run without the
|
||||
# Cloudflare secrets and with a read-only token, so they skip the
|
||||
# deploy/comment and keep just the validation above.
|
||||
- name: Deploy PR preview to Cloudflare Pages
|
||||
id: preview
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: apps/landing-page
|
||||
packageManager: npm
|
||||
command: >
|
||||
pages deploy out
|
||||
--project-name=open-design-landing-staging
|
||||
--branch=pr-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Comment preview URL on PR
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
DEPLOY_URL: ${{ steps.preview.outputs.deployment-url }}
|
||||
ALIAS_URL: ${{ steps.preview.outputs.pages-deployment-alias-url }}
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- landing-preview -->';
|
||||
const deploy = process.env.DEPLOY_URL || '';
|
||||
const alias =
|
||||
process.env.ALIAS_URL ||
|
||||
`https://pr-${context.issue.number}.open-design-landing-staging.pages.dev`;
|
||||
const sha = context.payload.pull_request.head.sha.slice(0, 7);
|
||||
const body = [
|
||||
marker,
|
||||
'### 🚀 Landing page preview',
|
||||
'',
|
||||
'This PR is deployed to a Cloudflare Pages preview — **not** staging or production:',
|
||||
'',
|
||||
`- Stable alias: ${alias}`,
|
||||
deploy ? `- This build: ${deploy}` : '',
|
||||
'',
|
||||
`Updated for commit \`${sha}\`.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body && c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
161
.github/workflows/landing-page-production.yml
vendored
Normal file
161
.github/workflows/landing-page-production.yml
vendored
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
name: landing-page-production
|
||||
|
||||
# Promotes the current landing page to PRODUCTION: the `open-design-landing`
|
||||
# Cloudflare Pages project, served at open-design.ai. This is the ONLY
|
||||
# workflow that names the production project, and it is manual-only
|
||||
# (workflow_dispatch) — a merge to `main` can never reach production on its
|
||||
# own; it only updates the staging project (staging.open-design.ai) via
|
||||
# `landing-page-staging`. Gate this further by configuring required reviewers
|
||||
# on the GitHub `production` environment (Settings → Environments).
|
||||
#
|
||||
# The build is identical to staging/CI, so what you reviewed on
|
||||
# staging.open-design.ai is what ships.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: 'Why promote now? (recorded in the run log)'
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
# Never cancel an in-flight production deploy.
|
||||
concurrency:
|
||||
group: landing-page-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy landing page to production
|
||||
# Production ships `main` only. workflow_dispatch can be launched from any
|
||||
# ref via the Actions "Use workflow from" dropdown; gate the whole job on
|
||||
# the main ref so a dispatch from a feature branch/tag is skipped outright
|
||||
# (no deploy) instead of recording a non-main production run — which would
|
||||
# also dodge blog-indexing's `workflow_run` `branches: [main]` filter.
|
||||
if: github.repository == 'nexu-io/open-design' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
environment:
|
||||
name: production
|
||||
url: https://open-design.ai
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
# Production always ships `main`. workflow_dispatch can be launched
|
||||
# from any ref via the Actions "Use workflow from" dropdown, so pin
|
||||
# the checkout to main — the deployed artifact must equal reviewed
|
||||
# main, never whatever branch/tag the operator happened to select.
|
||||
ref: main
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache generated previews
|
||||
id: previews-cache
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: apps/landing-page/public/previews
|
||||
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'apps/landing-page/scripts/fallback-preview-card.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**', 'plugins/_official/**') }}
|
||||
restore-keys: |
|
||||
landing-page-previews-${{ runner.os }}-
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
|
||||
|
||||
- name: Typecheck landing page
|
||||
run: pnpm --filter @open-design/landing-page typecheck
|
||||
|
||||
# Generate previews before build so they end up in `out/previews/`.
|
||||
# Soft vs. hard failure is enforced inside the script itself:
|
||||
# individual broken `example.html` entries are logged and skipped,
|
||||
# but a systemic failure (chromium launch error, every job failing)
|
||||
# exits non-zero so we don't silently ship a deploy with zero
|
||||
# thumbnails to production.
|
||||
- name: Generate skill + template previews
|
||||
# Exact previews-cache hit ⇒ public/previews already holds the correct
|
||||
# thumbnails, skip the slow Playwright render. A restore-keys partial
|
||||
# hit keeps cache-hit false, so we still regenerate — no stale-thumbnail
|
||||
# drift.
|
||||
if: steps.previews-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm --filter @open-design/landing-page previews
|
||||
|
||||
- name: Build landing page
|
||||
env:
|
||||
PUBLIC_GA_MEASUREMENT_ID: ${{ vars.PUBLIC_GA_MEASUREMENT_ID }}
|
||||
run: pnpm --filter @open-design/landing-page build:static
|
||||
|
||||
- name: Verify zero external JavaScript
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
|
||||
const forbidden = [
|
||||
/<script\b[^>]*\bsrc=/i,
|
||||
/type=["']module["']/i,
|
||||
/\/_astro\/[^"'<>\s]+\.js/i,
|
||||
];
|
||||
for (const pattern of forbidden) {
|
||||
if (pattern.test(html)) {
|
||||
console.error(`Unexpected client JavaScript matched ${pattern}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Verify Cloudflare image resizing URLs
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
|
||||
const resizedUrls = html.match(/https:\/\/static\.open-design\.ai\/cdn-cgi\/image\//g) ?? [];
|
||||
if (resizedUrls.length < 16) {
|
||||
console.error(`Expected at least 16 Cloudflare resized image URLs, found ${resizedUrls.length}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (/(?:src|content)=["']\/assets\/[A-Za-z0-9_.-]+\.png/.test(html)) {
|
||||
console.error('Found local /assets/*.png image reference in generated landing HTML.');
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
|
||||
# `--branch=main` IS the Cloudflare Pages production branch, so this
|
||||
# publishes to the production domain (open-design.ai).
|
||||
- name: Deploy to Cloudflare Pages (production)
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: apps/landing-page
|
||||
packageManager: npm
|
||||
command: >
|
||||
pages deploy out
|
||||
--project-name=open-design-landing
|
||||
--branch=main
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
name: landing-page-deploy
|
||||
name: landing-page-staging
|
||||
|
||||
# Pushes to `main` deploy the landing page to the STAGING Cloudflare Pages
|
||||
# project (`open-design-landing-staging`, served at staging.open-design.ai).
|
||||
# Production (the separate `open-design-landing` project → open-design.ai) is
|
||||
# never named here — it is reached exclusively through the manual
|
||||
# `landing-page-production` workflow. This is the safety gate: project
|
||||
# separation means a merge to main can only ever change staging.
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -6,7 +13,8 @@ on:
|
|||
- main
|
||||
paths:
|
||||
# Workflow files
|
||||
- .github/workflows/landing-page-deploy.yml
|
||||
- .github/workflows/landing-page-staging.yml
|
||||
- .github/workflows/landing-page-production.yml
|
||||
- .github/workflows/landing-page-ci.yml
|
||||
# Landing page sources
|
||||
- apps/landing-page/**
|
||||
|
|
@ -20,6 +28,11 @@ on:
|
|||
- design-systems/**
|
||||
- craft/**
|
||||
- templates/**
|
||||
# Plugin marketplace — the registry JSON and bundled official plugin
|
||||
# manifests are read at build time by plugin-registry.ts to render the
|
||||
# /plugins catalogue. Merging a plugin PR MUST trigger a redeploy or the
|
||||
# published catalogue silently falls behind.
|
||||
- plugins/**
|
||||
# Workspace plumbing
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
|
|
@ -31,15 +44,18 @@ permissions:
|
|||
deployments: write
|
||||
|
||||
concurrency:
|
||||
group: landing-page-deploy-${{ github.ref }}
|
||||
group: landing-page-staging-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy landing page
|
||||
name: Deploy landing page to staging
|
||||
if: github.repository == 'nexu-io/open-design'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
environment:
|
||||
name: landing-staging
|
||||
url: https://staging.open-design.ai
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
@ -65,8 +81,17 @@ jobs:
|
|||
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache generated previews
|
||||
id: previews-cache
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: apps/landing-page/public/previews
|
||||
key: landing-page-previews-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'apps/landing-page/package.json', 'apps/landing-page/scripts/generate-previews.ts', 'apps/landing-page/scripts/fallback-preview-card.ts', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**', 'plugins/_official/**') }}
|
||||
restore-keys: |
|
||||
landing-page-previews-${{ runner.os }}-
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
|
|
@ -84,10 +109,26 @@ jobs:
|
|||
# exits non-zero so we don't silently ship a deploy with zero
|
||||
# thumbnails to production.
|
||||
- name: Generate skill + template previews
|
||||
# Skip only on an EXACT previews-cache hit: the cache key hashes every
|
||||
# rendering input, so an exact hit means public/previews already holds
|
||||
# the correct thumbnails and regenerating is wasted work. A restore-keys
|
||||
# partial hit leaves cache-hit unset (false), so we still regenerate
|
||||
# then — no stale-thumbnail drift. This is what lets a plugins-only
|
||||
# deploy skip the slowest step (Playwright screenshotting) while a
|
||||
# skill/template change still re-renders.
|
||||
if: steps.previews-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm --filter @open-design/landing-page previews
|
||||
|
||||
# No PUBLIC_GA_MEASUREMENT_ID on staging: leaving it unset omits the
|
||||
# Google Analytics tag entirely, so test-environment traffic does not
|
||||
# pollute the production GA property. OD_LANDING_NOINDEX=1 makes every
|
||||
# page emit <meta name="robots" content="noindex"> so the staging mirror
|
||||
# stays out of search engines. Only `landing-page-production` builds
|
||||
# without these (GA on, indexable).
|
||||
- name: Build landing page
|
||||
run: pnpm --filter @open-design/landing-page build
|
||||
env:
|
||||
OD_LANDING_NOINDEX: '1'
|
||||
run: pnpm --filter @open-design/landing-page build:static
|
||||
|
||||
- name: Verify zero external JavaScript
|
||||
run: |
|
||||
|
|
@ -123,7 +164,11 @@ jobs:
|
|||
}
|
||||
NODE
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
# Deploys to the dedicated STAGING project. `--branch=main` is that
|
||||
# project's production branch, so it serves at its custom domain
|
||||
# staging.open-design.ai. The production project (open-design-landing)
|
||||
# is a different project and is never named here.
|
||||
- name: Deploy to Cloudflare Pages (staging)
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
|
@ -132,5 +177,5 @@ jobs:
|
|||
packageManager: npm
|
||||
command: >
|
||||
pages deploy out
|
||||
--project-name=open-design-landing
|
||||
--branch=${{ github.ref_name }}
|
||||
--project-name=open-design-landing-staging
|
||||
--branch=main
|
||||
205
.github/workflows/nix-hash-autofix.yml
vendored
Normal file
205
.github/workflows/nix-hash-autofix.yml
vendored
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
name: nix-hash-autofix
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
if: ${{ github.repository == 'nexu-io/open-design' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check for Nix hash refresh artifact
|
||||
id: artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
artifact_id="$(gh api "repos/$REPO/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "nix-hash-refresh") | .id' | head -n 1 || true)"
|
||||
if [ -z "$artifact_id" ]; then
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
echo "artifact_id=$artifact_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download Nix hash refresh artifact
|
||||
if: ${{ steps.artifact.outputs.present == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/nix-hash-refresh"
|
||||
gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPO/actions/artifacts/$ARTIFACT_ID/zip" > "$RUNNER_TEMP/nix-hash-refresh.zip"
|
||||
unzip -q "$RUNNER_TEMP/nix-hash-refresh.zip" -d "$RUNNER_TEMP/nix-hash-refresh"
|
||||
|
||||
- name: Read PR and patch metadata
|
||||
if: ${{ steps.artifact.outputs.present == 'true' }}
|
||||
id: meta
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
WORKFLOW_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || '' }}
|
||||
WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1], "r", encoding="utf-8")); print(data.get("status", "missing"))' "$RUNNER_TEMP/nix-hash-refresh/metadata.json")"
|
||||
artifact_head_sha="$(python3 -c 'import json, sys; data = json.load(open(sys.argv[1], "r", encoding="utf-8")); print(data.get("headSha", ""))' "$RUNNER_TEMP/nix-hash-refresh/metadata.json")"
|
||||
|
||||
pr_number="$WORKFLOW_PR_NUMBER"
|
||||
if [ -z "$pr_number" ]; then
|
||||
pr_number="$(gh api "repos/$REPO/commits/$WORKFLOW_HEAD_SHA/pulls" --jq '.[0].number // empty')"
|
||||
fi
|
||||
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
|
||||
echo "Unable to derive PR number for workflow head $WORKFLOW_HEAD_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pr_json="$(gh api "repos/$REPO/pulls/$pr_number")"
|
||||
head_repo="$(printf '%s' "$pr_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["repo"]["full_name"])')"
|
||||
head_ref="$(printf '%s' "$pr_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["ref"])')"
|
||||
head_sha="$(printf '%s' "$pr_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["sha"])')"
|
||||
{
|
||||
echo "status=$status"
|
||||
echo "artifact_head_sha=$artifact_head_sha"
|
||||
echo "pr_number=$pr_number"
|
||||
echo "head_repo=$head_repo"
|
||||
echo "head_ref=$head_ref"
|
||||
echo "head_sha=$head_sha"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$artifact_head_sha" ] && [ "$artifact_head_sha" = "$head_sha" ]; then
|
||||
echo "head_sha_matches=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "head_sha_matches=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if [ "$head_repo" = "$REPO" ] && [ "$status" = "patch-generated" ] && [ "$artifact_head_sha" = "$head_sha" ]; then
|
||||
echo "can_autopush=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "can_autopush=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Detect bot credentials
|
||||
if: ${{ steps.meta.outputs.can_autopush == 'true' }}
|
||||
id: bot-secrets
|
||||
env:
|
||||
BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
|
||||
BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${BOT_APP_ID}" ] && [ -n "${BOT_APP_PRIVATE_KEY}" ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Generate Open Design bot token
|
||||
if: ${{ steps.meta.outputs.can_autopush == 'true' && steps.bot-secrets.outputs.present == 'true' }}
|
||||
id: bot-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||
owner: nexu-io
|
||||
repositories: open-design
|
||||
permission-contents: write
|
||||
|
||||
- name: Checkout PR branch for auto-apply
|
||||
if: ${{ steps.meta.outputs.can_autopush == 'true' && steps.bot-token.outcome == 'success' }}
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.meta.outputs.head_repo }}
|
||||
ref: ${{ steps.meta.outputs.head_ref }}
|
||||
token: ${{ steps.bot-token.outputs.token }}
|
||||
|
||||
- name: Apply generated hash patch
|
||||
if: ${{ steps.meta.outputs.can_autopush == 'true' && steps.bot-token.outcome == 'success' }}
|
||||
id: apply
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
patch_path="$RUNNER_TEMP/nix-hash-refresh/nix-pnpm-deps.patch"
|
||||
if [ ! -f "$patch_path" ]; then
|
||||
echo "applied=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git apply --check "$patch_path"
|
||||
git apply "$patch_path"
|
||||
changed_files="$(git diff --name-only)"
|
||||
if [ "$changed_files" != "nix/pnpm-deps.nix" ]; then
|
||||
echo "Unexpected files changed after applying hash patch:" >&2
|
||||
printf '%s\n' "$changed_files" >&2
|
||||
exit 1
|
||||
fi
|
||||
if git diff --quiet --exit-code; then
|
||||
echo "applied=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "applied=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit and push generated hash refresh
|
||||
if: ${{ steps.meta.outputs.can_autopush == 'true' && steps.bot-token.outcome == 'success' && steps.apply.outputs.applied == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name 'open-design-bot[bot]'
|
||||
git config user.email '282769551+open-design-bot[bot]@users.noreply.github.com'
|
||||
git add nix/pnpm-deps.nix
|
||||
git commit -m 'chore(nix): refresh pnpm deps hash'
|
||||
git push origin "HEAD:${{ steps.meta.outputs.head_ref }}"
|
||||
|
||||
- name: Upsert fork/manual patch comment
|
||||
if: ${{ steps.artifact.outputs.present == 'true' && steps.meta.outputs.head_sha_matches == 'true' && (steps.meta.outputs.can_autopush != 'true' || steps.bot-token.outcome != 'success' || steps.apply.outputs.applied != 'true') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
|
||||
HASH_REFRESH_STATUS: ${{ steps.meta.outputs.status }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body_file="$RUNNER_TEMP/nix-hash-refresh-comment.md"
|
||||
patch_file="$RUNNER_TEMP/nix-hash-refresh/nix-pnpm-deps.patch"
|
||||
marker='<!-- nix-hash-refresh -->'
|
||||
|
||||
{
|
||||
printf '%s\n' "$marker"
|
||||
if [ "${HASH_REFRESH_STATUS}" = 'patch-generated' ]; then
|
||||
printf "A generated Nix hash refresh is available for this PR.\n\n"
|
||||
printf "Apply the patch from the \`nix-hash-refresh\` artifact attached to the failed \`ci\` run, or paste the diff below into \`git apply\`:\n\n"
|
||||
printf '```diff\n'
|
||||
if [ -f "$patch_file" ]; then
|
||||
cat "$patch_file"
|
||||
else
|
||||
printf '(artifact did not include nix-pnpm-deps.patch)\n'
|
||||
fi
|
||||
printf '\n```\n'
|
||||
else
|
||||
printf "The failed \`ci\` run attempted to refresh \`nix/pnpm-deps.nix\`, but it could not produce a hash-only patch.\n\n"
|
||||
printf "Download the \`nix-hash-refresh\` artifact from that run and inspect \`update.log\` for details.\n"
|
||||
fi
|
||||
} > "$body_file"
|
||||
|
||||
comment_id="$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" --jq ".[] | select(.body | contains(\"$marker\")) | .id" | tail -n 1 || true)"
|
||||
if [ -n "$comment_id" ]; then
|
||||
gh api --method PATCH "repos/$REPO/issues/comments/$comment_id" --field body="$(cat "$body_file")" >/dev/null
|
||||
else
|
||||
gh api --method POST "repos/$REPO/issues/$PR_NUMBER/comments" --field body="$(cat "$body_file")" >/dev/null
|
||||
fi
|
||||
64
.github/workflows/release-beta-s.yml
vendored
Normal file
64
.github/workflows/release-beta-s.yml
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Self-hosted release-beta lane. Hosted baseline remains release-beta.yml.
|
||||
name: release-beta-s
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
enable_win:
|
||||
description: "Run the Windows self-hosted beta lane."
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
publish:
|
||||
description: "Publish release assets and metadata. Keep disabled while validating the runner lane."
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: release-beta-s-win
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
windows_probe:
|
||||
name: Probe Windows self-hosted runner
|
||||
if: ${{ inputs.enable_win }}
|
||||
runs-on: [self-hosted, Windows, X64, nexu-win, release-beta]
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Verify preinstalled toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
where.exe git
|
||||
where.exe git-lfs
|
||||
where.exe node
|
||||
where.exe pnpm
|
||||
where.exe cargo
|
||||
where.exe makensis
|
||||
git --version
|
||||
git lfs version
|
||||
node --version
|
||||
pnpm --version
|
||||
cargo --version
|
||||
makensis /VERSION
|
||||
|
||||
- name: Confirm workflow boundary
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
"workspace=$env:GITHUB_WORKSPACE"
|
||||
"runner_temp=$env:RUNNER_TEMP"
|
||||
"runner_tool_cache=$env:RUNNER_TOOL_CACHE"
|
||||
if ($env:GITHUB_WORKSPACE -like "C:\Users\runner\Projects\*") {
|
||||
throw "GITHUB_WORKSPACE must not point at the operator Projects directory"
|
||||
}
|
||||
16
.github/workflows/release-beta.yml
vendored
16
.github/workflows/release-beta.yml
vendored
|
|
@ -41,6 +41,13 @@ env:
|
|||
# no events leave the user's machine.
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
|
||||
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
|
||||
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
|
||||
# to PostHog after `next build` and before the .map files are stripped
|
||||
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
|
||||
# and the helper still strips .map to keep source out of the installer.
|
||||
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
|
||||
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
|
|
@ -170,6 +177,7 @@ jobs:
|
|||
--mac-compression normal
|
||||
--to dmg
|
||||
--json
|
||||
--require-vela-cli
|
||||
--signed
|
||||
)
|
||||
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then
|
||||
|
|
@ -262,6 +270,8 @@ jobs:
|
|||
OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/mac-tools-pack-build.log
|
||||
OD_PACKAGED_E2E_MAC: "1"
|
||||
OD_PACKAGED_E2E_NAMESPACE: release-beta
|
||||
OD_PACKAGED_E2E_RELEASE_CHANNEL: beta
|
||||
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
|
||||
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac
|
||||
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
|
||||
run: |
|
||||
|
|
@ -450,7 +460,7 @@ jobs:
|
|||
"--namespace", "release-beta-win",
|
||||
"--portable",
|
||||
"--app-version", "${{ needs.metadata.outputs.beta_version }}",
|
||||
"--to", "nsis",
|
||||
"--to", "all",
|
||||
"--json"
|
||||
)
|
||||
"cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
|
|
@ -468,7 +478,7 @@ jobs:
|
|||
--namespace release-beta-win `
|
||||
--portable `
|
||||
--app-version "${{ needs.metadata.outputs.beta_version }}" `
|
||||
--to nsis `
|
||||
--to all `
|
||||
--json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
|
||||
|
|
@ -498,6 +508,8 @@ jobs:
|
|||
OD_PACKAGED_E2E_WIN: "1"
|
||||
OD_PACKAGED_E2E_WIN_VERIFY_REINSTALL: "0"
|
||||
OD_PACKAGED_E2E_NAMESPACE: release-beta-win
|
||||
OD_PACKAGED_E2E_RELEASE_CHANNEL: beta
|
||||
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
|
||||
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win
|
||||
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
|
||||
run: |
|
||||
|
|
|
|||
39
.github/workflows/release-preview.yml
vendored
39
.github/workflows/release-preview.yml
vendored
|
|
@ -19,6 +19,13 @@ env:
|
|||
# leave the user's machine, /api/analytics/config returns enabled=false).
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
|
||||
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
|
||||
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
|
||||
# to PostHog after `next build` and before the .map files are stripped
|
||||
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
|
||||
# and the helper still strips .map to keep source out of the installer.
|
||||
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
|
||||
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
|
|
@ -302,6 +309,7 @@ jobs:
|
|||
with:
|
||||
name: open-design-preview-mac-release-assets
|
||||
path: ${{ runner.temp }}/release-assets
|
||||
retention-days: 1
|
||||
|
||||
build_mac_intel:
|
||||
name: Build preview mac intel x64
|
||||
|
|
@ -372,6 +380,7 @@ jobs:
|
|||
with:
|
||||
name: open-design-preview-mac-intel-release-assets
|
||||
path: ${{ runner.temp }}/release-assets
|
||||
retention-days: 1
|
||||
|
||||
build_win:
|
||||
name: Build preview win x64
|
||||
|
|
@ -447,7 +456,7 @@ jobs:
|
|||
"--namespace", "release-preview-win",
|
||||
"--portable",
|
||||
"--app-version", "${{ needs.metadata.outputs.release_version }}",
|
||||
"--to", "nsis",
|
||||
"--to", "all",
|
||||
"--json"
|
||||
)
|
||||
"cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
|
|
@ -465,7 +474,7 @@ jobs:
|
|||
--namespace release-preview-win `
|
||||
--portable `
|
||||
--app-version "${{ needs.metadata.outputs.release_version }}" `
|
||||
--to nsis `
|
||||
--to all `
|
||||
--json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
|
||||
|
|
@ -557,6 +566,7 @@ jobs:
|
|||
with:
|
||||
name: open-design-preview-win-release-assets
|
||||
path: ${{ runner.temp }}/release-assets
|
||||
retention-days: 1
|
||||
|
||||
build_linux:
|
||||
name: Build preview linux x64
|
||||
|
|
@ -607,6 +617,7 @@ jobs:
|
|||
with:
|
||||
name: open-design-preview-linux-release-assets
|
||||
path: ${{ runner.temp }}/release-assets
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
name: Publish preview release
|
||||
|
|
@ -716,6 +727,7 @@ jobs:
|
|||
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
|
||||
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
|
||||
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
|
||||
R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }}
|
||||
run: bash .github/scripts/release/r2/verify.sh
|
||||
|
||||
- name: Publish summary
|
||||
|
|
@ -732,8 +744,31 @@ jobs:
|
|||
R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }}
|
||||
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
|
||||
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
|
||||
R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }}
|
||||
run: bash .github/scripts/release/r2/summary.sh
|
||||
|
||||
- name: Cleanup workflow artifacts
|
||||
if: ${{ success() }}
|
||||
run: bash .github/scripts/release/github/cleanup-artifacts.sh
|
||||
|
||||
cleanup_partial_release_assets:
|
||||
name: Cleanup unpublished preview asset artifacts
|
||||
needs:
|
||||
- build_mac
|
||||
- build_mac_intel
|
||||
- build_win
|
||||
- build_linux
|
||||
- publish
|
||||
if: ${{ always() && needs.publish.result != 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Delete unpublished preview asset artifacts
|
||||
env:
|
||||
ARTIFACT_CLEANUP_DESCRIPTION: intermediate preview asset Actions artifacts from this unpublished run. Canonical manual downloads are only the R2 links in a successful publish summary
|
||||
ARTIFACT_NAME_REGEX: "-release-assets$"
|
||||
run: bash .github/scripts/release/github/cleanup-artifacts.sh
|
||||
|
|
|
|||
87
.github/workflows/release-stable.yml
vendored
87
.github/workflows/release-stable.yml
vendored
|
|
@ -32,6 +32,13 @@ env:
|
|||
# leave the user's machine, /api/analytics/config returns enabled=false).
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
|
||||
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
|
||||
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
|
||||
# to PostHog after `next build` and before the .map files are stripped
|
||||
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
|
||||
# and the helper still strips .map to keep source out of the installer.
|
||||
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
|
||||
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
|
|
@ -51,6 +58,9 @@ jobs:
|
|||
channel: ${{ steps.stable.outputs.channel }}
|
||||
commit: ${{ steps.stable.outputs.commit }}
|
||||
github_release_enabled: ${{ steps.stable.outputs.github_release_enabled }}
|
||||
linux_namespace: ${{ steps.stable.outputs.linux_namespace }}
|
||||
mac_intel_namespace: ${{ steps.stable.outputs.mac_intel_namespace }}
|
||||
namespace: ${{ steps.stable.outputs.namespace }}
|
||||
nightly_number: ${{ steps.stable.outputs.nightly_number }}
|
||||
previous_stable: ${{ steps.stable.outputs.previous_stable }}
|
||||
release_name: ${{ steps.stable.outputs.release_name }}
|
||||
|
|
@ -58,6 +68,7 @@ jobs:
|
|||
stable_version: ${{ steps.stable.outputs.stable_version }}
|
||||
state_source: ${{ steps.stable.outputs.state_source }}
|
||||
version_tag: ${{ steps.stable.outputs.version_tag }}
|
||||
win_namespace: ${{ steps.stable.outputs.win_namespace }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
|
@ -82,7 +93,7 @@ jobs:
|
|||
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
|
||||
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
||||
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
|
||||
R2_ACCESS_PROBE_NAME: release-stable
|
||||
R2_ACCESS_PROBE_NAME: ${{ steps.stable.outputs.namespace }}
|
||||
RELEASE_CHANNEL: ${{ inputs.channel }}
|
||||
run: bash .github/scripts/release/r2/check.sh
|
||||
|
||||
|
|
@ -211,9 +222,9 @@ jobs:
|
|||
build_args=(
|
||||
exec tools-pack mac build
|
||||
--dir "$tools_pack_dir"
|
||||
--namespace release-stable
|
||||
--namespace "${{ needs.metadata.outputs.namespace }}"
|
||||
--portable
|
||||
--app-version "${{ needs.metadata.outputs.stable_version }}"
|
||||
--app-version "${{ needs.metadata.outputs.release_version }}"
|
||||
--mac-compression normal
|
||||
--to all
|
||||
--json
|
||||
|
|
@ -235,7 +246,11 @@ jobs:
|
|||
output="$RUNNER_TEMP/mac-framework-diagnostics.txt"
|
||||
source_resolve_log="$RUNNER_TEMP/mac-framework-source-resolve.err"
|
||||
source_framework="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist", "Electron.app", "Contents", "Frameworks", "Electron Framework.framework"));' 2>"$source_resolve_log" || true)"
|
||||
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/release-stable/builder/mac-arm64/Open Design.app/Contents/Frameworks/Electron Framework.framework"
|
||||
app_name="Open Design.app"
|
||||
if [ "${{ needs.metadata.outputs.channel }}" = "nightly" ]; then
|
||||
app_name="Open Design Nightly.app"
|
||||
fi
|
||||
built_framework="$RUNNER_TEMP/tools-pack/out/mac/namespaces/${{ needs.metadata.outputs.namespace }}/builder/mac-arm64/$app_name/Contents/Frameworks/Electron Framework.framework"
|
||||
|
||||
dump_framework() {
|
||||
local label="$1"
|
||||
|
|
@ -308,7 +323,7 @@ jobs:
|
|||
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/mac-tools-pack-build.json
|
||||
OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/mac-tools-pack-build.log
|
||||
OD_PACKAGED_E2E_MAC: "1"
|
||||
OD_PACKAGED_E2E_NAMESPACE: release-stable
|
||||
OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.namespace }}
|
||||
OD_PACKAGED_E2E_RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
|
||||
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
|
||||
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac
|
||||
|
|
@ -332,7 +347,7 @@ jobs:
|
|||
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
|
||||
RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }}
|
||||
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
|
||||
TOOLS_PACK_NAMESPACE: release-stable
|
||||
TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.namespace }}
|
||||
run: bash .github/scripts/release/assets/mac.sh
|
||||
|
||||
- name: Upload mac release bundle
|
||||
|
|
@ -340,6 +355,7 @@ jobs:
|
|||
with:
|
||||
name: open-design-release-mac-release-assets
|
||||
path: ${{ runner.temp }}/release-assets
|
||||
retention-days: 1
|
||||
|
||||
build_mac_intel:
|
||||
name: Build release mac intel x64
|
||||
|
|
@ -390,9 +406,9 @@ jobs:
|
|||
set -euo pipefail
|
||||
pnpm exec tools-pack mac build \
|
||||
--dir "$RUNNER_TEMP/tools-pack" \
|
||||
--namespace release-stable-intel \
|
||||
--namespace "${{ needs.metadata.outputs.mac_intel_namespace }}" \
|
||||
--portable \
|
||||
--app-version "${{ needs.metadata.outputs.stable_version }}" \
|
||||
--app-version "${{ needs.metadata.outputs.release_version }}" \
|
||||
--mac-compression normal \
|
||||
--to all \
|
||||
--json \
|
||||
|
|
@ -403,7 +419,7 @@ jobs:
|
|||
env:
|
||||
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
|
||||
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
|
||||
TOOLS_PACK_NAMESPACE: release-stable-intel
|
||||
TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.mac_intel_namespace }}
|
||||
run: bash .github/scripts/release/assets/mac-intel.sh
|
||||
|
||||
- name: Upload mac intel release bundle
|
||||
|
|
@ -411,6 +427,7 @@ jobs:
|
|||
with:
|
||||
name: open-design-release-mac-intel-release-assets
|
||||
path: ${{ runner.temp }}/release-assets
|
||||
retention-days: 1
|
||||
|
||||
build_win:
|
||||
name: Build release win x64
|
||||
|
|
@ -483,10 +500,10 @@ jobs:
|
|||
"exec", "tools-pack", "win", "build",
|
||||
"--dir", $toolsPackDir,
|
||||
"--cache-dir", $cacheDir,
|
||||
"--namespace", "release-stable-win",
|
||||
"--namespace", "${{ needs.metadata.outputs.win_namespace }}",
|
||||
"--portable",
|
||||
"--app-version", "${{ needs.metadata.outputs.stable_version }}",
|
||||
"--to", "nsis",
|
||||
"--app-version", "${{ needs.metadata.outputs.release_version }}",
|
||||
"--to", "all",
|
||||
"--json"
|
||||
)
|
||||
"cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
|
|
@ -501,10 +518,10 @@ jobs:
|
|||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $cacheDir
|
||||
$buildOutput = pnpm exec tools-pack win build `
|
||||
--dir $toolsPackDir `
|
||||
--namespace release-stable-win `
|
||||
--namespace "${{ needs.metadata.outputs.win_namespace }}" `
|
||||
--portable `
|
||||
--app-version "${{ needs.metadata.outputs.stable_version }}" `
|
||||
--to nsis `
|
||||
--app-version "${{ needs.metadata.outputs.release_version }}" `
|
||||
--to all `
|
||||
--json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
|
||||
|
|
@ -532,7 +549,7 @@ jobs:
|
|||
env:
|
||||
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/windows-tools-pack-build.json
|
||||
OD_PACKAGED_E2E_WIN: "1"
|
||||
OD_PACKAGED_E2E_NAMESPACE: release-stable-win
|
||||
OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.win_namespace }}
|
||||
OD_PACKAGED_E2E_RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
|
||||
OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
|
||||
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win
|
||||
|
|
@ -588,7 +605,7 @@ jobs:
|
|||
RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }}
|
||||
RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }}
|
||||
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
|
||||
TOOLS_PACK_NAMESPACE: release-stable-win
|
||||
TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.win_namespace }}
|
||||
run: ./.github/scripts/release/assets/win.ps1
|
||||
|
||||
- name: Upload windows release bundle
|
||||
|
|
@ -596,6 +613,7 @@ jobs:
|
|||
with:
|
||||
name: open-design-release-win-release-assets
|
||||
path: ${{ runner.temp }}/release-assets
|
||||
retention-days: 1
|
||||
|
||||
build_linux:
|
||||
name: Build release linux x64
|
||||
|
|
@ -643,9 +661,9 @@ jobs:
|
|||
build_args=(
|
||||
exec tools-pack linux build
|
||||
--dir "$tools_pack_dir"
|
||||
--namespace release-stable-linux
|
||||
--namespace "${{ needs.metadata.outputs.linux_namespace }}"
|
||||
--portable
|
||||
--app-version "${{ needs.metadata.outputs.stable_version }}"
|
||||
--app-version "${{ needs.metadata.outputs.release_version }}"
|
||||
--to appimage
|
||||
--containerized
|
||||
--json
|
||||
|
|
@ -663,7 +681,7 @@ jobs:
|
|||
working-directory: e2e
|
||||
env:
|
||||
OD_PACKAGED_E2E_LINUX_APPIMAGE: "1"
|
||||
OD_PACKAGED_E2E_NAMESPACE: release-stable-linux
|
||||
OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.linux_namespace }}
|
||||
OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/release-report/linux/screenshots/open-design-linux-smoke.png
|
||||
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
|
||||
run: |
|
||||
|
|
@ -676,7 +694,7 @@ jobs:
|
|||
"platform": "linux",
|
||||
"releaseVersion": "${{ needs.metadata.outputs.release_version }}",
|
||||
"spec": "specs/linux.spec.ts",
|
||||
"namespace": "release-stable-linux",
|
||||
"namespace": "${{ needs.metadata.outputs.linux_namespace }}",
|
||||
"screenshot": "screenshots/open-design-linux-smoke.png",
|
||||
"githubRunId": "$GITHUB_RUN_ID",
|
||||
"githubRunAttempt": "$GITHUB_RUN_ATTEMPT",
|
||||
|
|
@ -698,7 +716,7 @@ jobs:
|
|||
- name: Prepare linux release assets
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }}
|
||||
TOOLS_PACK_NAMESPACE: release-stable-linux
|
||||
TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.linux_namespace }}
|
||||
run: bash .github/scripts/release/assets/linux.sh
|
||||
|
||||
- name: Upload linux release bundle
|
||||
|
|
@ -706,6 +724,7 @@ jobs:
|
|||
with:
|
||||
name: open-design-release-linux-release-assets
|
||||
path: ${{ runner.temp }}/release-assets
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
name: Publish ${{ needs.metadata.outputs.channel }} release
|
||||
|
|
@ -869,6 +888,7 @@ jobs:
|
|||
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
|
||||
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
|
||||
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
|
||||
R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }}
|
||||
run: bash .github/scripts/release/r2/verify.sh
|
||||
|
||||
- name: Promote draft to published latest
|
||||
|
|
@ -902,8 +922,31 @@ jobs:
|
|||
R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }}
|
||||
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
|
||||
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
|
||||
R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }}
|
||||
run: bash .github/scripts/release/r2/summary.sh
|
||||
|
||||
- name: Cleanup workflow artifacts
|
||||
if: ${{ success() }}
|
||||
run: bash .github/scripts/release/github/cleanup-artifacts.sh
|
||||
|
||||
cleanup_partial_release_assets:
|
||||
name: Cleanup unpublished release asset artifacts
|
||||
needs:
|
||||
- build_mac
|
||||
- build_mac_intel
|
||||
- build_win
|
||||
- build_linux
|
||||
- publish
|
||||
if: ${{ always() && needs.publish.result != 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Delete unpublished release asset artifacts
|
||||
env:
|
||||
ARTIFACT_CLEANUP_DESCRIPTION: intermediate release asset Actions artifacts from this unpublished run. Canonical manual downloads are only the R2 links in a successful publish summary
|
||||
ARTIFACT_NAME_REGEX: "-release-assets$"
|
||||
run: bash .github/scripts/release/github/cleanup-artifacts.sh
|
||||
|
|
|
|||
8
.github/workflows/seo-daily-report.yml
vendored
8
.github/workflows/seo-daily-report.yml
vendored
|
|
@ -103,11 +103,11 @@ jobs:
|
|||
OPP_LOW_CTR: '0.01'
|
||||
OPP_MOBILE_DESKTOP_CTR_GAP: '0.30'
|
||||
run: |
|
||||
flags=""
|
||||
flags=()
|
||||
if [ -n "${{ github.event.inputs.today }}" ]; then
|
||||
flags="$flags --today ${{ github.event.inputs.today }}"
|
||||
flags+=(--today "${{ github.event.inputs.today }}")
|
||||
fi
|
||||
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
|
||||
flags="$flags --dry-run"
|
||||
flags+=(--dry-run)
|
||||
fi
|
||||
pnpm --filter @open-design/landing-page exec tsx scripts/seo-daily-report.ts $flags
|
||||
pnpm --filter @open-design/landing-page exec tsx scripts/seo-daily-report.ts "${flags[@]}"
|
||||
|
|
|
|||
130
.github/workflows/visual-pr-capture.yml
vendored
130
.github/workflows/visual-pr-capture.yml
vendored
|
|
@ -18,7 +18,6 @@ on:
|
|||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: visual-pr-capture-${{ github.event.pull_request.number }}
|
||||
|
|
@ -73,132 +72,3 @@ jobs:
|
|||
e2e/ui/reports/visual-report/manifest.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
comment_same_repo:
|
||||
name: Comment PR visual screenshots
|
||||
needs: [capture]
|
||||
if: ${{ always() && !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download PR visual artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
run-id: ${{ github.run_id }}
|
||||
path: visual-artifact
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Validate capture manifest
|
||||
id: manifest
|
||||
shell: bash
|
||||
env:
|
||||
EXPECTED_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
EXPECTED_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
EXPECTED_RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest="visual-artifact/visual-report/manifest.json"
|
||||
if [ ! -f "$manifest" ]; then
|
||||
manifest="visual-artifact/manifest.json"
|
||||
fi
|
||||
if [ ! -f "$manifest" ]; then
|
||||
echo "Capture manifest not found" >&2
|
||||
find visual-artifact -maxdepth 4 -type f >&2
|
||||
exit 1
|
||||
fi
|
||||
pr_number="$(jq -r '.pr_number' "$manifest")"
|
||||
head_sha="$(jq -r '.head_sha' "$manifest")"
|
||||
base_sha="$(jq -r '.base_sha' "$manifest")"
|
||||
run_id="$(jq -r '.run_id' "$manifest")"
|
||||
capture_outcome="$(jq -r '.capture_outcome // "success"' "$manifest")"
|
||||
if [ "$pr_number" != "$EXPECTED_PR_NUMBER" ] || [ "$head_sha" != "$EXPECTED_HEAD_SHA" ] || [ "$base_sha" != "$EXPECTED_BASE_SHA" ] || [ "$run_id" != "$EXPECTED_RUN_ID" ]; then
|
||||
echo "Capture manifest does not match the current pull_request event." >&2
|
||||
jq . "$manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
|
||||
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build visual diff report
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_PUBLIC_ORIGIN: ${{ vars.R2_PUBLIC_ORIGIN }}
|
||||
CLOUDFLARE_R2_RELEASES_AK: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
|
||||
CLOUDFLARE_R2_RELEASES_SK: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
|
||||
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
|
||||
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
||||
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
|
||||
run: |
|
||||
pnpm -C e2e exec tsx scripts/visual-report.ts compare-pr \
|
||||
--pr-number "${{ steps.manifest.outputs.pr_number }}" \
|
||||
--run-id "${{ steps.manifest.outputs.run_id }}" \
|
||||
--head-sha "${{ steps.manifest.outputs.head_sha }}" \
|
||||
--base-sha "${{ steps.manifest.outputs.base_sha }}" \
|
||||
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
|
||||
--screenshots ../visual-artifact/visual-screenshots \
|
||||
--comment-out ui/reports/visual-report/comment.md \
|
||||
--manifest-out ui/reports/visual-report/report-manifest.json
|
||||
|
||||
- name: Upsert PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body_path="e2e/ui/reports/visual-report/comment.md"
|
||||
comments_json="$RUNNER_TEMP/comments.json"
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" > "$comments_json"
|
||||
comment_id="$(jq -r '.[] | select(.body | contains("<!-- visual-regression-bot -->")) | .id' "$comments_json" | tail -n 1)"
|
||||
if [ -n "$comment_id" ]; then
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" --field "body=$(cat "$body_path")"
|
||||
else
|
||||
gh api --method POST "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --field "body=$(cat "$body_path")"
|
||||
fi
|
||||
|
||||
- name: Upload visual report artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: visual-pr-report-${{ github.event.pull_request.number }}-${{ github.run_id }}
|
||||
path: e2e/ui/reports/visual-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
|
|
|||
247
.github/workflows/visual-pr-comment.yml
vendored
247
.github/workflows/visual-pr-comment.yml
vendored
|
|
@ -27,15 +27,21 @@ concurrency:
|
|||
jobs:
|
||||
comment:
|
||||
name: Publish PR visual diff comment
|
||||
# Fork PR capture artifacts are untrusted. Always validate the live PR state
|
||||
# and only execute trusted base-repository code before publishing comments
|
||||
# or reports.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout same-repository workflow head
|
||||
if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_repository.full_name == github.repository }}
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
repository: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.8
|
||||
|
|
@ -48,25 +54,11 @@ jobs:
|
|||
node-version: 24
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download PR visual artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
run-id: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
|
||||
path: visual-artifact
|
||||
path: ${{ runner.temp }}/visual-artifact
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
|
|
@ -77,16 +69,19 @@ jobs:
|
|||
GH_TOKEN: ${{ github.token }}
|
||||
WORKFLOW_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number || inputs.pr_number }}
|
||||
WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha || '' }}
|
||||
WORKFLOW_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name || '' }}
|
||||
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch || '' }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id || inputs.capture_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest="visual-artifact/visual-report/manifest.json"
|
||||
artifact_dir="$RUNNER_TEMP/visual-artifact"
|
||||
manifest="$artifact_dir/visual-report/manifest.json"
|
||||
if [ ! -f "$manifest" ]; then
|
||||
manifest="visual-artifact/manifest.json"
|
||||
manifest="$artifact_dir/manifest.json"
|
||||
fi
|
||||
if [ ! -f "$manifest" ]; then
|
||||
echo "Capture manifest not found" >&2
|
||||
find visual-artifact -maxdepth 4 -type f >&2
|
||||
find "$artifact_dir" -maxdepth 4 -type f >&2
|
||||
exit 1
|
||||
fi
|
||||
manifest_pr_number="$(jq -r '.pr_number' "$manifest")"
|
||||
|
|
@ -97,22 +92,6 @@ jobs:
|
|||
echo "Invalid manifest pr_number: $manifest_pr_number" >&2
|
||||
exit 1
|
||||
fi
|
||||
derived_pr_number="$WORKFLOW_PR_NUMBER"
|
||||
if [ -z "$derived_pr_number" ] && [ -n "$WORKFLOW_HEAD_SHA" ]; then
|
||||
derived_pr_number="$(gh api "repos/$GITHUB_REPOSITORY/commits/$WORKFLOW_HEAD_SHA/pulls" --jq '.[0].number // empty')"
|
||||
fi
|
||||
pr_number="$derived_pr_number"
|
||||
if [ -z "$pr_number" ]; then
|
||||
pr_number="$manifest_pr_number"
|
||||
fi
|
||||
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
|
||||
echo "Unable to derive PR number from workflow event, capture manifest, or GitHub API for workflow head $WORKFLOW_HEAD_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$derived_pr_number" ] && [ "$manifest_pr_number" != "$derived_pr_number" ]; then
|
||||
echo "Manifest pr_number ($manifest_pr_number) does not match GitHub-derived PR number ($derived_pr_number)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Invalid manifest base_sha: $base_sha" >&2
|
||||
exit 1
|
||||
|
|
@ -125,23 +104,171 @@ jobs:
|
|||
echo "Invalid manifest capture_outcome: $capture_outcome" >&2
|
||||
exit 1
|
||||
fi
|
||||
source_head_sha="$WORKFLOW_HEAD_SHA"
|
||||
source_head_repository="$WORKFLOW_HEAD_REPOSITORY"
|
||||
source_head_branch="$WORKFLOW_HEAD_BRANCH"
|
||||
if [ -z "$source_head_sha" ] || [ -z "$source_head_repository" ] || [ -z "$source_head_branch" ]; then
|
||||
run_json="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$WORKFLOW_RUN_ID")"
|
||||
if [ -z "$source_head_sha" ]; then
|
||||
source_head_sha="$(jq -r '.head_sha // empty' <<< "$run_json")"
|
||||
fi
|
||||
if [ -z "$source_head_repository" ]; then
|
||||
source_head_repository="$(jq -r '.head_repository.full_name // empty' <<< "$run_json")"
|
||||
fi
|
||||
if [ -z "$source_head_branch" ]; then
|
||||
source_head_branch="$(jq -r '.head_branch // empty' <<< "$run_json")"
|
||||
fi
|
||||
fi
|
||||
manifest_head="$(jq -r '.head_sha' "$manifest")"
|
||||
if ! [[ "$manifest_head" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Invalid manifest head_sha: $manifest_head" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$WORKFLOW_HEAD_SHA" ] && [ "$manifest_head" != "$WORKFLOW_HEAD_SHA" ]; then
|
||||
echo "Artifact manifest head_sha ($manifest_head) does not match workflow_run head_sha ($WORKFLOW_HEAD_SHA)." >&2
|
||||
if ! [[ "$source_head_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "Unable to resolve trusted workflow_run head_sha for run $WORKFLOW_RUN_ID." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "path=$manifest" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$manifest_head" >> "$GITHUB_OUTPUT"
|
||||
echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
|
||||
echo "capture_outcome=$capture_outcome" >> "$GITHUB_OUTPUT"
|
||||
if [ "$manifest_head" != "$source_head_sha" ]; then
|
||||
echo "Artifact manifest head_sha ($manifest_head) does not match workflow_run head_sha ($source_head_sha)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$source_head_repository" ]; then
|
||||
echo "Unable to resolve trusted workflow_run head repository for run $WORKFLOW_RUN_ID." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$source_head_branch" ]; then
|
||||
echo "Unable to resolve trusted workflow_run head branch for run $WORKFLOW_RUN_ID." >&2
|
||||
exit 1
|
||||
fi
|
||||
pr_number="$WORKFLOW_PR_NUMBER"
|
||||
if [ -z "$pr_number" ]; then
|
||||
head_owner="${source_head_repository%%/*}"
|
||||
if [ -z "$head_owner" ] || [ "$head_owner" = "$source_head_repository" ]; then
|
||||
echo "Unable to resolve trusted workflow_run head owner from $source_head_repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
encoded_head="$(jq -rn --arg head "$head_owner:$source_head_branch" '$head|@uri')"
|
||||
matching_pr_numbers="$(
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$encoded_head&per_page=100" \
|
||||
| jq -r --arg repo "$source_head_repository" \
|
||||
'.[] | select((.head.repo.full_name // "") == $repo) | .number' \
|
||||
| paste -sd ' ' -
|
||||
)"
|
||||
match_count="$(wc -w <<< "$matching_pr_numbers" | tr -d ' ')"
|
||||
if [ "$match_count" -gt 1 ]; then
|
||||
echo "Unable to resolve a unique open PR from trusted workflow metadata for $source_head_repository@$source_head_branch ($source_head_sha); found $match_count matches." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$match_count" -eq 1 ]; then
|
||||
pr_number="$matching_pr_numbers"
|
||||
else
|
||||
pr_number="$manifest_pr_number"
|
||||
fi
|
||||
fi
|
||||
if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then
|
||||
echo "Unable to derive PR number from trusted workflow metadata for workflow head $source_head_sha." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$WORKFLOW_PR_NUMBER" ] && [ "$manifest_pr_number" != "$WORKFLOW_PR_NUMBER" ]; then
|
||||
echo "Manifest pr_number ($manifest_pr_number) does not match workflow_run PR number ($WORKFLOW_PR_NUMBER)." >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "path=$manifest"
|
||||
echo "pr_number=$pr_number"
|
||||
echo "head_sha=$manifest_head"
|
||||
echo "base_sha=$base_sha"
|
||||
echo "run_id=$run_id"
|
||||
echo "capture_outcome=$capture_outcome"
|
||||
echo "source_head_repository=$source_head_repository"
|
||||
echo "source_head_branch=$source_head_branch"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate live PR state for trusted checkout
|
||||
id: trusted-pr
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
||||
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
|
||||
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
|
||||
SOURCE_HEAD_REPOSITORY: ${{ steps.manifest.outputs.source_head_repository }}
|
||||
SOURCE_HEAD_BRANCH: ${{ steps.manifest.outputs.source_head_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
skip_or_fail_stale() {
|
||||
local message="$1"
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
echo "$message" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$message"
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
}
|
||||
|
||||
pr_json="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")"
|
||||
pr_state="$(jq -r '.state' <<< "$pr_json")"
|
||||
pr_is_draft="$(jq -r '.draft' <<< "$pr_json")"
|
||||
pr_is_cross_repository="$(jq -r '(.head.repo.full_name // "") != (.base.repo.full_name // "")' <<< "$pr_json")"
|
||||
current_head="$(jq -r '.head.sha' <<< "$pr_json")"
|
||||
current_base="$(jq -r '.base.sha' <<< "$pr_json")"
|
||||
current_head_repository="$(jq -r '.head.repo.full_name // empty' <<< "$pr_json")"
|
||||
current_head_branch="$(jq -r '.head.ref // empty' <<< "$pr_json")"
|
||||
if [ "$pr_state" != "open" ]; then
|
||||
skip_or_fail_stale "Skipping trusted checkout for PR $PR_NUMBER because state is $pr_state."
|
||||
fi
|
||||
if [ "$pr_is_draft" != "false" ]; then
|
||||
skip_or_fail_stale "Skipping trusted checkout for PR $PR_NUMBER because it is draft."
|
||||
fi
|
||||
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
|
||||
skip_or_fail_stale "Skipping stale visual artifact for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
|
||||
fi
|
||||
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
|
||||
skip_or_fail_stale "Skipping stale visual artifact for base $ARTIFACT_BASE_SHA; current PR base is $current_base."
|
||||
fi
|
||||
if [ "$current_head_repository" != "$SOURCE_HEAD_REPOSITORY" ]; then
|
||||
echo "Artifact PR head repository ($current_head_repository) does not match trusted workflow_run head repository ($SOURCE_HEAD_REPOSITORY)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$current_head_branch" != "$SOURCE_HEAD_BRANCH" ]; then
|
||||
echo "Artifact PR head branch ($current_head_branch) does not match trusted workflow_run head branch ($SOURCE_HEAD_BRANCH)." >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "stale=false"
|
||||
echo "base_sha=$current_base"
|
||||
echo "is_cross_repository=$pr_is_cross_repository"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout trusted base revision
|
||||
if: ${{ steps.trusted-pr.outputs.stale != 'true' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.head_repository.full_name != github.repository) }}
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ steps.trusted-pr.outputs.base_sha }}
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
|
||||
uses: actions/cache/restore@v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Stop stale visual runs
|
||||
if: ${{ steps.trusted-pr.outputs.stale != 'true' }}
|
||||
id: stale
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -150,9 +277,21 @@ jobs:
|
|||
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid,baseRefOid)"
|
||||
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,headRefOid,baseRefOid)"
|
||||
pr_state="$(jq -r '.state' <<< "$pr_json")"
|
||||
pr_is_draft="$(jq -r '.isDraft' <<< "$pr_json")"
|
||||
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
|
||||
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
|
||||
if [ "$pr_state" != "OPEN" ]; then
|
||||
echo "Skipping visual report for PR $PR_NUMBER because state is $pr_state."
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$pr_is_draft" != "false" ]; then
|
||||
echo "Skipping visual report for PR $PR_NUMBER because it is draft."
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
|
||||
echo "Skipping stale visual artifact for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -166,7 +305,7 @@ jobs:
|
|||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build visual diff report
|
||||
if: ${{ steps.stale.outputs.stale != 'true' }}
|
||||
if: ${{ steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
|
|
@ -185,12 +324,12 @@ jobs:
|
|||
--head-sha "${{ steps.manifest.outputs.head_sha }}" \
|
||||
--base-sha "${{ steps.manifest.outputs.base_sha }}" \
|
||||
--capture-outcome "${{ steps.manifest.outputs.capture_outcome }}" \
|
||||
--screenshots ../visual-artifact/visual-screenshots \
|
||||
--screenshots "$RUNNER_TEMP/visual-artifact/visual-screenshots" \
|
||||
--comment-out ui/reports/visual-report/comment.md \
|
||||
--manifest-out ui/reports/visual-report/report-manifest.json
|
||||
|
||||
- name: Upsert PR comment
|
||||
if: ${{ steps.stale.outputs.stale != 'true' }}
|
||||
if: ${{ steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
||||
|
|
@ -198,9 +337,19 @@ jobs:
|
|||
ARTIFACT_BASE_SHA: ${{ steps.manifest.outputs.base_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid,baseRefOid)"
|
||||
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json state,isDraft,headRefOid,baseRefOid)"
|
||||
pr_state="$(jq -r '.state' <<< "$pr_json")"
|
||||
pr_is_draft="$(jq -r '.isDraft' <<< "$pr_json")"
|
||||
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
|
||||
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
|
||||
if [ "$pr_state" != "OPEN" ]; then
|
||||
echo "Skipping visual comment for PR $PR_NUMBER because state is $pr_state."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$pr_is_draft" != "false" ]; then
|
||||
echo "Skipping visual comment for PR $PR_NUMBER because it is draft."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$current_head" != "$ARTIFACT_HEAD_SHA" ]; then
|
||||
echo "Skipping stale visual comment for $ARTIFACT_HEAD_SHA; current PR head is $current_head."
|
||||
exit 0
|
||||
|
|
@ -220,7 +369,7 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Upload visual report artifact
|
||||
if: ${{ always() && steps.stale.outputs.stale != 'true' }}
|
||||
if: ${{ always() && steps.trusted-pr.outputs.stale != 'true' && steps.stale.outputs.stale != 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: visual-pr-report-${{ steps.manifest.outputs.pr_number }}-${{ steps.manifest.outputs.run_id }}
|
||||
|
|
|
|||
42
AGENTS.md
42
AGENTS.md
|
|
@ -23,7 +23,6 @@ This file is the single source of truth for agents entering this repository. Rea
|
|||
- `packages/sidecar-proto` owns the Open Design sidecar business protocol; `packages/sidecar` owns the generic sidecar runtime; `packages/platform` owns generic OS process primitives.
|
||||
- `tools/dev` is the local development lifecycle control plane.
|
||||
- `tools/pack` is the local packaged build/start/stop/logs control plane, packaged updater harness, installer identity/registry validation surface, and mac beta release artifact preparation surface.
|
||||
- `tools/pr` is the maintainer PR-duty control plane: a thin `gh` wrapper that encodes this repo's review-lane derivation, forbidden-surface flags, lane checklists, and validation-command suggestions.
|
||||
- `tools/serve` is the local fixture-service control plane; first service is `tools-serve start updater` for deterministic updater metadata and artifacts.
|
||||
- `e2e` owns user-level end-to-end smoke tests and Playwright UI automation; read `e2e/AGENTS.md` before editing its tests or commands.
|
||||
|
||||
|
|
@ -58,8 +57,8 @@ This file is the single source of truth for agents entering this repository. Rea
|
|||
|
||||
## Root command boundary
|
||||
|
||||
- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, `pnpm tools-pack`, `pnpm tools-pr`, and `pnpm tools-serve`.
|
||||
- Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter <package> ...`) or tool-scoped (`pnpm tools-pack ...` / `pnpm tools-pr ...`).
|
||||
- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, `pnpm tools-pack`, and `pnpm tools-serve`.
|
||||
- Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter <package> ...`) or tool-scoped (`pnpm tools-pack ...`).
|
||||
- Do not add root e2e aliases; e2e package commands and ownership rules live in `e2e/AGENTS.md`.
|
||||
|
||||
## Release channel model
|
||||
|
|
@ -123,16 +122,11 @@ Every user-facing capability must be reachable through both the web UI **and** t
|
|||
|
||||
## PR-duty tooling
|
||||
|
||||
`pnpm tools-pr` is the maintainer-only control plane for PR-duty work on this repo. It is a thin `gh` wrapper that encodes repo-specific knowledge — review-lane derivation, forbidden-surface flags, per-lane checklists, validation-command suggestions, and a fixed dictionary of factual classify tags (`bot-only-approval`, `needs-rebase`, `stale-approval`, `unresolved-changes-requested`, `awaiting-*` timing, `org-member`, etc.). The tool is read-only on the PR surface: it never approves, merges, comments, or closes; those side effects stay in explicit `gh` invocations the maintainer runs.
|
||||
|
||||
Common subcommands:
|
||||
|
||||
- `pnpm tools-pr list` — triage the open queue by lane and review-state bucket.
|
||||
- `pnpm tools-pr view <num>` — factual review brief for a single PR.
|
||||
- `pnpm tools-pr classify --all` — script-level tag JSON for the whole open queue (entry point for cron / digest consumers); per-PR `classify <num>` for spot checks.
|
||||
- `pnpm tools-pr assignment` — assigner-perspective ownership + idle-time / blocker view across the queue.
|
||||
|
||||
For the full tag dictionary, operational playbook (direct merge / duplicate-title / awaiting-author / org-member / agent-review flows), comment templates, language-detection rules, and tool-design constraints (precision boundaries, factual-output rule, retry + pagination strategy), see [`tools/pr/AGENTS.md`](tools/pr/AGENTS.md).
|
||||
This repository no longer ships a maintainer PR-duty control plane. The former
|
||||
`pnpm tools-pr` workflow has moved to the standalone `PerishCode/duty` project
|
||||
so personal review-lane automation does not become product workspace
|
||||
maintenance surface. Do not recreate `tools/pr`, `@open-design/tools-pr`, or a
|
||||
root `pnpm tools-pr` script without a new explicit maintainer decision.
|
||||
|
||||
## Agent runtime conventions
|
||||
|
||||
|
|
@ -145,10 +139,19 @@ For the full tag dictionary, operational playbook (direct merge / duplicate-titl
|
|||
## Chat UI conventions
|
||||
|
||||
- `apps/web/src/components/file-viewer-render-mode.ts` decides URL-load vs srcDoc for HTML previews. Bridges (deck, comment/inspect selection, palette, edit, tweaks) can ONLY inject through the srcDoc path. Add a new disqualifier to `UrlLoadDecision` whenever a feature needs a srcDoc-only bridge; pass it from `FileViewer.tsx` based on a source-content heuristic where appropriate (e.g. `hasTweaksTemplate`). The host keeps both iframes mounted simultaneously and swaps CSS visibility so toggling render mode does not cause an iframe reload flash; `iframeRef.current` stays aligned with the active iframe via `useEffect`. Receive filters use `isOurIframe(ev.source)` to accept messages from either iframe but signals that should ONLY come from the active iframe (e.g. `od:tweaks-available`) re-check `ev.source === iframeRef.current?.contentWindow`.
|
||||
- TodoWrite UI pins one canonical task list above the chat composer via `PinnedTodoSlot` in `ChatPane.tsx`. The slot reads the latest TodoWrite snapshot across the conversation through `latestTodoWriteInputFromMessages` (`apps/web/src/runtime/todos.ts`). `AssistantMessage.stripTodoToolGroups` removes any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes both `completed` and `in_progress` items (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card.
|
||||
- TodoWrite UI pins one canonical task list above the chat composer via `PinnedTodoSlot` in `ChatPane.tsx`. The slot reads the latest TodoWrite snapshot across the conversation through `latestTodoWriteInputFromMessages` (`apps/web/src/runtime/todos.ts`). `AssistantMessage.stripTodoToolGroups` removes any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes both `completed` and `in_progress` items (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card. `PinnedTodoSlot` sits OUTSIDE the `.chat-log` scroll container, so auto-scroll requires explicit coverage: `ChatPane`'s `ResizeObserver` accepts a `containerRef` from `PinnedTodoSlot` and observes that element directly, and a pane-level `MutationObserver` (`childList: true` on the chat pane ancestor) re-syncs that observation whenever the slot mounts or unmounts as new TodoWrite snapshots arrive.
|
||||
- `AskUserQuestionCard` (in `ToolCard.tsx`) prefers the live `onAnswerToolUse(toolUseId, content)` route (POSTs to `/api/runs/:id/tool-result`) and falls back to the legacy `onSubmitForm(text)` path when the run has already terminated. Selected chips persist across reloads by parsing the stored `tool_result.content` back into the selections shape.
|
||||
- Tool group rendering uses `dedupeSnapshotToolRetries` to collapse identical `AskUserQuestion` retries (one card per unique input, keeping the latest tool_use_id) and `TodoWrite` snapshots (only the most recent call, since each call is a state replace).
|
||||
|
||||
## Web CSS ownership
|
||||
|
||||
- `apps/web/src/index.css` is an import-only cascade entrypoint. Do not add selectors or declarations there; add imports only when a truly global stylesheet is needed, and keep import order intentional.
|
||||
- Shared global styles belong in `apps/web/src/styles/`: design tokens, base/reset rules, primitives, app-shell layout, and legacy cross-component selectors that cannot safely be scoped yet. Keep domain-level global files grouped by owner (for example `styles/viewer/` and `styles/workspace/`) instead of adding more large files directly under `styles/`.
|
||||
- New component-owned UI styles should default to CSS Modules next to the component (`Component.module.css`) instead of expanding global stylesheets. This is preferred for isolated components, panels, menus, drawers, toolbars, cards, and form sections.
|
||||
- When touching an existing component with nearby global styles, prefer migrating that component's local selectors to a CSS Module as part of the change if it is small and testable. Do not mix a large mechanical move with behavior/styling changes in the same patch.
|
||||
- Keep global class names only for deliberate shared contracts: reusable primitives, theme hooks, third-party/content styling, cross-component layout, or selectors that rely on global cascade/specificity. Document any new global selector group with its owning feature.
|
||||
- CSS refactors must preserve cascade semantics. For mechanical splits, verify expanded import content/order matches the previous stylesheet; for CSS Module migrations, validate the affected UI path with `pnpm --filter @open-design/web typecheck` and a focused build/test or visual check when practical.
|
||||
|
||||
## i18n keys
|
||||
|
||||
- `apps/web/src/i18n/types.ts` is the typed `Dict`; every key must be defined in all 18 locale files under `apps/web/src/i18n/locales/*.ts` (`ar`, `de`, `en`, `es-ES`, `fa`, `fr`, `hu`, `id`, `ja`, `ko`, `pl`, `pt-BR`, `ru`, `th`, `tr`, `uk`, `zh-CN`, `zh-TW`). Add the key to `types.ts` first; missing translations produce a typecheck error.
|
||||
|
|
@ -164,7 +167,7 @@ For the full tag dictionary, operational playbook (direct merge / duplicate-titl
|
|||
## Validation strategy
|
||||
|
||||
- After package, workspace, or command-entry changes, run `pnpm install` so workspace links and generated dist entries stay fresh.
|
||||
- Treat every `pnpm-lock.yaml` change as requiring a Nix pnpm deps hash refresh check. Use `pnpm nix:update-hash` to regenerate `nix/pnpm-deps.nix`, then re-run `nix flake check --print-build-logs --keep-going` (or rely on the PR `Validate workspace` gate if Nix is unavailable locally).
|
||||
- Treat every `pnpm-lock.yaml` change as requiring a Nix pnpm deps hash refresh check. `nix/pnpm-deps.nix` is a generated lock artifact; use `pnpm nix:update-hash` only when intentionally maintaining Nix packaging, then re-run `nix flake check --print-build-logs --keep-going`. Contributors without Nix can rely on the PR `Validate workspace` gate, which now uploads or auto-applies the generated hash-only fix when possible.
|
||||
- Before marking regular work ready, run at least `pnpm guard` and `pnpm typecheck`, plus the package-scoped tests/builds that match the files changed. Do not use or add root `pnpm test`/`pnpm build` aliases.
|
||||
- For local web runtime loops, prefer `pnpm tools-dev run web --daemon-port <port> --web-port <port>`.
|
||||
- On a GUI-capable machine, validate desktop by running `pnpm tools-dev`, then `pnpm tools-dev inspect desktop status`.
|
||||
|
|
@ -207,14 +210,6 @@ pnpm guard
|
|||
pnpm typecheck
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm tools-pr list
|
||||
pnpm tools-pr list --bucket=merge-ready,approved-blocked
|
||||
pnpm tools-pr list --lane=skill,contract --json
|
||||
pnpm tools-pr view 1180
|
||||
pnpm tools-pr view 1180 --json
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm --filter @open-design/web typecheck
|
||||
pnpm --filter @open-design/web test
|
||||
|
|
@ -224,7 +219,6 @@ pnpm --filter @open-design/daemon build
|
|||
pnpm --filter @open-design/desktop build
|
||||
pnpm --filter @open-design/tools-dev build
|
||||
pnpm --filter @open-design/tools-pack build
|
||||
pnpm --filter @open-design/tools-pr build
|
||||
pnpm --filter @open-design/tools-serve build
|
||||
```
|
||||
|
||||
|
|
|
|||
344
CHANGELOG.md
344
CHANGELOG.md
|
|
@ -7,73 +7,177 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.0] - 2026-05-20
|
||||
|
||||
The rebuilt-core release: **everything is a plugin**, **headless by default**, **plugins create plugins**. Open Design's research-preview architecture has been replaced with a small, boring engine plus a plugin surface — design systems, slices, prototypes, exports, and Figma itself all live in plugins now. The desktop app is a thin wrapper around the OD CLI, so the same engine runs in Claude Code, OpenClaw, Hermes Agent, and chat bots in Lark / Discord / Slack. **Critique Theater** matures through **Phase 16** (rollout ratchet, conformance API, 9 Prometheus metrics, Grafana dashboard, M0 dark-launch by default). **149 design systems** now ship with structured `tokens.css` + components manifests across 60+ new brand fixtures. **Italian (it) locale** + **CJK font fallback**. New media providers: **Leonardo.ai**, **ElevenLabs**, **SenseAudio**. **Packaged auto-update** lands on both **macOS and Windows**, battle-hardened through the preview cycle. Plus a **top-to-bottom visual refresh**, **Quick-brief discovery overhaul**, **PostHog v2 analytics schema**, **manual edit UX overhaul** (focus mode, uploads, remove-element patch), **custom CLI agent profiles**, and **HTML Anything** landing page. 305 merged PRs by 75 contributors since 0.7.0.
|
||||
|
||||
### Added
|
||||
|
||||
- Critique Theater Settings toggle with i18n across 6 locales. ([#1484])
|
||||
- Custom select web component primitive. ([#1714])
|
||||
- AskUserQuestion tool wired through chat with TodoWrite pinning. ([#1743])
|
||||
- Structured tokens for Notion, Linear, GitHub design systems. ([#1825])
|
||||
- Structured tokens for Cursor, Apple, Stripe design systems. ([#1831])
|
||||
- OpenAI-compatible media providers in daemon. ([#1712])
|
||||
- Leonardo.ai image generation provider. ([#1123])
|
||||
- Italian (`it`) locale support. ([#1323])
|
||||
#### Plugin engine, registry & publishing
|
||||
- **Plugin engine rebuild** with `packages/plugin-runtime`, `packages/registry-protocol`, and `packages/host` — the engine surfaces the plugin lifecycle through a small, neutral API so design systems, slices, prototypes, exports, and even Figma itself can live as plugins.
|
||||
- **Plugin registry detail drawer** with trust badges and marketplace metadata. ([#2087])
|
||||
- **GitHub rate-limit fallback for marketplace plugins** keeps install / refresh flows reliable when GitHub API is throttled. ([#2064])
|
||||
- **Plugin Publish-repo flow creates the author's repo correctly.** ([#2332], [#2363])
|
||||
- **CLI plugin publish reads manifest version** when the stored row is the `0.0.0` sentinel. ([#1903])
|
||||
- **Block raw publish CLIs from the authoring summary** — keep agents on the OD publish path. ([#2380])
|
||||
- **Demote Plugins + Integrations to the nav rail footer** so primary surface stays focused. ([#1806], [#2360], [#2397])
|
||||
|
||||
#### Critique Theater (Phases 9 – 16)
|
||||
- **Phase 9** — drop-in mount wrapper, native i18n for `de` / `ja` / `ko` / `zh-TW`. ([#1315])
|
||||
- **Phase 10** — daemon adapter conformance lab + degraded registry. ([#1316])
|
||||
- **Phase 11** — Playwright stage suite (happy path, interrupt, 3 viewports, a11y). ([#1317], [#1483])
|
||||
- **Phase 12** — 9 Prometheus metrics + 6 log events + OTel span + Grafana dashboard. ([#1485])
|
||||
- **Phase 13** — reducer p99 benchmark + surface coverage walker. ([#1318])
|
||||
- **Phase 15** — rollout resolver + Settings toggle hook. ([#1320])
|
||||
- **Phase 16** — M-phase rollout ratchet + `/api/critique/conformance`. ([#1499])
|
||||
- **Wireup with M0 dark-launch by default.** ([#1338])
|
||||
- **Settings toggle** with dedicated section + i18n keys across 6 locales. ([#1484])
|
||||
|
||||
#### Design systems & tokens
|
||||
- **Token channel default-on (PR-D)** so the new fixture pipeline is the default surface. ([#1544])
|
||||
- **Structured `tokens.css` for 60+ new brands** across AI, devtool, SaaS, fintech, docs, consumer, hardware, cultural categories (Apple, Stripe, Airbnb, Vercel, Notion, Linear, GitHub, Figma, Slack, Discord, OpenAI, Shopify, Spotify, Uber, Cursor, and many more). ([#1652], [#1794], [#1841], [#2023], [#2028], [#2029], [#2033])
|
||||
- **Token fixture catalog** — 20 brand + 20 product + remaining style fixtures, component-fixture coverage report. ([#2037], [#2040], [#2043], [#2049])
|
||||
- **Component manifests** — extract + consume manifests for design systems. ([#2051])
|
||||
- **Import design-system projects** via the discovery flow.
|
||||
- **Perplexity design system.** ([#1747])
|
||||
|
||||
#### Agents, providers & media
|
||||
- **Local custom CLI agent profiles** for arbitrary CLI agents. ([#378])
|
||||
- **Leonardo.ai image provider.** ([#1123])
|
||||
- **ElevenLabs audio support.** ([#1384])
|
||||
- **SenseAudio TTS provider** + BYOK chat with image / video generation tools. ([#1633], [#2065])
|
||||
- **User-configurable model alias for the media dispatcher.** ([#1277])
|
||||
- **Cursor Agent live model id parsing** + auth diagnostics. ([#1538], [#2228])
|
||||
|
||||
#### Web UI
|
||||
- **Manual edit UX overhaul** — focus mode, inline uploads, remove-element patch. ([#1516])
|
||||
- **Manual edit inspector.** ([#1448])
|
||||
- **Tweaks toolbar bound to the artifact panel** (toggle visibility from the panel chrome).
|
||||
- **Custom select primitive** for cleaner dropdowns.
|
||||
- **Collapsible comment side panel.**
|
||||
- **Export as image** in the share menu.
|
||||
- **Render GFM tables in markdown artifacts and chat.**
|
||||
- **Surface saved Project instructions** for review and retrieval.
|
||||
- **Copy-to-clipboard for user messages.**
|
||||
- **Filter-by-kind dropdown** on the design-files viewer.
|
||||
|
||||
#### Discovery & onboarding
|
||||
- **Quick-brief: collapse freeform clarification into a single form.** ([#2226])
|
||||
- **Plugin inputs as authoritative Quick-brief answers.** ([#2243])
|
||||
- **Stabilize discovery brand answers** in prompts. ([#1861])
|
||||
- **Daemon surfaces discovery form answers to agents.** ([#2071])
|
||||
|
||||
#### Desktop & packaging
|
||||
- **Packaged auto-update for both macOS and Windows.** ([#2362], [#2270], [#2403])
|
||||
- **Updater hardening** through the preview cycle — release validation, deferred installer on Windows, applied-state clearing, download / install handoff hardening, smoke-recovery. ([#2565], [#2575], [#2592], [#2595], [#2677], [#2687], [#2700])
|
||||
- **Desktop updater UI flow** — new in-app updater popup.
|
||||
- **Packaged update apply observations** captured for telemetry / debugging. ([#2429])
|
||||
- **Nightly + preview package identity** so beta installs don't collide with stable. ([#2437])
|
||||
- **macOS Dock icon stays put** when desktop-pet window opens. ([#2413])
|
||||
- **Refresh Open Design app visuals** — new app icons, logo, brand glyphs. ([#2436])
|
||||
- **Linux packaged client parity smoke coverage.**
|
||||
- **Ensure node binary dir is on PATH for agent sub-processes on Windows.** ([#1989])
|
||||
|
||||
#### Internationalization
|
||||
- **Italian (it) locale** — full UI translation, brings supported languages to 19. ([#1323])
|
||||
- **CJK font fallback** for Chinese / Japanese / Korean. ([#2227])
|
||||
- **Refresh + polish French UI locale.**
|
||||
- **Translate template platform selection + Companion surfaces to Chinese.** ([#1491])
|
||||
- **Localize accent controls in settings**, comment-panel strings ([#1390], [#1392]), and skill validation messages.
|
||||
|
||||
#### Analytics, observability & infra
|
||||
- **PostHog v2 event schema.** ([#2285])
|
||||
- **Unify `page_name` + onboarding / design-system page_views.** ([#2390])
|
||||
- **Upgrade `posthog-node` 4 → 5 in the daemon.** ([#2309])
|
||||
- **One-click log export from Settings → About.**
|
||||
|
||||
#### Templates, landing & tutorials
|
||||
- **HTML Anything page + responsive landing header.** ([#2452])
|
||||
- **Rebuild `/templates` catalog from `design-templates`.** ([#2369])
|
||||
- **Refresh templates + add tutorials channel** on the landing site. ([#2409])
|
||||
- **Blog routes** on the landing site.
|
||||
- **Search Console reporting workflows** + GSC report opportunities. ([#2388])
|
||||
- **WeRead year-in-review HyperFrames template.**
|
||||
|
||||
### Changed
|
||||
|
||||
- Packaged client lazy-loads Electron to enable headless config imports. ([#1798])
|
||||
- Claude design import canvas no longer zooms on scroll. ([#1726])
|
||||
- **Critique Theater dark-launched at M0 by default**, gated through the new rollout ratchet so phases can be promoted independently.
|
||||
- **Plugin trust badges unified** across registry surfaces.
|
||||
- Plugins and Integrations moved to the nav rail footer ([#1806], [#2360], [#2397]) — keep primary surface focused.
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Web / UI
|
||||
- Agent model select duplicate chevrons on macOS (wrapper+icon pattern). ([#1831])
|
||||
- Memory editor reveal after edit click. ([#1827])
|
||||
- Memory preview action distinct from delete. ([#1813], follow-up [#1863])
|
||||
- Settings subtab-pill hover contrast in dark theme. ([#1815])
|
||||
- Filter pill hover label readability across themes. ([#1828])
|
||||
- Comment marker numbering in panels. ([#1826])
|
||||
- Draw overlay scroll interaction. ([#1848])
|
||||
- Plugin publish footer spacing. ([#1849])
|
||||
- Picker hint clarity relative to comments panel. ([#1820])
|
||||
- Draw ink clears when exiting draw mode. ([#1821])
|
||||
- Chrome action icon alignment. ([#1783])
|
||||
- Manual folder import error feedback. ([#1666])
|
||||
- Template toolbar stickiness during scroll. ([#1785])
|
||||
- Comment panel string localization. ([#1443])
|
||||
- Resolved comments hidden from preview overlays. ([#1762])
|
||||
- HTML preview sandbox fallback to `srcDoc`. ([#1754])
|
||||
- BYOK chat inlines attached file context. ([#1730])
|
||||
#### Web
|
||||
- Block pitch-deck placeholder publishes and unbreak framework decks.
|
||||
- Rename FileViewer "Share" button to "Export".
|
||||
- Confirm before deleting a saved template in New Project.
|
||||
- Restore consistent app header layout on the entry view. ([#1519])
|
||||
- Refine preview and project dropdown controls. ([#1514])
|
||||
- Pin chat during content growth.
|
||||
- Auto-scroll feedback form.
|
||||
- Routines history rows deep-link to their specific conversation. (Fixes [#1505])
|
||||
- Hide resolved comments from preview overlays.
|
||||
- Keep filter pill hover labels readable.
|
||||
- Improve replace-modal button hover contrast.
|
||||
- Freeze completed run durations across conversations.
|
||||
- Align Home prompt overlay with textarea so caret lands on click.
|
||||
- Restore release-light background. ([#1540])
|
||||
- Allow downloads from preview iframes; fall back to srcDoc when HTML preview needs sandbox shim.
|
||||
- Coalesce chokidar rewrite bursts before refreshing files.
|
||||
- Reveal memory editor after edit click; distinguish expanded memory preview action.
|
||||
- Auto-annotate imported HTML elements for Tweaks selection. ([#892])
|
||||
- Stable shared frame screen paths from referrer.
|
||||
- Restore custom dropdown chevron for timezone selector in dark mode.
|
||||
- Daemon run recovery across reloads. ([#2374])
|
||||
|
||||
#### Desktop
|
||||
- "Export PDF" now opens a direct "Save as PDF" file dialog and writes the PDF to disk, instead of opening the macOS system print dialog. Fixes [#1774](https://github.com/nexu-io/open-design/issues/1774).
|
||||
#### Desktop & packaging
|
||||
- macOS Dock icon stays put when desktop-pet window opens. ([#2413])
|
||||
- Align Windows smoke update root with portable installs. ([#2376])
|
||||
- Nightly release smoke identity. ([#2446])
|
||||
- Improve desktop updater ready UI. ([#2403])
|
||||
- Forward proxy env vars to packaged sidecars.
|
||||
- Detect mise-installed npm package bins.
|
||||
- Launch Windows updater fixture via Node. ([#2364])
|
||||
- Desktop "Export PDF" opens a direct "Save as PDF" file dialog and writes the PDF to disk, instead of opening the macOS system print dialog. (Fixes [#1774])
|
||||
- macOS close exits fullscreen before hiding.
|
||||
- Daemon's external-browser opener fixed on Windows.
|
||||
|
||||
#### Daemon
|
||||
- Claude connection smoke wraps stdin properly. ([#1844])
|
||||
- BYOK proxy honors IP-literal `OD_ALLOWED_ORIGINS` in no-Origin Host check. ([#1775])
|
||||
- ACP stage timeout aligned to outer chat inactivity window. ([#1743])
|
||||
#### Daemon, runtime & connectivity
|
||||
- Surface discovery form answers to agents. ([#2071])
|
||||
- Stabilize discovery brand answers in prompts. ([#1861])
|
||||
- ACP model detection timeout is configurable.
|
||||
- Wrap Claude smoke test stdin as stream-json.
|
||||
- Preserve Claude tool inputs. ([#1476])
|
||||
- Codex CLI path fallback UX. ([#1205])
|
||||
- Treat Codex reconnect events as warnings, not fatal errors. ([#1482])
|
||||
- ACP config options used for model selection. ([#1208])
|
||||
- Remove OpenCode stdin dash sentinel; soft empty API response handling.
|
||||
- Forward external MCP servers to OpenCode.
|
||||
|
||||
#### Documentation
|
||||
- Korean README desktop/background startup paragraph. ([#1876])
|
||||
- Windows troubleshooting link synced across 12 locale READMEs. ([#1875])
|
||||
- 0.8.0-preview banner pointing to Discussion [#1727]. ([#1781])
|
||||
- Clarify that packaged macOS support includes a verified Intel x64 ZIP path on Monterey, and document the Finder `PATH` caveat for packaged CLI detection. Fixes [#327](https://github.com/nexu-io/open-design/issues/327).
|
||||
### Documentation
|
||||
|
||||
#### Packaging
|
||||
- Nix flake `pnpmDepsHash` refresh after merging main. ([#1765])
|
||||
- Critique Theater Phase 14 user guide + 2 AGENTS module maps. ([#1319])
|
||||
- Windows native setup notes in `AGENTS.md`.
|
||||
- Comprehensive contributor guide in `TRANSLATIONS.md`.
|
||||
- RTL_LOCALES UI guidance + `es-ES` alignment.
|
||||
- Sync `zh-TW` README with the English version.
|
||||
- Sync Windows troubleshooting link across locale READMEs.
|
||||
- Refresh contributors wall + GitHub metrics SVG.
|
||||
- Clarify Intel Mac ZIP packaging support (includes the Monterey verified path and the Finder `PATH` caveat for packaged CLI detection). (Fixes [#327])
|
||||
- README inventory badges sync — skills 31 → 131, design-systems 72 → 149. ([#1899])
|
||||
- 0.8.0-preview banner + Discussion #1727 pointer. ([#1781])
|
||||
- Active 0.8.0 contributors point at `main`. ([#1846])
|
||||
|
||||
### Security
|
||||
### Internal
|
||||
|
||||
- DNS-rebinding SSRF prevented by resolving hostname before approving external API base URLs. ([#1176])
|
||||
- Tightened `LiveArtifactSsePayload.refreshStatus` to the canonical `LiveArtifactRefreshStatus` enum, preventing future REST↔SSE type drift. ([#1871])
|
||||
- `nix-check.yml` workflow scoped to `permissions: contents: read`, matching the rest of the workflow suite. ([#1870])
|
||||
|
||||
### Internal / Tests
|
||||
|
||||
- Italian locale cleanup (`onImportFolder` signature + stale pet rail keys). ([#1814])
|
||||
- Linux packaged client parity smoke coverage. ([#1204])
|
||||
- Metrics PRs trigger required checks. ([#1801])
|
||||
- Packaged-linux runtime logs captured into headless artifact. ([#1823])
|
||||
- Memory preview icon assertion decoupled for test stability. ([#1863])
|
||||
- Critique Theater Playwright stage suite (happy, interrupt, 3 viewports, a11y). ([#1317], [#1483])
|
||||
- Reducer p99 bench + surface coverage walker. ([#1318])
|
||||
- Harden e2e extended coverage state assertions. ([#2245])
|
||||
- Visual regression PR workflow (CI).
|
||||
- Component manifest extraction + daemon consume path. ([#2051])
|
||||
- OD CLI wraps GitHub CLI (so plugins create plugins).
|
||||
- `pnpm i18n:coverage` informational report.
|
||||
- Issue templates: bug, feature, preview/v0.8.0 + chooser config. ([#1708])
|
||||
|
||||
## [0.7.0] - 2026-05-12
|
||||
|
||||
|
|
@ -1006,7 +1110,8 @@ First public release of Open Design — a local-first, open-source alternative t
|
|||
- Beta release workflow placeholder. ([#36])
|
||||
- Git commit co-author policy. ([#131])
|
||||
|
||||
[Unreleased]: https://github.com/nexu-io/open-design/compare/open-design-v0.7.0...HEAD
|
||||
[Unreleased]: https://github.com/nexu-io/open-design/compare/open-design-v0.8.0...HEAD
|
||||
[0.8.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.8.0
|
||||
[0.7.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.7.0
|
||||
[0.6.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.6.0
|
||||
[0.5.0]: https://github.com/nexu-io/open-design/releases/tag/open-design-v0.5.0
|
||||
|
|
@ -1515,7 +1620,6 @@ First public release of Open Design — a local-first, open-source alternative t
|
|||
[#1105]: https://github.com/nexu-io/open-design/pull/1105
|
||||
[#1115]: https://github.com/nexu-io/open-design/pull/1115
|
||||
[#1117]: https://github.com/nexu-io/open-design/pull/1117
|
||||
[#1123]: https://github.com/nexu-io/open-design/pull/1123
|
||||
[#1126]: https://github.com/nexu-io/open-design/pull/1126
|
||||
[#1128]: https://github.com/nexu-io/open-design/pull/1128
|
||||
[#1132]: https://github.com/nexu-io/open-design/pull/1132
|
||||
|
|
@ -1529,13 +1633,11 @@ First public release of Open Design — a local-first, open-source alternative t
|
|||
[#1150]: https://github.com/nexu-io/open-design/pull/1150
|
||||
[#1156]: https://github.com/nexu-io/open-design/pull/1156
|
||||
[#1159]: https://github.com/nexu-io/open-design/pull/1159
|
||||
[#1176]: https://github.com/nexu-io/open-design/pull/1176
|
||||
[#1171]: https://github.com/nexu-io/open-design/pull/1171
|
||||
[#1173]: https://github.com/nexu-io/open-design/pull/1173
|
||||
[#1183]: https://github.com/nexu-io/open-design/pull/1183
|
||||
[#1188]: https://github.com/nexu-io/open-design/pull/1188
|
||||
[#1203]: https://github.com/nexu-io/open-design/pull/1203
|
||||
[#1204]: https://github.com/nexu-io/open-design/pull/1204
|
||||
[#1206]: https://github.com/nexu-io/open-design/pull/1206
|
||||
[#1205]: https://github.com/nexu-io/open-design/pull/1205
|
||||
[#1207]: https://github.com/nexu-io/open-design/pull/1207
|
||||
|
|
@ -1563,7 +1665,6 @@ First public release of Open Design — a local-first, open-source alternative t
|
|||
[#1300]: https://github.com/nexu-io/open-design/pull/1300
|
||||
[#1307]: https://github.com/nexu-io/open-design/pull/1307
|
||||
[#1308]: https://github.com/nexu-io/open-design/pull/1308
|
||||
[#1323]: https://github.com/nexu-io/open-design/pull/1323
|
||||
[#1328]: https://github.com/nexu-io/open-design/pull/1328
|
||||
[#1330]: https://github.com/nexu-io/open-design/pull/1330
|
||||
[#1161]: https://github.com/nexu-io/open-design/pull/1161
|
||||
|
|
@ -1579,40 +1680,97 @@ First public release of Open Design — a local-first, open-source alternative t
|
|||
[#1402]: https://github.com/nexu-io/open-design/pull/1402
|
||||
[#1439]: https://github.com/nexu-io/open-design/pull/1439
|
||||
[#1442]: https://github.com/nexu-io/open-design/pull/1442
|
||||
[#1443]: https://github.com/nexu-io/open-design/pull/1443
|
||||
[#327]: https://github.com/nexu-io/open-design/issues/327
|
||||
[#378]: https://github.com/nexu-io/open-design/pull/378
|
||||
[#892]: https://github.com/nexu-io/open-design/pull/892
|
||||
[#1123]: https://github.com/nexu-io/open-design/pull/1123
|
||||
[#1277]: https://github.com/nexu-io/open-design/pull/1277
|
||||
[#1315]: https://github.com/nexu-io/open-design/pull/1315
|
||||
[#1316]: https://github.com/nexu-io/open-design/pull/1316
|
||||
[#1317]: https://github.com/nexu-io/open-design/pull/1317
|
||||
[#1318]: https://github.com/nexu-io/open-design/pull/1318
|
||||
[#1319]: https://github.com/nexu-io/open-design/pull/1319
|
||||
[#1320]: https://github.com/nexu-io/open-design/pull/1320
|
||||
[#1323]: https://github.com/nexu-io/open-design/pull/1323
|
||||
[#1338]: https://github.com/nexu-io/open-design/pull/1338
|
||||
[#1384]: https://github.com/nexu-io/open-design/pull/1384
|
||||
[#1390]: https://github.com/nexu-io/open-design/pull/1390
|
||||
[#1392]: https://github.com/nexu-io/open-design/pull/1392
|
||||
[#1448]: https://github.com/nexu-io/open-design/pull/1448
|
||||
[#1476]: https://github.com/nexu-io/open-design/pull/1476
|
||||
[#1482]: https://github.com/nexu-io/open-design/pull/1482
|
||||
[#1483]: https://github.com/nexu-io/open-design/pull/1483
|
||||
[#1484]: https://github.com/nexu-io/open-design/pull/1484
|
||||
[#1666]: https://github.com/nexu-io/open-design/pull/1666
|
||||
[#1712]: https://github.com/nexu-io/open-design/pull/1712
|
||||
[#1714]: https://github.com/nexu-io/open-design/pull/1714
|
||||
[#1726]: https://github.com/nexu-io/open-design/pull/1726
|
||||
[#1727]: https://github.com/nexu-io/open-design/discussions/1727
|
||||
[#1730]: https://github.com/nexu-io/open-design/pull/1730
|
||||
[#1743]: https://github.com/nexu-io/open-design/pull/1743
|
||||
[#1754]: https://github.com/nexu-io/open-design/pull/1754
|
||||
[#1762]: https://github.com/nexu-io/open-design/pull/1762
|
||||
[#1765]: https://github.com/nexu-io/open-design/pull/1765
|
||||
[#1775]: https://github.com/nexu-io/open-design/pull/1775
|
||||
[#1485]: https://github.com/nexu-io/open-design/pull/1485
|
||||
[#1491]: https://github.com/nexu-io/open-design/pull/1491
|
||||
[#1499]: https://github.com/nexu-io/open-design/pull/1499
|
||||
[#1505]: https://github.com/nexu-io/open-design/issues/1505
|
||||
[#1514]: https://github.com/nexu-io/open-design/pull/1514
|
||||
[#1516]: https://github.com/nexu-io/open-design/pull/1516
|
||||
[#1519]: https://github.com/nexu-io/open-design/pull/1519
|
||||
[#1538]: https://github.com/nexu-io/open-design/pull/1538
|
||||
[#1540]: https://github.com/nexu-io/open-design/pull/1540
|
||||
[#1544]: https://github.com/nexu-io/open-design/pull/1544
|
||||
[#1633]: https://github.com/nexu-io/open-design/pull/1633
|
||||
[#1652]: https://github.com/nexu-io/open-design/pull/1652
|
||||
[#1708]: https://github.com/nexu-io/open-design/pull/1708
|
||||
[#1747]: https://github.com/nexu-io/open-design/pull/1747
|
||||
[#1774]: https://github.com/nexu-io/open-design/issues/1774
|
||||
[#1781]: https://github.com/nexu-io/open-design/pull/1781
|
||||
[#1783]: https://github.com/nexu-io/open-design/pull/1783
|
||||
[#1785]: https://github.com/nexu-io/open-design/pull/1785
|
||||
[#1798]: https://github.com/nexu-io/open-design/pull/1798
|
||||
[#1801]: https://github.com/nexu-io/open-design/pull/1801
|
||||
[#1813]: https://github.com/nexu-io/open-design/pull/1813
|
||||
[#1814]: https://github.com/nexu-io/open-design/pull/1814
|
||||
[#1815]: https://github.com/nexu-io/open-design/pull/1815
|
||||
[#1820]: https://github.com/nexu-io/open-design/pull/1820
|
||||
[#1821]: https://github.com/nexu-io/open-design/pull/1821
|
||||
[#1823]: https://github.com/nexu-io/open-design/pull/1823
|
||||
[#1825]: https://github.com/nexu-io/open-design/pull/1825
|
||||
[#1826]: https://github.com/nexu-io/open-design/pull/1826
|
||||
[#1827]: https://github.com/nexu-io/open-design/pull/1827
|
||||
[#1828]: https://github.com/nexu-io/open-design/pull/1828
|
||||
[#1831]: https://github.com/nexu-io/open-design/pull/1831
|
||||
[#1844]: https://github.com/nexu-io/open-design/pull/1844
|
||||
[#1848]: https://github.com/nexu-io/open-design/pull/1848
|
||||
[#1849]: https://github.com/nexu-io/open-design/pull/1849
|
||||
[#1863]: https://github.com/nexu-io/open-design/pull/1863
|
||||
[#1870]: https://github.com/nexu-io/open-design/pull/1870
|
||||
[#1871]: https://github.com/nexu-io/open-design/pull/1871
|
||||
[#1875]: https://github.com/nexu-io/open-design/pull/1875
|
||||
[#1876]: https://github.com/nexu-io/open-design/pull/1876
|
||||
[#1794]: https://github.com/nexu-io/open-design/pull/1794
|
||||
[#1806]: https://github.com/nexu-io/open-design/pull/1806
|
||||
[#1841]: https://github.com/nexu-io/open-design/pull/1841
|
||||
[#1846]: https://github.com/nexu-io/open-design/pull/1846
|
||||
[#1861]: https://github.com/nexu-io/open-design/pull/1861
|
||||
[#1899]: https://github.com/nexu-io/open-design/pull/1899
|
||||
[#1903]: https://github.com/nexu-io/open-design/pull/1903
|
||||
[#1989]: https://github.com/nexu-io/open-design/pull/1989
|
||||
[#2023]: https://github.com/nexu-io/open-design/pull/2023
|
||||
[#2028]: https://github.com/nexu-io/open-design/pull/2028
|
||||
[#2029]: https://github.com/nexu-io/open-design/pull/2029
|
||||
[#2033]: https://github.com/nexu-io/open-design/pull/2033
|
||||
[#2037]: https://github.com/nexu-io/open-design/pull/2037
|
||||
[#2040]: https://github.com/nexu-io/open-design/pull/2040
|
||||
[#2043]: https://github.com/nexu-io/open-design/pull/2043
|
||||
[#2049]: https://github.com/nexu-io/open-design/pull/2049
|
||||
[#2051]: https://github.com/nexu-io/open-design/pull/2051
|
||||
[#2064]: https://github.com/nexu-io/open-design/pull/2064
|
||||
[#2065]: https://github.com/nexu-io/open-design/pull/2065
|
||||
[#2071]: https://github.com/nexu-io/open-design/pull/2071
|
||||
[#2087]: https://github.com/nexu-io/open-design/pull/2087
|
||||
[#2226]: https://github.com/nexu-io/open-design/pull/2226
|
||||
[#2227]: https://github.com/nexu-io/open-design/pull/2227
|
||||
[#2228]: https://github.com/nexu-io/open-design/pull/2228
|
||||
[#2243]: https://github.com/nexu-io/open-design/pull/2243
|
||||
[#2245]: https://github.com/nexu-io/open-design/pull/2245
|
||||
[#2264]: https://github.com/nexu-io/open-design/pull/2264
|
||||
[#2270]: https://github.com/nexu-io/open-design/pull/2270
|
||||
[#2285]: https://github.com/nexu-io/open-design/pull/2285
|
||||
[#2309]: https://github.com/nexu-io/open-design/pull/2309
|
||||
[#2332]: https://github.com/nexu-io/open-design/pull/2332
|
||||
[#2360]: https://github.com/nexu-io/open-design/pull/2360
|
||||
[#2362]: https://github.com/nexu-io/open-design/pull/2362
|
||||
[#2363]: https://github.com/nexu-io/open-design/pull/2363
|
||||
[#2364]: https://github.com/nexu-io/open-design/pull/2364
|
||||
[#2369]: https://github.com/nexu-io/open-design/pull/2369
|
||||
[#2374]: https://github.com/nexu-io/open-design/pull/2374
|
||||
[#2376]: https://github.com/nexu-io/open-design/pull/2376
|
||||
[#2380]: https://github.com/nexu-io/open-design/pull/2380
|
||||
[#2388]: https://github.com/nexu-io/open-design/pull/2388
|
||||
[#2390]: https://github.com/nexu-io/open-design/pull/2390
|
||||
[#2397]: https://github.com/nexu-io/open-design/pull/2397
|
||||
[#2403]: https://github.com/nexu-io/open-design/pull/2403
|
||||
[#2409]: https://github.com/nexu-io/open-design/pull/2409
|
||||
[#2413]: https://github.com/nexu-io/open-design/pull/2413
|
||||
[#2429]: https://github.com/nexu-io/open-design/pull/2429
|
||||
[#2436]: https://github.com/nexu-io/open-design/pull/2436
|
||||
[#2437]: https://github.com/nexu-io/open-design/pull/2437
|
||||
[#2446]: https://github.com/nexu-io/open-design/pull/2446
|
||||
[#2452]: https://github.com/nexu-io/open-design/pull/2452
|
||||
[#2565]: https://github.com/nexu-io/open-design/pull/2565
|
||||
[#2575]: https://github.com/nexu-io/open-design/pull/2575
|
||||
[#2592]: https://github.com/nexu-io/open-design/pull/2592
|
||||
[#2595]: https://github.com/nexu-io/open-design/pull/2595
|
||||
[#2677]: https://github.com/nexu-io/open-design/pull/2677
|
||||
[#2687]: https://github.com/nexu-io/open-design/pull/2687
|
||||
[#2700]: https://github.com/nexu-io/open-design/pull/2700
|
||||
|
|
|
|||
34
CONTEXT.md
34
CONTEXT.md
|
|
@ -52,6 +52,34 @@ _Avoid_: generic subject field, hidden prompt text
|
|||
The default voice option shown when the Home Audio composer cannot load configured ElevenLabs voices. It keeps ElevenLabs speech runnable by selecting the same default voice id the daemon uses when no explicit voice is supplied.
|
||||
_Avoid_: required credential setup, empty voice selector
|
||||
|
||||
**AMR Cloud**:
|
||||
The user-facing cloud runtime option for Open Design's official model router, shown in onboarding and login-oriented product surfaces.
|
||||
_Avoid_: Vela, local CLI label
|
||||
|
||||
**AMR CLI**:
|
||||
The local command-line runtime adapter used to run AMR from an installed or packaged native CLI.
|
||||
_Avoid_: AMR Cloud, cloud account
|
||||
|
||||
**AMR CLI Distribution Contract**:
|
||||
The separately owned release contract that provides the native AMR CLI builds Open Design can package.
|
||||
_Avoid_: Open Design release channel, package build step, source checkout
|
||||
|
||||
**AMR CLI Distribution Slice**:
|
||||
The set of native AMR CLI platforms currently available through the distribution contract; platforms outside the slice do not bundle the AMR CLI.
|
||||
_Avoid_: Open Design supported platforms, release channel, future platform promise
|
||||
|
||||
**AMR Account Status**:
|
||||
Whether the user has authenticated the account needed to use AMR Cloud.
|
||||
_Avoid_: profile badge, environment, CLI version
|
||||
|
||||
**AMR Environment Profile**:
|
||||
The target AMR service environment a packaged runtime is configured to use.
|
||||
_Avoid_: release channel, account status, app identity
|
||||
|
||||
**Onboarding Skip**:
|
||||
The explicit path that lets a user leave onboarding without completing the currently selected setup option.
|
||||
_Avoid_: continue, finish setup, passive close
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Project** contains zero or more **Normal Artifacts**.
|
||||
|
|
@ -62,6 +90,12 @@ _Avoid_: required credential setup, empty voice selector
|
|||
- A **Home Composer Media Surface** maps user intent to an existing project kind and project metadata at submit time.
|
||||
- The **Chip Rail** is the visible Home entry point for choosing a **Home Composer Media Surface**.
|
||||
- **Essential Audio Generation** uses an **Audio Source Field** plus model options before creating an audio **Project**.
|
||||
- **AMR Cloud** is the user-facing product choice; **AMR CLI** is the local execution adapter behind that capability.
|
||||
- The **AMR CLI Distribution Contract** is owned separately from Open Design; Open Design release packaging consumes it instead of defining the native CLI release itself.
|
||||
- The first **AMR CLI Distribution Slice** is mac arm64 only.
|
||||
- **AMR Account Status** describes account readiness for **AMR Cloud**, not the environment profile or CLI installation state.
|
||||
- An **AMR Environment Profile** is independent from release channel identity; a beta, preview, nightly, or stable package can target different AMR service environments when explicitly configured.
|
||||
- **Onboarding Skip** bypasses setup completion requirements that belong to the normal onboarding continue path.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pnpm typecheck # tsc -b --noEmit
|
|||
pnpm --filter @open-design/web build # Web-Paket bei Bedarf bauen
|
||||
```
|
||||
|
||||
Node `~24` und pnpm `10.33.x` sind erforderlich. `nvm` / `fnm` sind optional; nutzen Sie `nvm install 24 && nvm use 24` oder `fnm install 24 && fnm use 24`, wenn Sie Node so verwalten. macOS, Linux und WSL2 sind die primären Pfade. Windows nativ sollte funktionieren, ist aber kein primäres Ziel.
|
||||
Node `~24` und pnpm `10.33.x` sind erforderlich. `nvm` / `fnm` sind optional; nutzen Sie `nvm install 24 && nvm use 24` oder `fnm install 24 && fnm use 24`, wenn Sie Node so verwalten. macOS, Linux und WSL2 sind die primären Pfade. Windows nativ wird unterstützt; siehe [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md) für die häufigsten Setup-Stolpersteine.
|
||||
|
||||
Sie brauchen keine Agent-CLI im `PATH`, um OD selbst zu entwickeln. Der daemon meldet dann "no agents found" und fällt auf den **Anthropic API · BYOK** Pfad zurück, der oft die schnellste Dev-Schleife ist.
|
||||
|
||||
|
|
@ -217,6 +217,7 @@ Außerdem:
|
|||
|
||||
- **Ein Anliegen pro PR.**
|
||||
- **Titel ist imperativ + Scope.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
|
||||
- **Nutzen Sie das PR-Template.** Füllen Sie jeden Abschnitt von [`.github/pull_request_template.md`](.github/pull_request_template.md) aus — Why, What users will see, Surface area, Screenshots (bei UI), Bug fix verification (bei Bugfix), Validation. Leere Abschnitte ergeben einen "please fill in"-Kommentar.
|
||||
- **Body erklärt das Warum.** Der Diff zeigt oft das Was, aber selten den Grund.
|
||||
- **Issue referenzieren**, falls vorhanden. Bei nicht-trivialen PRs ohne Issue bitte zuerst eines öffnen.
|
||||
- **Während Review nicht squashen.** Fixups pushen; wir squashen beim Merge.
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ Node `~24` et pnpm `10.33.x` sont requis. `nvm` / `fnm` sont optionnels ;
|
|||
utilisez `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` si
|
||||
vous gérez Node comme cela. macOS, Linux et WSL2 sont les environnements
|
||||
principaux pris en charge.
|
||||
Windows natif devrait fonctionner, mais ce n'est pas la cible principale :
|
||||
ouvrez une issue si ce n'est pas le cas.
|
||||
Windows natif est supporté ; voir [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)
|
||||
pour les pièges de configuration les plus courants.
|
||||
|
||||
Vous n'avez pas besoin d'une CLI d'agent dans votre `PATH` pour développer OD.
|
||||
Le daemon indiquera "no agents found" ; utilisez alors le mode API/BYOK
|
||||
|
|
@ -357,6 +357,11 @@ Au-delà de ça :
|
|||
dépendance : ce sont trois PR.
|
||||
- **Titre impératif + scope.** `add dating-web skill`,
|
||||
`fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
|
||||
- **Utilisez le template de PR.** Remplissez chaque section de
|
||||
[`.github/pull_request_template.md`](.github/pull_request_template.md) — Why,
|
||||
What users will see, Surface area, Screenshots (si UI), Bug fix verification
|
||||
(si correctif), Validation. Les sections vides recevront un commentaire
|
||||
« please fill in ».
|
||||
- **Le body explique le pourquoi.** Le diff montre souvent le quoi ; le pourquoi
|
||||
est rarement évident.
|
||||
- **Référencez une issue** s'il y en a une. S'il n'y en a pas et que la PR est
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pnpm typecheck # tsc -b --noEmit
|
|||
pnpm --filter @open-design/web build # 必要に応じて web パッケージをビルド
|
||||
```
|
||||
|
||||
Node `~24` と pnpm `10.33.x` が必要です。`nvm` / `fnm` はオプション。使用する場合は `nvm install 24 && nvm use 24` または `fnm install 24 && fnm use 24` を実行してください。macOS、Linux、WSL2 が主要プラットフォームです。Windows ネイティブでも動作するはずですが、主要ターゲットではありません — 動作しない場合は issue を作成してください。
|
||||
Node `~24` と pnpm `10.33.x` が必要です。`nvm` / `fnm` はオプション。使用する場合は `nvm install 24 && nvm use 24` または `fnm install 24 && fnm use 24` を実行してください。macOS、Linux、WSL2 が主要プラットフォームです。Windows ネイティブもサポートされています — 一般的なセットアップ時の落とし穴については [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md) を参照してください。
|
||||
|
||||
OD 自体の開発に agent CLI は `PATH` 上に不要です — daemon は「no agents found」と表示し、**Anthropic API · BYOK** パスにフォールバックします。このパスが最も高速な開発ループです。
|
||||
|
||||
|
|
@ -213,6 +213,7 @@ design-systems/your-brand/
|
|||
|
||||
- **PR 1 つにつき 1 つの関心事。** Skill の追加 + パーサーのリファクタリング + 依存関係のバンプは 3 つの PR です。
|
||||
- **タイトルは命令形 + スコープ。** `add dating-web skill`、`fix daemon SSE backpressure when CLI hangs`、`docs: clarify .od layout`。
|
||||
- **PR テンプレートを使用する。** [`.github/pull_request_template.md`](.github/pull_request_template.md) の各セクション(Why、What users will see、Surface area、Screenshots(UI の場合)、Bug fix verification(バグ修正の場合)、Validation)をすべて埋めてください。空欄のセクションには "please fill in" のコメントが付きます。
|
||||
- **本文は「なぜ」を説明。** 「何をするか」は通常 diff から明らかです。「なぜこれが必要か」はほとんどの場合そうではありません。
|
||||
- **issue がある場合は参照。** ない場合で、PR が自明でないなら、先に issue を作成して変更が求められていることを合意してから時間を費やしてください。
|
||||
- **レビュー中にスカッシュしない。** fixup をプッシュしてください。マージ時にスカッシュします。
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pnpm typecheck # tsc -b --noEmit
|
|||
pnpm --filter @open-design/web build # build do pacote web quando necessário
|
||||
```
|
||||
|
||||
Node `~24` e pnpm `10.33.x` são obrigatórios. `nvm` / `fnm` são opcionais; use `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` se preferir gerenciar Node assim. macOS, Linux e WSL2 são os caminhos principais. Windows nativo costuma funcionar, mas não é alvo principal — abra uma issue se quebrar.
|
||||
Node `~24` e pnpm `10.33.x` são obrigatórios. `nvm` / `fnm` são opcionais; use `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` se preferir gerenciar Node assim. macOS, Linux e WSL2 são os caminhos principais. Windows nativo é suportado; veja [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md) para os tropeços de configuração mais comuns.
|
||||
|
||||
Você não precisa de nenhum CLI de agente no `PATH` para desenvolver o próprio OD — o daemon dirá "no agents found" e cairá no caminho **Anthropic API · BYOK**, que é o loop de dev mais rápido de qualquer jeito.
|
||||
|
||||
|
|
@ -243,6 +243,7 @@ Além disso:
|
|||
|
||||
- **Uma preocupação por PR.** Adicionar uma skill + refatorar o parser + bumpar uma dep são três PRs.
|
||||
- **Título é imperativo + escopo.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`.
|
||||
- **Use o template de PR.** Preencha cada seção de [`.github/pull_request_template.md`](.github/pull_request_template.md) — Why, What users will see, Surface area, Screenshots (se UI), Bug fix verification (se correção de bug), Validation. Seções vazias ganham um comentário "please fill in".
|
||||
- **Corpo explica o porquê.** "O que isso faz" geralmente é óbvio do diff; "por que isso precisa existir" raramente é.
|
||||
- **Referencie uma issue** se houver. Se não houver e o PR for não-trivial, abra uma antes para combinarmos que a mudança é desejada antes de você gastar o tempo.
|
||||
- **Sem squash durante review.** Empurre fixups; squash no merge.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pnpm typecheck # tsc -b --noEmit
|
|||
pnpm --filter @open-design/web build # 需要时构建 web package
|
||||
```
|
||||
|
||||
要求 Node `~24` 和 pnpm `10.33.x`。`nvm` / `fnm` 是可选路径;如果你习惯用它们,先执行 `nvm install 24 && nvm use 24` 或 `fnm install 24 && fnm use 24`。macOS、Linux、WSL2 是主要路径。Windows 原生应该能跑但不是主要目标 —— 跑不起来请开 issue。
|
||||
要求 Node `~24` 和 pnpm `10.33.x`。`nvm` / `fnm` 是可选路径;如果你习惯用它们,先执行 `nvm install 24 && nvm use 24` 或 `fnm install 24 && fnm use 24`。macOS、Linux、WSL2 是主要路径。Windows 原生已支持;常见的安装与配置坑请参见 [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)。
|
||||
|
||||
**开发 OD 本身不需要在 `PATH` 上装任何 agent CLI** —— daemon 会告诉你「找不到 agent」并落到 **Anthropic API · BYOK** 路径,反而是最快的开发循环。
|
||||
|
||||
|
|
@ -234,6 +234,7 @@ node --experimental-strip-types scripts/sync-litellm-models.ts
|
|||
|
||||
- **一个 PR 只做一件事。** 加 skill + 重构 parser + 升依赖,是三个 PR。
|
||||
- **标题用动词起头 + 范围。** `add dating-web skill`、`fix daemon SSE backpressure when CLI hangs`、`docs: clarify .od layout`。
|
||||
- **使用 PR 模板。** 把 [`.github/pull_request_template.md`](.github/pull_request_template.md) 的每一节都填上 —— Why、What users will see、Surface area、Screenshots(如果有 UI 改动)、Bug fix verification(如果是修 bug)、Validation。留空的节会被 reviewer 回 "please fill in"。
|
||||
- **正文解释 why。** 「这个 PR 改了什么」从 diff 一般能看出来;「为什么要改」很少能。
|
||||
- **如果有 issue,引用它。** 没有、且改动非平凡,请先开 issue 让我们先就「值不值得做」达成一致,再投入时间。
|
||||
- **Review 期间不要 squash。** 推 fixup commit;merge 时我们会 squash。
|
||||
|
|
|
|||
|
|
@ -55,8 +55,24 @@ docker compose version
|
|||
|
||||
From the repository root:
|
||||
|
||||
1. Change to the deploy directory and copy the environment template:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Generate a secure token:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
3. Open `.env` in your editor, find `OD_API_TOKEN=`, and paste the generated token there.
|
||||
|
||||
Then start the service:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
|
|
@ -107,7 +123,13 @@ docker compose down -v
|
|||
|
||||
## Environment Configuration
|
||||
|
||||
Create a `deploy/.env` file to override the default configuration:
|
||||
Create a `deploy/.env` file to override the default configuration. Start from the provided example:
|
||||
|
||||
```bash
|
||||
cp deploy/.env.example deploy/.env
|
||||
```
|
||||
|
||||
Edit `deploy/.env` to set your own token and adjust other values as needed:
|
||||
|
||||
```env
|
||||
# Port exposed on the host
|
||||
|
|
@ -121,6 +143,10 @@ OPEN_DESIGN_ALLOWED_ORIGINS=https://yourdomain.com
|
|||
|
||||
# Docker image tag
|
||||
OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest
|
||||
|
||||
# Required API token for daemon security
|
||||
# Generate one with: openssl rand -hex 32
|
||||
OD_API_TOKEN=
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -800,7 +800,7 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
|
|||
شكراً لكلّ من ساعد في دفع Open Design للأمام — بكود، بوثائق، بملاحظات، بـ skills جديدة، بأنظمة تصميم جديدة، أو حتى بـ issue حادّة. كلّ مساهمة حقيقية تهمّ، والجدار أدناه أسهل طريقة لقول ذلك علناً.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول.
|
||||
|
|
@ -817,9 +817,9 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -726,7 +726,7 @@ Vollständiger Walkthrough, Merge-Messlatte, Code Style und was wir nicht annehm
|
|||
Danke an alle, die Open Design vorangebracht haben: durch Code, Docs, Feedback, neue Skills, neue Design Systems oder auch ein scharfes Issue. Jeder echte Beitrag zählt, und die Wand unten ist die einfachste Art, das laut zu sagen.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
Wenn Sie Ihren ersten PR gemergt haben: willkommen. Das Label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ist der Einstiegspunkt.
|
||||
|
|
@ -743,9 +743,9 @@ Das SVG oben wird täglich von [`.github/workflows/metrics.yml`](.github/workflo
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -787,7 +787,7 @@ Walkthrough completo, estándar de merge, code style y lo que no aceptamos → [
|
|||
Gracias a todas las personas que han ayudado a mover Open Design hacia adelante: con código, docs, feedback, nuevas skills, nuevos design systems o incluso un issue preciso. Toda contribución real cuenta, y el muro de abajo es la forma más simple de decirlo en voz alta.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Contribuidores de Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Contribuidores de Open Design" />
|
||||
</a>
|
||||
|
||||
Si ya enviaste tu primer PR, bienvenido. La etiqueta [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) es el punto de entrada.
|
||||
|
|
@ -804,9 +804,9 @@ El SVG anterior se regenera diariamente mediante [`.github/workflows/metrics.yml
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -733,7 +733,7 @@ Guide complet, critères de merge, style de code et refus fréquents → [`CONTR
|
|||
Merci à toutes les personnes qui font avancer Open Design : code, docs, retours, nouveaux Skills, nouveaux Design Systems ou issues bien ciblées. Chaque vraie contribution compte.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Contributeurs Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Contributeurs Open Design" />
|
||||
</a>
|
||||
|
||||
Si vous avez livré votre première PR, bienvenue. Le label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) est le point d’entrée.
|
||||
|
|
@ -750,9 +750,9 @@ Le SVG ci-dessus est régénéré chaque jour par [`.github/workflows/metrics.ym
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -723,7 +723,7 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
|
|||
コード、ドキュメント、フィードバック、新 Skill、新 Design System、あるいは鋭い Issue — あらゆる形で Open Design を前進させてくださったすべての方に感謝します。すべての実質的なコントリビューションは大切であり、以下のウォールは最もシンプルな感謝の表明です。
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design コントリビューター" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design コントリビューター" />
|
||||
</a>
|
||||
|
||||
初めての PR を送った方 — ようこそ。[`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ラベルがエントリポイントです。
|
||||
|
|
@ -740,9 +740,9 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -726,7 +726,7 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스
|
|||
Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다 — 코드, 문서, 피드백, 새 skill, 새 디자인 시스템, 또는 날카로운 이슈 하나라도. 모든 진짜 기여가 의미 있고, 아래의 벽이 가장 직접적인 "감사합니다"입니다.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design 컨트리뷰터" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design 컨트리뷰터" />
|
||||
</a>
|
||||
|
||||
첫 PR을 보냈다면 — 환영합니다. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 레이블이 시작점입니다.
|
||||
|
|
@ -743,9 +743,9 @@ Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
76
README.md
76
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Open Design — the open-source Claude Design alternative
|
||||
|
||||
> **Open Design is the open-source, local-first alternative to [Claude Design][cd].** Web-deployable, BYOK at every layer — **16 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) become the design engine, driven by **31 composable Skills** and **72 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn.
|
||||
> **Open Design is the open-source, local-first alternative to [Claude Design][cd].** Web-deployable, BYOK at every layer — **16 coding-agent CLIs** auto-detected on your `PATH` (Claude Code, Codex, Devin for Terminal, Cursor Agent, Gemini CLI, OpenCode, Qwen, Qoder CLI, GitHub Copilot CLI, Hermes, Kimi, Pi, Kiro, Kilo, Mistral Vibe, DeepSeek TUI) become the design engine, driven by **132 composable Skills** and **150 brand-grade Design Systems**. No CLI? An OpenAI-compatible BYOK proxy is the same loop minus the spawn.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ### 🔥 `0.8.0-preview` is here. Design's old world ends here.
|
||||
|
|
@ -30,8 +30,8 @@
|
|||
<a href="https://github.com/nexu-io/open-design/releases"><img alt="Latest release" src="https://img.shields.io/github/v/release/nexu-io/open-design?style=flat-square&color=blueviolet&label=release&include_prereleases&display_name=tag" /></a>
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square" /></a>
|
||||
<a href="#supported-coding-agents"><img alt="Agents" src="https://img.shields.io/badge/agents-16%20CLIs%20%2B%20BYOK%20proxy-black?style=flat-square" /></a>
|
||||
<a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-149-orange?style=flat-square" /></a>
|
||||
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-131-teal?style=flat-square" /></a>
|
||||
<a href="#design-systems"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-150-orange?style=flat-square" /></a>
|
||||
<a href="#skills"><img alt="Skills" src="https://img.shields.io/badge/skills-132-teal?style=flat-square" /></a>
|
||||
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
|
||||
<a href="https://x.com/nexudotio"><img alt="Follow @nexudotio on X" src="https://img.shields.io/badge/follow-%40nexudotio-1DA1F2?style=flat-square&logo=x&logoColor=white" /></a>
|
||||
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
|
||||
|
|
@ -65,8 +65,8 @@ OD stands on four open-source shoulders:
|
|||
| **Coding-agent CLIs (16)** | Claude Code · Codex CLI · Devin for Terminal · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Qoder CLI · GitHub Copilot CLI · Hermes (ACP) · Kimi CLI (ACP) · Pi (RPC) · Kiro CLI (ACP) · Kilo (ACP) · Mistral Vibe CLI (ACP) · DeepSeek TUI — auto-detected on `PATH`, swap with one click |
|
||||
| **BYOK fallback** | Protocol-specific API proxy at `/api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream` — paste `baseUrl` + `apiKey` + `model`, choose Anthropic / OpenAI / Azure OpenAI / Google Gemini / Ollama Cloud / SenseAudio, and the daemon normalizes SSE back to the same chat stream. SenseAudio chat additionally exposes `generate_image` and `generate_video` tools so the model can write rendered artifacts straight into the active project's folder. Internal-IP/SSRF blocked at the daemon edge. |
|
||||
| **Design systems built-in** | **129** — 2 hand-authored starters + 70 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, Xiaohongshu, …) from [`awesome-design-md`][acd2], plus 57 design skills from [`awesome-design-skills`][ads] added directly under `design-systems/` |
|
||||
| **Skills built-in** | **31** — 27 in `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 in `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
|
||||
| **Media generation** | Image · video · audio surfaces ship alongside the design loop. **gpt-image-2** (Azure / OpenAI) for posters, avatars, infographics, illustrated maps · **Seedance 2.0** (ByteDance) for cinematic 15s text-to-video and image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) for HTML→MP4 motion graphics (product reveals, kinetic typography, data charts, social overlays, logo outros). **93** ready-to-replicate prompts gallery — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — under [`prompt-templates/`](prompt-templates/), with preview thumbnails and source attribution. Same chat surface as code; outputs a real `.mp4` / `.png` chip into the project workspace. |
|
||||
| **Skills built-in** | **132** — 27 in `prototype` mode (web-prototype, saas-landing, dashboard, mobile-app, gamified-app, social-carousel, magazine-poster, dating-web, sprite-animation, motion-frames, critique, tweaks, wireframe-sketch, pm-spec, eng-runbook, finance-report, hr-onboarding, invoice, kanban-board, team-okrs, …) + 4 in `deck` mode (`guizang-ppt` · `simple-deck` · `replit-deck` · `weekly-update`). Grouped in the picker by `scenario`: design / marketing / operation / engineering / product / finance / hr / sale / personal. |
|
||||
| **Media generation** | Image · video · audio surfaces ship alongside the design loop. **gpt-image-2** (Azure / OpenAI) for posters, avatars, infographics, illustrated maps · **Seedance 2.0** (ByteDance) for cinematic 15s text-to-video and image-to-video · **HyperFrames** ([heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)) for HTML→MP4 motion graphics (product reveals, kinetic typography, data charts, social overlays, logo outros). Other image generators can already plug in through **Custom Image API** / **ImageRouter** when they expose an OpenAI-compatible image endpoint; workflow-first local runtimes such as **ComfyUI** are tracked separately as planned adapters. **93** ready-to-replicate prompts gallery — 43 gpt-image-2 + 39 Seedance + 11 HyperFrames — under [`prompt-templates/`](prompt-templates/), with preview thumbnails and source attribution. Same chat surface as code; outputs a real `.mp4` / `.png` chip into the project workspace. |
|
||||
| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Warm Soft · Tech Utility · Brutalist Experimental) — each ships a deterministic OKLch palette + font stack ([`apps/daemon/src/prompts/directions.ts`](apps/daemon/src/prompts/directions.ts)) |
|
||||
| **Device frames** | iPhone 15 Pro · Pixel · iPad Pro · MacBook · Browser Chrome — pixel-accurate, shared across skills under [`assets/frames/`](assets/frames/) |
|
||||
| **Agent runtime** | Local daemon spawns the CLI in your project folder — agent gets real `Read`, `Write`, `Bash`, `WebFetch` against a real on-disk environment, with Windows `ENAMETOOLONG` fallbacks (stdin / prompt-file) on every adapter |
|
||||
|
|
@ -111,8 +111,8 @@ Linux AppImage packaging is available through the optional release lane and is c
|
|||
<sub><b>Sandboxed preview</b> — every <code><artifact></code> renders in a clean srcdoc iframe. Editable in place via the file workspace; downloadable as HTML, PDF, ZIP.</sub>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 72-system library" /><br/>
|
||||
<sub><b>72-system library</b> — every product system shows its 4-color signature. Click for the full <code>DESIGN.md</code>, swatch grid, and live showcase.</sub>
|
||||
<img src="docs/screenshots/06-design-systems-library.png" alt="06 · 150-system library" /><br/>
|
||||
<sub><b>150-system library</b> — every product system shows its 4-color signature. Click for the full <code>DESIGN.md</code>, swatch grid, and live showcase.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -129,9 +129,9 @@ Linux AppImage packaging is available through the optional release lane and is c
|
|||
|
||||
## Skills
|
||||
|
||||
**31 skills ship in the box.** Each is a folder under [`skills/`](skills/) following the Claude Code [`SKILL.md`][skill] convention with an extended `od:` frontmatter that the daemon parses verbatim — `mode`, `platform`, `scenario`, `preview.type`, `design_system.requires`, `default_for`, `featured`, `fidelity`, `speaker_notes`, `animations`, `example_prompt` ([`apps/daemon/src/skills.ts`](apps/daemon/src/skills.ts)).
|
||||
**132 skills ship in the box.** Each is a folder under [`skills/`](skills/) following the Claude Code [`SKILL.md`][skill] convention with an extended `od:` frontmatter that the daemon parses verbatim — `mode`, `platform`, `scenario`, `preview.type`, `design_system.requires`, `default_for`, `featured`, `fidelity`, `speaker_notes`, `animations`, `example_prompt` ([`apps/daemon/src/skills.ts`](apps/daemon/src/skills.ts)).
|
||||
|
||||
Two top-level **modes** carry the catalog: **`prototype`** (27 skills — anything that renders as a single-page artifact, from a magazine landing to a phone screen to a PM spec doc) and **`deck`** (4 skills — horizontal-swipe presentations with deck-framework chrome). The **`scenario`** field is what the picker groups them by: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
|
||||
Two **modes** anchor the interactive catalog: **`prototype`** (32 skills — anything that renders as a single-page artifact, from a magazine landing to a phone screen to a PM spec doc) and **`deck`** (9 skills — horizontal-swipe presentations with deck-framework chrome). The catalog also ships `image`, `video`, `audio`, `template`, `design-system`, and `utility` modes for media generation, catalog updaters, and post-export audit helpers. The **`scenario`** field is what the picker groups them by: `design` · `marketing` · `operation` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`.
|
||||
|
||||
### Showcase examples
|
||||
|
||||
|
|
@ -259,8 +259,8 @@ What you compose at send time isn't "system + user". It's:
|
|||
```
|
||||
DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critique)
|
||||
+ identity charter (OFFICIAL_DESIGNER_PROMPT, anti-AI-slop, junior-pass)
|
||||
+ active DESIGN.md (72 systems available)
|
||||
+ active SKILL.md (31 skills available)
|
||||
+ active DESIGN.md (150 systems available)
|
||||
+ active SKILL.md (132 skills available)
|
||||
+ project metadata (kind, fidelity, speakerNotes, animations, inspiration ids)
|
||||
+ skill side files (auto-injected pre-flight: read assets/template.html + references/*.md)
|
||||
+ (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
|
||||
|
|
@ -338,9 +338,25 @@ docker compose version
|
|||
|
||||
#### Start Open Design
|
||||
|
||||
```bash id="m9w43w"
|
||||
git clone https://github.com/nexu-io/open-design.git
|
||||
cd open-design/deploy
|
||||
1. Clone the repository, change to the deploy directory, and copy the environment template:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nexu-io/open-design.git
|
||||
cd open-design/deploy
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Generate a secure token:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
3. Open `.env` in your editor, find `OD_API_TOKEN=`, and paste the generated token there.
|
||||
|
||||
Then start the service:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
|
|
@ -392,7 +408,7 @@ For desktop/background startup, fixed-port restarts, and media generation dispat
|
|||
The first load:
|
||||
|
||||
1. Detects which agent CLIs you have on `PATH` and picks one automatically.
|
||||
2. Loads 31 skills + 72 design systems.
|
||||
2. Loads 132 skills + 150 design systems.
|
||||
3. Pops the welcome dialog so you can paste an Anthropic key (only needed for the BYOK fallback path).
|
||||
4. **Auto-creates `./.od/`** — the local runtime folder for the SQLite project DB, per-project artifacts, and saved renders. There is no `od init` step; the daemon `mkdir`s everything it needs on boot.
|
||||
|
||||
|
|
@ -693,7 +709,7 @@ open-design/
|
|||
│ ├── sidecar/ ← generic sidecar runtime primitives
|
||||
│ └── platform/ ← generic process/platform primitives
|
||||
│
|
||||
├── skills/ ← 31 SKILL.md skill bundles (27 prototype + 4 deck)
|
||||
├── skills/ ← 132 SKILL.md skill bundles (32 prototype + 9 deck + image / video / audio / template / design-system / utility)
|
||||
│ ├── web-prototype/ ← default for prototype mode
|
||||
│ ├── saas-landing/ dashboard/ pricing-page/ docs-page/ blog-post/
|
||||
│ ├── mobile-app/ mobile-onboarding/ gamified-app/
|
||||
|
|
@ -708,7 +724,7 @@ open-design/
|
|||
│ ├── assets/template.html ← seed
|
||||
│ └── references/{themes,layouts,components,checklist}.md
|
||||
│
|
||||
├── design-systems/ ← 72 DESIGN.md systems
|
||||
├── design-systems/ ← 150 DESIGN.md systems
|
||||
│ ├── default/ ← Neutral Modern (starter)
|
||||
│ ├── warm-editorial/ ← Warm Editorial (starter)
|
||||
│ ├── linear-app/ vercel/ stripe/ airbnb/ notion/ cursor/ apple/ …
|
||||
|
|
@ -750,10 +766,10 @@ open-design/
|
|||
## Design Systems
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/design-systems-library.png" alt="The 72 design systems library — style guide spread" width="100%" />
|
||||
<img src="docs/assets/design-systems-library.png" alt="The 150 design systems library — style guide spread" width="100%" />
|
||||
</p>
|
||||
|
||||
72 systems out of the box, each as a single [`DESIGN.md`](design-systems/README.md):
|
||||
150 systems out of the box, each as a single [`DESIGN.md`](design-systems/README.md):
|
||||
|
||||
<details>
|
||||
<summary><b>Full catalog</b> (click to expand)</summary>
|
||||
|
|
@ -798,7 +814,7 @@ Full spec → [`apps/daemon/src/prompts/directions.ts`](apps/daemon/src/prompts/
|
|||
|
||||
OD doesn't stop at code. The same chat surface that produces `<artifact>` HTML also drives **image**, **video**, and **audio** generation, with model adapters wired into the daemon's media pipeline ([`apps/daemon/src/media-models.ts`](apps/daemon/src/media-models.ts), [`apps/web/src/media/models.ts`](apps/web/src/media/models.ts)). Every render lands as a real file in the project workspace — `.png` for image, `.mp4` for video — and shows up as a download chip when the turn ends.
|
||||
|
||||
Three model families carry the load today:
|
||||
Three flagship paths carry the load today:
|
||||
|
||||
| Surface | Model | Provider | What it's for |
|
||||
|---|---|---|---|
|
||||
|
|
@ -806,6 +822,12 @@ Three model families carry the load today:
|
|||
| **Video** | `seedance-2.0` | ByteDance Volcengine | 15s cinematic t2v + i2v with audio — narrative shorts, character close-ups, product films, MV-style choreography |
|
||||
| **Video** | `hyperframes-html` | [HeyGen / OSS](https://github.com/heygen-com/hyperframes) | HTML→MP4 motion graphics — product reveals, kinetic typography, data charts, social overlays, logo outros, TikTok-style verticals with karaoke captions |
|
||||
|
||||
Other generators are possible, but the path depends on the API shape:
|
||||
|
||||
- **Today:** use **Settings → Media providers → Custom Image API** for any local or hosted image generator that exposes an OpenAI-compatible `POST /v1/images/generations` endpoint. **ImageRouter** covers the same contract for routed image and video backends.
|
||||
- **Not wired yet:** workflow-first local runtimes such as **ComfyUI**. OD now lists ComfyUI in the Media providers “Coming soon” drawer to make that gap explicit, but direct JSON-workflow execution still needs a dedicated adapter.
|
||||
- **Still provider-specific:** arbitrary non-OpenAI-compatible video APIs. Those need a first-class daemon integration rather than just a base URL swap.
|
||||
|
||||
A growing **prompt gallery** at [`prompt-templates/`](prompt-templates/) ships **93 ready-to-replicate prompts** — 43 image (`prompt-templates/image/*.json`), 39 Seedance (`prompt-templates/video/*.json` excluding `hyperframes-*`), 11 HyperFrames (`prompt-templates/video/hyperframes-*.json`). Each carries a preview thumbnail, the prompt body verbatim, the target model, the aspect ratio, and a `source` block for license + attribution. The daemon serves them at `GET /api/prompt-templates`, the web app surfaces them as a card grid in the **Image templates** and **Video templates** tabs of the entry view; one click drops a prompt into the composer with the right model preselected.
|
||||
|
||||
### gpt-image-2 — image gallery (sample of 43)
|
||||
|
|
@ -873,7 +895,7 @@ The chat / artifact loop gets the spotlight, but a handful of less-visible capab
|
|||
|
||||
- **Claude Design ZIP import.** Drop an export from claude.ai onto the welcome dialog. `POST /api/import/claude-design` extracts it into a real `.od/projects/<id>/`, opens the entry file as a tab, and stages a continue-where-Anthropic-left-off prompt for your local agent. No re-prompting, no "ask the model to re-create what we just had". ([`apps/daemon/src/server.ts`](apps/daemon/src/server.ts) — `/api/import/claude-design`)
|
||||
- **Multi-provider BYOK proxy.** `POST /api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream` takes `{ baseUrl, apiKey, model, messages }`, builds the provider-specific upstream request, normalizes SSE chunks into `delta/end/error`, and allows loopback local LLM providers while rejecting non-loopback private, link-local, CGNAT, multicast, reserved, and redirect targets to head off SSRF. OpenAI-compatible covers OpenAI, Azure AI Foundry `/openai/v1`, DeepSeek, Groq, MiMo, OpenRouter, Ollama, LM Studio, and self-hosted vLLM; Azure OpenAI adds deployment URL + `api-version`; Google uses Gemini `:streamGenerateContent`.
|
||||
- **User-saved templates.** Once you like a render, `POST /api/templates` snapshots the HTML + metadata into the SQLite `templates` table. The next project picks it from a "your templates" row in the picker — same surface as the shipped 31, but yours.
|
||||
- **User-saved templates.** Once you like a render, `POST /api/templates` snapshots the HTML + metadata into the SQLite `templates` table. The next project picks it from a "your templates" row in the picker — same surface as the shipped 132, but yours.
|
||||
- **Tab persistence.** Every project remembers its open files and active tab in the `tabs` table. Reopen the project tomorrow and the workspace looks exactly the way you left it.
|
||||
- **Artifact lint API.** `POST /api/artifacts/lint` runs structural checks on a generated artifact (broken `<artifact>` framing, missing required side files, stale palette tokens) and returns findings the agent can read back into its next turn. The five-dim self-critique uses this to ground its score in real evidence, not vibes.
|
||||
- **Sidecar protocol + desktop automation.** Daemon, web, and desktop processes carry typed five-field stamps (`app · mode · namespace · ipc · source`) and expose a JSON-RPC IPC channel at `/tmp/open-design/ipc/<namespace>/<app>.sock`. `tools-dev inspect desktop status \| eval \| screenshot` drives that channel, so headless E2E works against a real Electron shell without bespoke harnesses ([`packages/sidecar-proto/`](packages/sidecar-proto/), [`apps/desktop/src/main/`](apps/desktop/src/main/)).
|
||||
|
|
@ -899,7 +921,7 @@ The whole machinery below is the [`huashu-design`](https://github.com/alchaincyf
|
|||
| Form factor | Web (claude.ai) | Desktop (Electron) | **Web app + local daemon** |
|
||||
| Deployable on Vercel | ❌ | ❌ | **✅** |
|
||||
| Agent runtime | Bundled (Opus 4.7) | Bundled ([`pi-ai`][piai]) | **Delegated to user's existing CLI** |
|
||||
| Skills | Proprietary | 12 custom TS modules + `SKILL.md` | **31 file-based [`SKILL.md`][skill] bundles, droppable** |
|
||||
| Skills | Proprietary | 12 custom TS modules + `SKILL.md` | **132 file-based [`SKILL.md`][skill] bundles, droppable** |
|
||||
| Design system | Proprietary | `DESIGN.md` (v0.2 roadmap) | **`DESIGN.md` × 129 systems shipped** |
|
||||
| Provider flexibility | Anthropic only | 7+ via [`pi-ai`][piai] | **16 CLI adapters + OpenAI-compatible BYOK proxy** |
|
||||
| Init question form | ❌ | ❌ | **✅ Hard rule, turn 1** |
|
||||
|
|
@ -972,7 +994,7 @@ Long-form provenance write-up — what we take from each, what we deliberately d
|
|||
|
||||
- [x] Daemon + agent detection (16 CLI adapters) + skill registry + design-system catalog
|
||||
- [x] Web app + chat + question form + 5-direction picker + todo progress + sandboxed preview
|
||||
- [x] 31 skills + 72 design systems + 5 visual directions + 5 device frames
|
||||
- [x] 132 skills + 150 design systems + 5 visual directions + 5 device frames
|
||||
- [x] SQLite-backed projects · conversations · messages · tabs · templates
|
||||
- [x] Multi-provider BYOK proxy (`/api/proxy/{anthropic,openai,azure,google,ollama,senseaudio}/stream`) with SSRF guard
|
||||
- [x] Claude Design ZIP import (`/api/import/claude-design`)
|
||||
|
|
@ -1018,7 +1040,7 @@ Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CO
|
|||
Thanks to everyone who has helped move Open Design forward — through code, docs, feedback, new skills, new design systems, or even a sharp issue. Every real contribution counts, and the wall below is the easiest way to say so out loud.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
If you've shipped your first PR — welcome. The [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label is the entry point.
|
||||
|
|
@ -1035,9 +1057,9 @@ The SVG above is regenerated daily by [`.github/workflows/metrics.yml`](.github/
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -730,7 +730,7 @@ Walkthrough completo, barra para mergear, estilo de código e o que não aceitam
|
|||
Obrigado a todas as pessoas que ajudaram a empurrar o Open Design pra frente — via código, docs, feedback, novas skills, novos design systems ou até uma issue afiada. Toda contribuição real conta, e a parede abaixo é a forma mais simples de dizer isso em voz alta.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Contribuidoras e contribuidores do Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Contribuidoras e contribuidores do Open Design" />
|
||||
</a>
|
||||
|
||||
Se você acabou de mandar seu primeiro PR — bem-vindo. A label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) é o ponto de entrada.
|
||||
|
|
@ -747,9 +747,9 @@ O SVG acima é regenerado diariamente por [`.github/workflows/metrics.yml`](.git
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,7 @@ Issues, PR, новые skills и новые design systems приветству
|
|||
Спасибо всем, кто помогает двигать Open Design вперёд — кодом, документацией, обратной связью, новыми skills, новыми design systems или просто точным issue. Вклад любого реального масштаба здесь важен, а стена ниже — самый простой способ сказать это вслух.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Contributors Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Contributors Open Design" />
|
||||
</a>
|
||||
|
||||
Если вы только что отправили свой первый PR — добро пожаловать. Метка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — хорошая точка входа.
|
||||
|
|
@ -746,9 +746,9 @@ SVG выше ежедневно пересобирается workflow [`.github/
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -887,7 +887,7 @@ Tam walkthrough, merge çıtası, code style ve kabul etmediklerimiz → [`CONTR
|
|||
Open Design'ı kod, doküman, feedback, yeni skill, yeni design system veya keskin bir issue ile ileri taşıyan herkese teşekkürler. Her gerçek katkı önemlidir; aşağıdaki wall bunu yüksek sesle söylemenin en kolay yolu.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
İlk PR'ını gönderdiysen hoş geldin. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label'ı giriş noktasıdır.
|
||||
|
|
@ -904,9 +904,9 @@ Yukarıdaki SVG [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml)
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,7 @@ OD не зупиняється на коді. Та сама поверхня ч
|
|||
Дякуємо всім, хто допоміг просувати Open Design — через код, документацію, зворотний зв'язок, нові навички, нові системи дизайну або навіть гостре питання. Кожен реальний внесок рахується, а стіна нижче — найпростіший спосіб сказати це вголос.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Контриб'ютори Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Контриб'ютори Open Design" />
|
||||
</a>
|
||||
|
||||
Якщо ви злили свій перший PR — ласкаво просимо. Мітка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — це точка входу.
|
||||
|
|
@ -746,9 +746,9 @@ SVG вище перегенерується щодня [`.github/workflows/metri
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -722,7 +722,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
|
|||
感谢每一位让 Open Design 变得更好的朋友 —— 无论是写代码、修文档、提 issue、加 skill 还是加 design system,每一次真实贡献都会被记住。下面这面墙是最直观的「Thank you」。
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design 贡献者" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design 贡献者" />
|
||||
</a>
|
||||
|
||||
第一次提 PR?欢迎从 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 标签起步。
|
||||
|
|
@ -739,9 +739,9 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -713,7 +713,8 @@ open-design/
|
|||
│ └── browser-chrome.html
|
||||
│
|
||||
├── templates/
|
||||
│ └── deck-framework.html ← deck 基線(nav / counter / print)
|
||||
│ ├── deck-framework.html ← deck 基線(nav / counter / print)
|
||||
│ └── kami-deck.html ← kami 風格 deck 起手(羊皮紙 / 墨藍襯線)
|
||||
│
|
||||
├── scripts/
|
||||
│ └── sync-design-systems.ts ← 從上游 awesome-design-md tarball 重新匯入
|
||||
|
|
@ -888,7 +889,7 @@ Chat / artifact 迴圈最顯眼,但這套倉庫裡還有幾個能力被埋得
|
|||
| 可部署 Vercel | ❌ | ❌ | **✅** |
|
||||
| Agent 執行時 | 內建 (Opus 4.7) | 內建 ([`pi-ai`][piai]) | **委託給使用者已裝好的 CLI** |
|
||||
| Skill | 私有 | 12 套自定義 TS 模組 + `SKILL.md` | **31 套基於檔案的 [`SKILL.md`][skill],可丟入** |
|
||||
| Design system | 私有 | `DESIGN.md`(v0.2 路線圖) | **`DESIGN.md` × 72 套,開箱即有** |
|
||||
| Design system | 私有 | `DESIGN.md`(v0.2 路線圖) | **`DESIGN.md` × 129 套,開箱即有** |
|
||||
| Provider 靈活度 | 僅 Anthropic | 7+([`pi-ai`][piai]) | **16 套 CLI adapter + OpenAI 相容 BYOK 代理** |
|
||||
| 初始化問題表單 | ❌ | ❌ | **✅ 硬規則 turn 1** |
|
||||
| 方向選擇器 | ❌ | ❌ | **✅ 5 套確定性方向** |
|
||||
|
|
@ -1005,7 +1006,7 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
|
|||
感謝每一位讓 Open Design 變得更好的朋友 —— 無論是寫程式碼、修文檔、提 issue、加 skill 還是加 design system,每一次真實貢獻都會被記住。下面這面牆是最直觀的「Thank you」。
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-21" alt="Open Design 貢獻者" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-28" alt="Open Design 貢獻者" />
|
||||
</a>
|
||||
|
||||
第一次提 PR?歡迎從 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 標籤起步。
|
||||
|
|
@ -1022,9 +1023,9 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-21" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-28" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-28" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ pnpm i18n:check
|
|||
|
||||
## 📋 Supported Languages
|
||||
|
||||
Open Design currently supports **18 languages** across different surfaces:
|
||||
Open Design currently supports **19 languages** across different surfaces:
|
||||
|
||||
| Language | Code | README | UI Dict | Core Docs | Status |
|
||||
| -------------------- | ------- | ------ | ------- | --------- | ------ |
|
||||
|
|
@ -116,6 +116,7 @@ Open Design currently supports **18 languages** across different surfaces:
|
|||
| Français | `fr` | ✅ | ✅ | ✅ | active |
|
||||
| Magyar (Hungarian) | `hu` | — | ✅ | — | active |
|
||||
| Bahasa Indonesia | `id` | — | ✅ | — | active |
|
||||
| Italiano | `it` | — | ✅ | — | active |
|
||||
| 日本語 (Japanese) | `ja` | ✅ | ✅ | ✅ | active |
|
||||
| 한국어 (Korean) | `ko` | ✅ | ✅ | — | active |
|
||||
| Polski (Polish) | `pl` | — | ✅ | — | active |
|
||||
|
|
@ -331,6 +332,88 @@ Translations follow the conventions of the target region's tech writing communit
|
|||
- "quickstart" → your language's equivalent
|
||||
- "settings" → your language's equivalent
|
||||
|
||||
### French (`fr`) Glossary
|
||||
|
||||
French UI copy should read naturally for a technical product audience without
|
||||
turning product/runtime terms into vague French approximations. Keep these
|
||||
rules stable across `apps/web/src/i18n/locales/fr.ts`, French core docs, and
|
||||
French display metadata.
|
||||
|
||||
#### Keep in English
|
||||
|
||||
Keep the exact English/token form for names, protocols, commands, environment
|
||||
variables, code identifiers, package names, file extensions, and technical
|
||||
runtime nouns that are clearer in English:
|
||||
|
||||
| English source | French usage |
|
||||
| -------------- | ------------ |
|
||||
| Open Design | Open Design |
|
||||
| Claude Code, Codex, Cursor, Gemini, OpenCode | Claude Code, Codex, Cursor, Gemini, OpenCode |
|
||||
| CLI, API, SDK, MCP, HTTP, REST, SSE, JSONL | CLI, API, SDK, MCP, HTTP, REST, SSE, JSONL |
|
||||
| BYOK | BYOK |
|
||||
| runtime | runtime |
|
||||
| daemon | daemon |
|
||||
| sidecar | sidecar |
|
||||
| headless | headless |
|
||||
| plugin | plugin |
|
||||
| prompt | prompt |
|
||||
| token | token |
|
||||
| iframe | iframe |
|
||||
| monorepo, workspace | monorepo, workspace |
|
||||
| `od`, `pnpm`, `pnpm tools-dev` | `od`, `pnpm`, `pnpm tools-dev` |
|
||||
| `OD_DATA_DIR`, `OD_WEB_PORT`, `{provider}` | `OD_DATA_DIR`, `OD_WEB_PORT`, `{provider}` |
|
||||
| `.zip`, `.html`, `.md`, `.json` | `.zip`, `.html`, `.md`, `.json` |
|
||||
|
||||
Use French grammar around preserved terms:
|
||||
|
||||
- `le daemon local`, `un runtime`, `des plugins`, `les prompts`
|
||||
- `l’API`, `un endpoint REST`, `un flux SSE`
|
||||
- `la CLI locale`, `un serveur MCP`
|
||||
|
||||
#### Translate When Standard
|
||||
|
||||
Translate ordinary UI terms, workflow labels, and non-identifier product copy
|
||||
when a natural French equivalent exists:
|
||||
|
||||
| English source | French |
|
||||
| -------------- | ------ |
|
||||
| Settings | Paramètres |
|
||||
| Save | Enregistrer |
|
||||
| Cancel | Annuler |
|
||||
| Delete | Supprimer |
|
||||
| Folder | Dossier |
|
||||
| File | Fichier |
|
||||
| Download | Télécharger |
|
||||
| Upload | Téléverser |
|
||||
| Search | Rechercher |
|
||||
| Preview | Aperçu |
|
||||
| Project | Projet |
|
||||
| Conversation | Conversation |
|
||||
| Dashboard | Tableau de bord |
|
||||
| Schedule | Planification |
|
||||
| Automation | Automatisation |
|
||||
| Artifact | Artefact |
|
||||
| Live artifact | Artefact dynamique |
|
||||
| Design files | Fichiers de design |
|
||||
| Slide deck | Présentation |
|
||||
| Engineering handoff | Transmission aux ingénieurs |
|
||||
| Shipped (product/software status) | Livré |
|
||||
|
||||
#### Context-Sensitive Choices
|
||||
|
||||
- `Skill` stays `Skill` when it names the Open Design/Claude skill format.
|
||||
Translate only generic prose such as "ability" or "capability" as
|
||||
`capacité`.
|
||||
- `Design System` may stay `Design System` when referring to the product
|
||||
registry/object name. In explanatory prose, `système de design` is also
|
||||
acceptable when it improves readability.
|
||||
- `runtime` stays `runtime` as a noun. Labels like "execution mode" can still
|
||||
use `mode d’exécution`.
|
||||
- `source` can stay `source` for provenance labels, but translate ordinary
|
||||
"data source" as `source de données`.
|
||||
- Do not translate command output or examples that users should see exactly in
|
||||
their terminal.
|
||||
|
||||
### zh-CN ↔ zh-TW Glossary
|
||||
|
||||
When converting between Simplified and Traditional Chinese, prefer Taiwan-specific phrasing in zh-TW rather than character-only conversion. This list grew out of [PR #194](https://github.com/nexu-io/open-design/pull/194) and is meant as a starting point, not a rulebook.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@open-design/daemon",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/cli.js",
|
||||
|
|
@ -44,8 +44,9 @@
|
|||
"@opentelemetry/api": "1.9.1",
|
||||
"better-sqlite3": "12.10.0",
|
||||
"blake3-wasm": "2.1.5",
|
||||
"cheerio": "1.2.0",
|
||||
"chokidar": "5.0.0",
|
||||
"express": "4.22.1",
|
||||
"express": "5.2.1",
|
||||
"jszip": "3.10.1",
|
||||
"multer": "2.1.1",
|
||||
"posthog-node": "5.34.6",
|
||||
|
|
@ -55,7 +56,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/express": "4.17.25",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/node": "20.19.39",
|
||||
"typescript": "5.9.3",
|
||||
|
|
|
|||
101
apps/daemon/scripts/verify-amr-real-vela.mjs
Executable file
101
apps/daemon/scripts/verify-amr-real-vela.mjs
Executable file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Ad-hoc end-to-end verifier: drives the real `vela` binary through Open
|
||||
* Design's `attachAcpSession`. Not part of the test suite — it makes a real
|
||||
* OpenRouter request when VELA_RUNTIME_KEY is a live key.
|
||||
*
|
||||
* Usage:
|
||||
* VELA_BIN=/path/to/vela \
|
||||
* VELA_RUNTIME_KEY=<openrouter-key> \
|
||||
* VELA_LINK_URL=https://openrouter.ai/api/v1 \
|
||||
* PATH=<dir-with-opencode>:$PATH \
|
||||
* node apps/daemon/scripts/verify-amr-real-vela.mjs
|
||||
*
|
||||
* Behaviour:
|
||||
* - Runs initialize → session/new → session/set_model (if --model given) →
|
||||
* session/prompt with the prompt from VELA_VERIFY_PROMPT (defaults to a
|
||||
* short hello).
|
||||
* - Logs every Open Design `send(event, payload)` to stdout so you can see
|
||||
* the same text_delta / usage events the chat UI would receive.
|
||||
* - Exits 0 on completedSuccessfully, 1 otherwise.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const { attachAcpSession } = await import(
|
||||
path.join(HERE, '..', 'dist', 'acp.js')
|
||||
);
|
||||
|
||||
const velaBin = process.env.VELA_BIN || 'vela';
|
||||
const prompt = process.env.VELA_VERIFY_PROMPT || 'Reply with the exact text: AMR-E2E-OK.';
|
||||
const model = process.env.VELA_VERIFY_MODEL || null;
|
||||
|
||||
if (
|
||||
(!process.env.VELA_RUNTIME_KEY || !process.env.VELA_LINK_URL) &&
|
||||
!process.env.VELA_PROFILE
|
||||
) {
|
||||
console.error(
|
||||
'Provide credentials via either:\n' +
|
||||
' - VELA_RUNTIME_KEY + VELA_LINK_URL env vars, or\n' +
|
||||
' - VELA_PROFILE (e.g. "local") with a logged-in ~/.amr/config.json.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const child = spawn(velaBin, ['agent', 'run', '--runtime', 'opencode'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
process.stderr.write(`[vela.stderr] ${chunk}`);
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
console.error('[child.error]', err.message);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
console.error(`[child.exit] code=${code} signal=${signal}`);
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
console.error(`[child.close] code=${code} signal=${signal}`);
|
||||
});
|
||||
|
||||
const overallTimeoutMs = Number(process.env.VELA_VERIFY_TIMEOUT_MS) || 120_000;
|
||||
const overallTimer = setTimeout(() => {
|
||||
console.error(`[verify-amr] overall timeout after ${overallTimeoutMs}ms; SIGTERM child`);
|
||||
if (!child.killed) child.kill('SIGTERM');
|
||||
}, overallTimeoutMs);
|
||||
overallTimer.unref?.();
|
||||
|
||||
const session = attachAcpSession({
|
||||
child,
|
||||
prompt,
|
||||
cwd: process.cwd(),
|
||||
model,
|
||||
mcpServers: [],
|
||||
send: (event, payload) => {
|
||||
const stamp = new Date().toISOString();
|
||||
if (event === 'agent' && payload?.type === 'text_delta') {
|
||||
process.stdout.write(payload.delta);
|
||||
return;
|
||||
}
|
||||
console.log(`\n[${stamp}] ${event} ${JSON.stringify(payload)}`);
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => child.on('close', resolve));
|
||||
process.stdout.write('\n');
|
||||
|
||||
if (session.hasFatalError()) {
|
||||
console.error('Session reported fatal error.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!session.completedSuccessfully()) {
|
||||
console.error('Session did not complete successfully.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('verify-amr-real-vela: OK');
|
||||
|
|
@ -65,11 +65,13 @@ interface AttachAcpSessionOptions {
|
|||
prompt: string;
|
||||
cwd?: string;
|
||||
model?: string | null;
|
||||
imagePaths?: string[];
|
||||
mcpServers?: AcpMcpServerInput[];
|
||||
send: (event: string, payload: unknown) => void;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
stageTimeoutMs?: number;
|
||||
modelUnavailableErrorCode?: 'AMR_MODEL_UNAVAILABLE';
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
|
|
@ -115,6 +117,15 @@ function sendRpcResult(writable: RpcWritable, id: JsonRpcId, result: unknown): v
|
|||
writable.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
|
||||
}
|
||||
|
||||
function buildPromptBlocks(prompt: string, imagePaths: string[]): Array<Record<string, string>> {
|
||||
const blocks: Array<Record<string, string>> = [{ type: 'text', text: prompt }];
|
||||
for (const imagePath of imagePaths) {
|
||||
if (typeof imagePath !== 'string' || imagePath.trim().length === 0) continue;
|
||||
blocks.push({ type: 'resource_link', uri: imagePath });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function isJsonRpcId(value: unknown): value is JsonRpcId {
|
||||
return typeof value === 'number' || typeof value === 'string';
|
||||
}
|
||||
|
|
@ -421,11 +432,13 @@ export function attachAcpSession({
|
|||
prompt,
|
||||
cwd,
|
||||
model,
|
||||
imagePaths = [],
|
||||
mcpServers,
|
||||
send,
|
||||
clientName = 'open-design',
|
||||
clientVersion = 'runtime-adapter',
|
||||
stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
|
||||
modelUnavailableErrorCode,
|
||||
}: AttachAcpSessionOptions) {
|
||||
const runStartedAt = Date.now();
|
||||
const effectiveCwd = path.resolve(cwd || process.cwd());
|
||||
|
|
@ -443,6 +456,7 @@ export function attachAcpSession({
|
|||
let modelConfigId: string | null = null;
|
||||
let emittedThinkingStart = false;
|
||||
let emittedFirstTokenStatus = false;
|
||||
let emittedTextChunk = false;
|
||||
let finished = false;
|
||||
let fatal = false;
|
||||
let aborted = false;
|
||||
|
|
@ -467,12 +481,41 @@ export function attachAcpSession({
|
|||
stageTimer = null;
|
||||
};
|
||||
|
||||
const fail = (message: string) => {
|
||||
const amrModelUnavailablePayload = (message: string) => ({
|
||||
message,
|
||||
error: {
|
||||
code: 'AMR_MODEL_UNAVAILABLE',
|
||||
message,
|
||||
retryable: false,
|
||||
details: { kind: 'amr_model', action: 'choose_model' },
|
||||
},
|
||||
});
|
||||
|
||||
const isModelUnavailableError = (message: string) => {
|
||||
const value = message.toLowerCase();
|
||||
return (
|
||||
value.includes('model not found') ||
|
||||
value.includes('providermodelnotfounderror') ||
|
||||
value.includes('unknown model') ||
|
||||
value.includes('invalid model')
|
||||
);
|
||||
};
|
||||
|
||||
const fail = (
|
||||
message: string,
|
||||
options: { forceModelUnavailable?: boolean } = {},
|
||||
) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
fatal = true;
|
||||
clearStageTimer();
|
||||
send('error', { message });
|
||||
const useModelUnavailable =
|
||||
modelUnavailableErrorCode &&
|
||||
(options.forceModelUnavailable || isModelUnavailableError(message));
|
||||
send(
|
||||
'error',
|
||||
useModelUnavailable ? amrModelUnavailablePayload(message) : { message },
|
||||
);
|
||||
if (!child.killed) child.kill('SIGTERM');
|
||||
};
|
||||
|
||||
|
|
@ -493,7 +536,7 @@ export function attachAcpSession({
|
|||
'session/prompt',
|
||||
{
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
prompt: buildPromptBlocks(prompt, imagePaths),
|
||||
},
|
||||
'session/prompt',
|
||||
);
|
||||
|
|
@ -575,6 +618,7 @@ export function attachAcpSession({
|
|||
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||
const text = asObject(update.content)?.text;
|
||||
if (typeof text === 'string' && text.length > 0) {
|
||||
emittedTextChunk = true;
|
||||
if (!emittedFirstTokenStatus) {
|
||||
emittedFirstTokenStatus = true;
|
||||
send('agent', {
|
||||
|
|
@ -638,6 +682,13 @@ export function attachAcpSession({
|
|||
return;
|
||||
}
|
||||
if (promptRequestId !== null && obj.id === promptRequestId) {
|
||||
if (!emittedTextChunk && modelUnavailableErrorCode) {
|
||||
fail(
|
||||
'ACP session completed without producing any assistant text. Refresh the AMR model list, choose a supported model, and retry this run.',
|
||||
{ forceModelUnavailable: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const usage = formatUsage(result.usage);
|
||||
if (usage) {
|
||||
send('agent', {
|
||||
|
|
@ -672,9 +723,12 @@ export function attachAcpSession({
|
|||
});
|
||||
|
||||
stdout.on('data', (chunk: string) => parser.feed(chunk));
|
||||
child.on('close', () => {
|
||||
child.on('close', (code, signal) => {
|
||||
clearStageTimer();
|
||||
parser.flush();
|
||||
if (!finished && !aborted && !fatal) {
|
||||
fail(`ACP session exited before completion (code=${code ?? 'null'}, signal=${signal ?? 'none'})`);
|
||||
}
|
||||
});
|
||||
child.on('error', (err: Error) => fail(err.message));
|
||||
stdin.on('error', (err: Error) => fail(`stdin error: ${err.message}`));
|
||||
|
|
@ -701,12 +755,27 @@ export function attachAcpSession({
|
|||
aborted = true;
|
||||
finished = true;
|
||||
clearStageTimer();
|
||||
if (!sessionId || !child.stdin || child.stdin.destroyed || child.stdin.writableEnded) return;
|
||||
if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded)
|
||||
return;
|
||||
// Only cancel an established session; before session/new resolves there
|
||||
// is no sessionId to cancel, but we must still close stdin below.
|
||||
if (sessionId) {
|
||||
try {
|
||||
sendRpc(child.stdin, nextId, 'session/cancel', { sessionId });
|
||||
nextId += 1;
|
||||
} catch {
|
||||
// The caller owns process-signal fallback if the ACP transport is gone.
|
||||
}
|
||||
}
|
||||
// Always close stdin so the agent receives EOF and shuts down its own
|
||||
// runtime — the vela ACP bridge tears down its private OpenCode server on
|
||||
// EOF — instead of lingering (and leaking that server) until the caller's
|
||||
// SIGTERM fallback fires. This also covers aborts during ACP startup,
|
||||
// before session/new returns. Mirrors the clean-completion path above.
|
||||
try {
|
||||
sendRpc(child.stdin, nextId, 'session/cancel', { sessionId });
|
||||
nextId += 1;
|
||||
child.stdin.end();
|
||||
} catch {
|
||||
// The caller owns process-signal fallback if the ACP transport is gone.
|
||||
// Best effort; the caller still owns the SIGTERM/SIGKILL fallback.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,71 +1,128 @@
|
|||
import type { Express } from 'express';
|
||||
import { createApiError } from '@open-design/contracts';
|
||||
import { ACTIVE_CONTEXT_TTL_MS } from './constants.js';
|
||||
import type { RouteDeps } from './server-context.js';
|
||||
import { defineJsonRoute, err, mountJsonRoute, ok, type Result } from './http/index.js';
|
||||
|
||||
export interface RegisterActiveContextRoutesDeps extends RouteDeps<'db' | 'http' | 'projectStore'> {}
|
||||
|
||||
export function registerActiveContextRoutes(app: Express, ctx: RegisterActiveContextRoutesDeps) {
|
||||
const { db } = ctx;
|
||||
const { sendApiError, isLocalSameOrigin, resolvedPortRef } = ctx.http;
|
||||
const { getProject } = ctx.projectStore;
|
||||
const getResolvedPort = () => resolvedPortRef.current;
|
||||
// Soft "what is the user looking at right now in Open Design?" channel. The
|
||||
// web UI POSTs the current project + file on every route change; the MCP
|
||||
// surface reads it so a coding agent in another repo can resolve "the design
|
||||
// I have open" without the user typing the project id. In-memory only —
|
||||
// daemon restart clears it.
|
||||
interface ActiveContext {
|
||||
projectId: string;
|
||||
fileName: string | null;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
// Soft "what is the user looking at right now in Open Design?" channel. The
|
||||
// web UI POSTs the current project + file on every route change; the MCP
|
||||
// surface reads it so a coding agent in another repo can resolve "the design
|
||||
// I have open" without the user typing the project id. In-memory only -
|
||||
// daemon restart clears it.
|
||||
let activeContext: { projectId: string; fileName: string | null; ts: number } | null = null;
|
||||
interface ActiveContextStore {
|
||||
current: ActiveContext | null;
|
||||
}
|
||||
|
||||
// Active context is private to the local machine. The daemon may bind beyond
|
||||
// loopback, so without an origin check a peer on the LAN could read what the
|
||||
// user is currently looking at (GET) or spoof it to redirect MCP fallbacks
|
||||
// (POST). The web proxies same-origin and MCP runs in-process via 127.0.0.1,
|
||||
// so both legitimate callers pass the check.
|
||||
app.post('/api/active', (req, res) => {
|
||||
if (!isLocalSameOrigin(req, getResolvedPort())) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (body.active === false) {
|
||||
activeContext = null;
|
||||
res.json({ active: false });
|
||||
return;
|
||||
}
|
||||
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
|
||||
if (!projectId) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', 'projectId is required');
|
||||
return;
|
||||
}
|
||||
const fileName =
|
||||
typeof body.fileName === 'string' && body.fileName.length > 0
|
||||
? body.fileName
|
||||
: null;
|
||||
activeContext = { projectId, fileName, ts: Date.now() };
|
||||
res.json({ active: true, ...activeContext });
|
||||
} catch (err) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
||||
}
|
||||
});
|
||||
type PostActiveInput =
|
||||
| { kind: 'clear' }
|
||||
| { kind: 'set'; projectId: string; fileName: string | null };
|
||||
|
||||
app.get('/api/active', (req, res) => {
|
||||
if (!isLocalSameOrigin(req, getResolvedPort())) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
if (!activeContext || Date.now() - activeContext.ts > ACTIVE_CONTEXT_TTL_MS) {
|
||||
activeContext = null;
|
||||
res.json({ active: false });
|
||||
return;
|
||||
}
|
||||
const project = getProject(db, activeContext.projectId);
|
||||
res.json({
|
||||
active: true,
|
||||
projectId: activeContext.projectId,
|
||||
projectName: project?.name ?? null,
|
||||
fileName: activeContext.fileName,
|
||||
ts: activeContext.ts,
|
||||
ageMs: Date.now() - activeContext.ts,
|
||||
});
|
||||
type PostActiveOutput =
|
||||
| { active: false }
|
||||
| { active: true; projectId: string; fileName: string | null; ts: number };
|
||||
|
||||
type GetActiveOutput =
|
||||
| { active: false }
|
||||
| {
|
||||
active: true;
|
||||
projectId: string;
|
||||
projectName: string | null;
|
||||
fileName: string | null;
|
||||
ts: number;
|
||||
ageMs: number;
|
||||
};
|
||||
|
||||
interface ActiveContextDomainDeps {
|
||||
store: ActiveContextStore;
|
||||
db: unknown;
|
||||
getProject: (db: unknown, projectId: string) => { name?: string | null } | null | undefined;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
function parsePostActive(raw: { body: unknown }): Result<PostActiveInput> {
|
||||
const body = (raw.body ?? {}) as Record<string, unknown>;
|
||||
if (body.active === false) {
|
||||
return ok({ kind: 'clear' });
|
||||
}
|
||||
const projectId = typeof body.projectId === 'string' ? body.projectId : '';
|
||||
if (!projectId) {
|
||||
return err(createApiError('BAD_REQUEST', 'projectId is required'));
|
||||
}
|
||||
const fileName =
|
||||
typeof body.fileName === 'string' && body.fileName.length > 0 ? body.fileName : null;
|
||||
return ok({ kind: 'set', projectId, fileName });
|
||||
}
|
||||
|
||||
function handlePostActive(
|
||||
input: PostActiveInput,
|
||||
deps: ActiveContextDomainDeps,
|
||||
): Result<PostActiveOutput> {
|
||||
if (input.kind === 'clear') {
|
||||
deps.store.current = null;
|
||||
return ok({ active: false });
|
||||
}
|
||||
const next: ActiveContext = {
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
ts: deps.now(),
|
||||
};
|
||||
deps.store.current = next;
|
||||
return ok({ active: true, ...next });
|
||||
}
|
||||
|
||||
function handleGetActive(
|
||||
_input: void,
|
||||
deps: ActiveContextDomainDeps,
|
||||
): Result<GetActiveOutput> {
|
||||
const current = deps.store.current;
|
||||
if (!current || deps.now() - current.ts > ACTIVE_CONTEXT_TTL_MS) {
|
||||
deps.store.current = null;
|
||||
return ok({ active: false });
|
||||
}
|
||||
const project = deps.getProject(deps.db, current.projectId);
|
||||
return ok({
|
||||
active: true,
|
||||
projectId: current.projectId,
|
||||
projectName: project?.name ?? null,
|
||||
fileName: current.fileName,
|
||||
ts: current.ts,
|
||||
ageMs: deps.now() - current.ts,
|
||||
});
|
||||
}
|
||||
|
||||
export const postActiveRoute = defineJsonRoute<PostActiveInput, PostActiveOutput, ActiveContextDomainDeps>({
|
||||
method: 'post',
|
||||
path: '/api/active',
|
||||
requireSameOrigin: true,
|
||||
parse: parsePostActive,
|
||||
handle: handlePostActive,
|
||||
});
|
||||
|
||||
export const getActiveRoute = defineJsonRoute<void, GetActiveOutput, ActiveContextDomainDeps>({
|
||||
method: 'get',
|
||||
path: '/api/active',
|
||||
requireSameOrigin: true,
|
||||
parse: () => ok(undefined),
|
||||
handle: handleGetActive,
|
||||
});
|
||||
|
||||
export function registerActiveContextRoutes(app: Express, ctx: RegisterActiveContextRoutesDeps): void {
|
||||
const store: ActiveContextStore = { current: null };
|
||||
const domainDeps: ActiveContextDomainDeps = {
|
||||
store,
|
||||
db: ctx.db,
|
||||
getProject: ctx.projectStore.getProject,
|
||||
now: () => Date.now(),
|
||||
};
|
||||
const adapter = { resolvedPortRef: ctx.http.resolvedPortRef };
|
||||
mountJsonRoute(app, postActiveRoute, domainDeps, adapter);
|
||||
mountJsonRoute(app, getActiveRoute, domainDeps, adapter);
|
||||
}
|
||||
|
|
|
|||
76
apps/daemon/src/amr-image-staging.ts
Normal file
76
apps/daemon/src/amr-image-staging.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const STAGING_DIRNAME = '.amr-attachments';
|
||||
const STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function isWithinRoot(root: string, candidate: string): boolean {
|
||||
const relativePath = path.relative(root, candidate);
|
||||
return (
|
||||
relativePath === '' ||
|
||||
(relativePath.length > 0 &&
|
||||
!relativePath.startsWith('..') &&
|
||||
!path.isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
async function pruneStagedAttachments(stagingDir: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.promises.readdir(stagingDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const cutoff = Date.now() - STAGING_MAX_AGE_MS;
|
||||
await Promise.all(entries.map(async (entry) => {
|
||||
if (!entry.isFile()) return;
|
||||
const filePath = path.join(stagingDir, entry.name);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
await fs.promises.rm(filePath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export async function stageAmrImagePaths(
|
||||
cwd: string | null | undefined,
|
||||
imagePaths: string[],
|
||||
uploadRoot?: string | null,
|
||||
): Promise<string[]> {
|
||||
if (!cwd || !Array.isArray(imagePaths) || imagePaths.length === 0) return [];
|
||||
const root = path.resolve(cwd);
|
||||
const uploadRootReal = uploadRoot
|
||||
? await fs.promises.realpath(uploadRoot).catch(() => null)
|
||||
: null;
|
||||
const stagingDir = path.join(root, STAGING_DIRNAME);
|
||||
await fs.promises.mkdir(stagingDir, { recursive: true });
|
||||
await pruneStagedAttachments(stagingDir);
|
||||
|
||||
const staged: string[] = [];
|
||||
for (const inputPath of imagePaths) {
|
||||
if (typeof inputPath !== 'string' || inputPath.trim().length === 0) continue;
|
||||
try {
|
||||
const resolved = path.resolve(inputPath);
|
||||
const real = await fs.promises.realpath(resolved);
|
||||
if (uploadRootReal && !isWithinRoot(uploadRootReal, real)) continue;
|
||||
const stat = await fs.promises.stat(real);
|
||||
if (!stat.isFile()) continue;
|
||||
if (isWithinRoot(root, real)) {
|
||||
staged.push(real);
|
||||
continue;
|
||||
}
|
||||
const basename = path.basename(real);
|
||||
const destination = path.join(stagingDir, `${randomUUID()}-${basename}`);
|
||||
await fs.promises.copyFile(real, destination);
|
||||
staged.push(destination);
|
||||
} catch {
|
||||
// Ignore malformed or missing files; attachments are advisory input.
|
||||
}
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
|
@ -90,11 +90,35 @@ export interface AnalyticsService {
|
|||
properties: Record<string, unknown>;
|
||||
insertId: string;
|
||||
}): void;
|
||||
/**
|
||||
* Safety / reliability events (renderer crashes, daemon uncaught errors,
|
||||
* SSE health, etc.) that intentionally BYPASS the user's analytics
|
||||
* consent toggle. The product policy is: we always retain ground-truth
|
||||
* stability data even for opted-out users — the user-facing consent copy
|
||||
* in Settings → Privacy must call this out.
|
||||
*
|
||||
* Falls back to a synthetic distinctId when the installationId is not
|
||||
* yet stamped (first-launch or fork builds without an app-config file).
|
||||
*
|
||||
* Returns a Promise that resolves AFTER the event has been enqueued in
|
||||
* posthog-node's local buffer. Fire-and-forget callers (e.g. the
|
||||
* /api/observability/event endpoint) can `void` it; fatal-exit paths
|
||||
* MUST await before calling `shutdown()` so the crash event actually
|
||||
* makes it into the flush.
|
||||
*/
|
||||
captureSafety(args: {
|
||||
eventName: string;
|
||||
distinctId?: string;
|
||||
appVersion: string;
|
||||
properties: Record<string, unknown>;
|
||||
insertId?: string;
|
||||
}): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
const NOOP_SERVICE: AnalyticsService = {
|
||||
capture: () => undefined,
|
||||
captureSafety: async () => undefined,
|
||||
shutdown: async () => undefined,
|
||||
};
|
||||
|
||||
|
|
@ -165,6 +189,44 @@ export function createAnalyticsService(args: {
|
|||
}
|
||||
})();
|
||||
},
|
||||
captureSafety: async ({ eventName, distinctId, appVersion, properties, insertId }) => {
|
||||
// No consent re-check here — that's the entire point of this surface.
|
||||
// We still fall back gracefully when the installationId is missing
|
||||
// (cold start before the daemon has stamped one in app-config) by
|
||||
// synthesizing an anonymous distinct id rooted at the process boot.
|
||||
//
|
||||
// Returns a Promise that resolves AFTER `client.capture()` has run.
|
||||
// The fatal-shutdown path in server.ts awaits this before invoking
|
||||
// `shutdown()` so the event is guaranteed to be in posthog-node's
|
||||
// local queue when the flush starts — otherwise a fast `shutdown()`
|
||||
// would drain an empty queue and the crash signal would be lost.
|
||||
// See codex review on PR #2527 (Siri-Ray) for the original race.
|
||||
const resolvedInsertId = insertId ?? randomInsertId();
|
||||
try {
|
||||
const resolvedDistinctId =
|
||||
distinctId && distinctId.length > 0
|
||||
? distinctId
|
||||
: await readInstallationIdSafe(args.dataDir);
|
||||
client.capture({
|
||||
distinctId: resolvedDistinctId,
|
||||
event: eventName,
|
||||
properties: {
|
||||
...properties,
|
||||
event_id: resolvedInsertId,
|
||||
event_schema_version: EVENT_SCHEMA_VERSION,
|
||||
ui_version: appVersion,
|
||||
app_version: appVersion,
|
||||
device_id: resolvedDistinctId,
|
||||
client_type: 'daemon',
|
||||
capture_source: 'daemon/safety',
|
||||
$insert_id: resolvedInsertId,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Capture failures must never propagate. The whole point of this
|
||||
// path is best-effort observability into a degraded state.
|
||||
}
|
||||
},
|
||||
shutdown: async () => {
|
||||
try {
|
||||
await client.shutdown();
|
||||
|
|
@ -175,6 +237,24 @@ export function createAnalyticsService(args: {
|
|||
};
|
||||
}
|
||||
|
||||
const SYNTHETIC_DISTINCT_ID = `daemon-anon-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
async function readInstallationIdSafe(dataDir: string): Promise<string> {
|
||||
try {
|
||||
const cfg = await readAppConfig(dataDir);
|
||||
if (typeof cfg.installationId === 'string' && cfg.installationId.length > 0) {
|
||||
return cfg.installationId;
|
||||
}
|
||||
} catch {
|
||||
// fall through to synthetic id
|
||||
}
|
||||
return SYNTHETIC_DISTINCT_ID;
|
||||
}
|
||||
|
||||
function randomInsertId(): string {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
// Re-export so server.ts and route handlers don't need a second import
|
||||
// path; the canonical hash lives in @open-design/contracts/analytics so
|
||||
// the web bundle produces the same id for the same (projectId, fileName).
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
readInstallationFile,
|
||||
resolveInstallationDir,
|
||||
writeInstallationFile,
|
||||
type InstallationFilePatch,
|
||||
} from './installation.js';
|
||||
|
||||
// Plugin-system env knobs. See docs/plans/plugins-implementation.md F6 / F9.
|
||||
// Phase 1 only reads them; the GC worker that enforces snapshot expiry lands
|
||||
// in Phase 5. Centralized here to keep daemon modules from sprinkling magic
|
||||
|
|
@ -135,6 +142,15 @@ function validateTelemetry(raw: unknown): TelemetryPrefs | undefined {
|
|||
}
|
||||
|
||||
const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
||||
['amr', new Set([
|
||||
'VELA_BIN',
|
||||
'VELA_LINK_URL',
|
||||
'VELA_RUNTIME_KEY',
|
||||
'VELA_OPENCODE_BIN',
|
||||
'OPEN_DESIGN_AMR_PROFILE',
|
||||
'OPENCODE_TEST_HOME',
|
||||
])],
|
||||
['aider', new Set(['AIDER_BIN'])],
|
||||
['claude', new Set(['CLAUDE_CONFIG_DIR', 'CLAUDE_BIN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY'])],
|
||||
['codex', new Set(['CODEX_HOME', 'CODEX_BIN', 'OPENAI_BASE_URL', 'CODEX_API_KEY', 'OPENAI_API_KEY'])],
|
||||
['copilot', new Set(['COPILOT_BIN'])],
|
||||
|
|
@ -150,6 +166,7 @@ const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|||
['pi', new Set(['PI_BIN'])],
|
||||
['qoder', new Set(['QODER_BIN'])],
|
||||
['qwen', new Set(['QWEN_BIN'])],
|
||||
['trae-cli', new Set(['TRAE_CLI_BIN'])],
|
||||
['vibe', new Set(['VIBE_BIN'])],
|
||||
]);
|
||||
|
||||
|
|
@ -325,7 +342,58 @@ function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
|
|||
return result as AppConfigPrefs;
|
||||
}
|
||||
|
||||
// Fill in telemetry defaults when the saved config has no `telemetry`
|
||||
// field at all (fresh install, pre-disclosure). `metrics` / `content`
|
||||
// default to true so onboarding-funnel events emit from the first
|
||||
// render — without these defaults the gate at
|
||||
// `analytics.ts` (`if (cfg.telemetry?.metrics !== true) return`)
|
||||
// dropped every event a user fired before the post-onboarding
|
||||
// disclosure modal had a chance to set them. An EXPLICIT `false`
|
||||
// the user previously saved is preserved (only `undefined` gets
|
||||
// the new default), so opt-out users stay opted out across the
|
||||
// 0.7.x → 0.8.0 upgrade.
|
||||
function applyTelemetryDefaults(prefs: AppConfigPrefs): AppConfigPrefs {
|
||||
if (prefs.telemetry === undefined) {
|
||||
return {
|
||||
...prefs,
|
||||
telemetry: { metrics: true, content: true, artifactManifest: false },
|
||||
};
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> {
|
||||
const base = await readAppConfigFileOnly(dataDir);
|
||||
// Channel-root installation file is the new authoritative source for the
|
||||
// identity bits that must survive a namespace-scoped data-dir wipe. It
|
||||
// lives outside `<namespace>/data/` so a reinstall of the same channel
|
||||
// (which might churn the namespace token, or eventually clear per-
|
||||
// namespace data) keeps the same id.
|
||||
//
|
||||
// Migration: when this daemon is the first to boot with installation.json
|
||||
// support and finds an existing installationId in the legacy app-config
|
||||
// path, mirror it forward exactly once so PostHog continues to see the
|
||||
// same person across the 0.7.x → 0.8.0 upgrade. Without this mirror, the
|
||||
// user count would double when 0.8.0 ships.
|
||||
const installationDir = resolveInstallationDir(dataDir);
|
||||
const installation = await readInstallationFile(installationDir);
|
||||
if (typeof installation.installationId === 'string' && installation.installationId.length > 0) {
|
||||
return applyTelemetryDefaults({ ...base, installationId: installation.installationId });
|
||||
}
|
||||
if (typeof base.installationId === 'string' && base.installationId.length > 0) {
|
||||
// Best-effort migration. A write failure here doesn't break the read —
|
||||
// we still serve the legacy id. The next write through writeAppConfig
|
||||
// will retry the mirror.
|
||||
try {
|
||||
await writeInstallationFile(installationDir, { installationId: base.installationId });
|
||||
} catch {
|
||||
// swallow — observability beats correctness on this path
|
||||
}
|
||||
}
|
||||
return applyTelemetryDefaults(base);
|
||||
}
|
||||
|
||||
async function readAppConfigFileOnly(dataDir: string): Promise<AppConfigPrefs> {
|
||||
try {
|
||||
const raw = await readFile(configFile(dataDir), 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
|
|
@ -378,5 +446,26 @@ async function doWrite(
|
|||
const tmp = file + '.' + randomBytes(4).toString('hex') + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
|
||||
await rename(tmp, file);
|
||||
// Mirror the identity bits to the channel-root installation file so they
|
||||
// survive a namespace-scoped data-dir wipe. Only fires when the caller
|
||||
// explicitly touched `installationId` (avoiding noisy writes on every
|
||||
// unrelated app-config update). A write failure here doesn't roll back
|
||||
// the app-config write — the next read merges them transparently.
|
||||
if (Object.prototype.hasOwnProperty.call(partial, 'installationId')) {
|
||||
const id = next.installationId;
|
||||
// Caller explicitly touched installationId — mirror the outcome
|
||||
// (including the clear case) to installation.json so a future read
|
||||
// doesn't keep serving the old value out of the channel-root file.
|
||||
// "Delete my data" relies on this clear path.
|
||||
const installPatch: InstallationFilePatch = {
|
||||
installationId: typeof id === 'string' && id.length > 0 ? id : null,
|
||||
};
|
||||
try {
|
||||
await writeInstallationFile(resolveInstallationDir(dataDir), installPatch);
|
||||
} catch {
|
||||
// swallow — install file mirroring is best-effort; the canonical
|
||||
// app-config write already succeeded.
|
||||
}
|
||||
}
|
||||
return next as AppConfigPrefs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,13 @@ function cleanString(value: unknown): string | null {
|
|||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function inferReleaseChannelFromVersion(version: string): string | null {
|
||||
if (/(?:^|[-.])beta(?:[-.]|$)/i.test(version)) return 'beta';
|
||||
if (/(?:^|[-.])preview(?:[-.]|$)/i.test(version)) return 'preview';
|
||||
if (/(?:^|[-.])nightly(?:[-.]|$)/i.test(version)) return 'nightly';
|
||||
return version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1]?.split('.')[0] ?? null;
|
||||
}
|
||||
|
||||
export function isPackagedRuntime({
|
||||
resourcesPath = processWithResources.resourcesPath,
|
||||
execPath = process.execPath,
|
||||
|
|
@ -109,10 +116,10 @@ export function resolveAppVersionInfo({
|
|||
const version = cleanString(env.OD_APP_VERSION)
|
||||
?? cleanString(packageMetadata?.version)
|
||||
?? APP_VERSION_FALLBACK;
|
||||
const prereleaseChannel = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1]?.split('.')[0] ?? null;
|
||||
const inferredChannel = inferReleaseChannelFromVersion(version);
|
||||
const channel = cleanString(env.OD_RELEASE_CHANNEL)
|
||||
?? cleanString(env.OD_APP_CHANNEL)
|
||||
?? prereleaseChannel
|
||||
?? inferredChannel
|
||||
?? (packaged ? 'stable' : 'development');
|
||||
|
||||
return { version, channel, packaged, platform, arch };
|
||||
|
|
|
|||
|
|
@ -132,6 +132,13 @@ export function validateArtifactManifestInput(
|
|||
}
|
||||
}
|
||||
|
||||
if (manifest.primary !== undefined) {
|
||||
if (manifest.primary !== true) {
|
||||
const primaryErr = validateSupportingPath(manifest.primary);
|
||||
if (primaryErr) return { ok: false, error: `artifactManifest.primary ${primaryErr}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.supportingFiles !== undefined) {
|
||||
if (!Array.isArray(manifest.supportingFiles)) {
|
||||
return { ok: false, error: 'artifactManifest.supportingFiles must be an array' };
|
||||
|
|
@ -216,6 +223,12 @@ export function sanitizeManifest(
|
|||
renderer: manifest.renderer,
|
||||
status: typeof manifest.status === 'string' && ALLOWED_STATUS.has(manifest.status) ? manifest.status : 'complete',
|
||||
exports: manifest.exports,
|
||||
primary:
|
||||
manifest.primary === true
|
||||
? true
|
||||
: typeof manifest.primary === 'string'
|
||||
? manifest.primary.replace(/\\/g, '/')
|
||||
: undefined,
|
||||
supportingFiles: Array.isArray(manifest.supportingFiles)
|
||||
? manifest.supportingFiles.map((x) => String(x).replace(/\\/g, '/'))
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,8 @@
|
|||
// back as a `role: 'tool'` message → re-issue the completion. The chat surface
|
||||
// stays the same; the tool dispatch happens entirely daemon-side.
|
||||
//
|
||||
// Today we ship one tool — `generate_image` — backed by SenseAudio's
|
||||
// /v1/image/sync endpoint, since the BYOK chat session already authenticates
|
||||
// against SenseAudio with the same API key. Additional tools (TTS, video,
|
||||
// research) can be added here as the BYOK surface expands.
|
||||
// Today we ship image, video, and speech tools backed by SenseAudio endpoints,
|
||||
// since the BYOK chat session already authenticates with the same API key.
|
||||
|
||||
import path from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
|
@ -43,6 +41,18 @@ export function isSenseAudioImageModel(value: unknown): value is string {
|
|||
|
||||
const SENSEAUDIO_DEFAULT_BASE_URL = 'https://api.senseaudio.cn';
|
||||
const PROMPT_MAX_LENGTH = 2000;
|
||||
const SENSEAUDIO_TTS_MODEL = 'senseaudio-tts-1.5-260319';
|
||||
const SENSEAUDIO_DEFAULT_VOICE_ID = 'female_0033_b';
|
||||
const HEX_AUDIO_PATTERN = /^[0-9a-fA-F]+$/;
|
||||
|
||||
function appendSenseAudioApiPath(baseUrl: string, path: string): string {
|
||||
const url = new URL(baseUrl);
|
||||
const trimmed = url.pathname.replace(/\/+$/, '');
|
||||
url.pathname = /\/v\d+(\/|$)/.test(trimmed)
|
||||
? `${trimmed}${path}`
|
||||
: `${trimmed}/v1${path}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// SenseAudio video — the API only documents one model today, so the
|
||||
// wire id is a const. The chat tool's `generate_video` param surface
|
||||
|
|
@ -122,6 +132,30 @@ export const BYOK_SENSEAUDIO_TOOLS = [
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'generate_speech',
|
||||
description:
|
||||
'Generate a text-to-speech voiceover using SenseAudio TTS. Returns a URL pointing to the rendered MP3. Use this whenever the user asks for narration, voiceover, speech, TTS, or spoken audio. After this tool succeeds, reply with a clickable markdown link to the MP3.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Exact script to speak. Include only the words that should be spoken, not production notes.',
|
||||
},
|
||||
voice_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
`Optional SenseAudio voice id. Defaults to ${SENSEAUDIO_DEFAULT_VOICE_ID}.`,
|
||||
},
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
|
|
@ -206,6 +240,10 @@ export interface BYOKToolContext {
|
|||
* (e.g. 1 ms) to keep the suite fast without changing the polling
|
||||
* semantics. */
|
||||
videoPollIntervalMs?: number;
|
||||
/** Optional per-request init copied from the live chat turn. Used to
|
||||
* forward the current proxy dispatcher into every upstream/download
|
||||
* fetch the BYOK tool executor performs. */
|
||||
requestInit?: Pick<RequestInit, 'dispatcher'>;
|
||||
}
|
||||
|
||||
export interface ImageToolResult {
|
||||
|
|
@ -217,6 +255,112 @@ export interface ImageToolResult {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
function withToolRequestInit(
|
||||
ctx: BYOKToolContext,
|
||||
init: RequestInit,
|
||||
): RequestInit {
|
||||
return {
|
||||
...ctx.requestInit,
|
||||
...init,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeGenerateSpeech(
|
||||
args: { text?: unknown; voice_id?: unknown },
|
||||
ctx: BYOKToolContext,
|
||||
): Promise<ImageToolResult> {
|
||||
const text = typeof args.text === 'string' ? args.text.trim() : '';
|
||||
if (!text) return { ok: false, error: 'text is required' };
|
||||
|
||||
let dir: string;
|
||||
try {
|
||||
dir = await ensureProject(ctx.projectsRoot, ctx.projectId);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `invalid projectId for speech storage: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = ctx.upstreamApiKey;
|
||||
if (!apiKey) return { ok: false, error: 'no SenseAudio API key available' };
|
||||
|
||||
const voiceId =
|
||||
typeof args.voice_id === 'string' && args.voice_id.trim()
|
||||
? args.voice_id.trim()
|
||||
: SENSEAUDIO_DEFAULT_VOICE_ID;
|
||||
const baseUrl = ctx.upstreamBaseUrl || SENSEAUDIO_DEFAULT_BASE_URL;
|
||||
let data: {
|
||||
data?: { audio?: string };
|
||||
base_resp?: { status_code?: number; status_msg?: string };
|
||||
};
|
||||
try {
|
||||
const resp = await fetch(appendSenseAudioApiPath(baseUrl, '/t2a_v2'), withToolRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: SENSEAUDIO_TTS_MODEL,
|
||||
text,
|
||||
stream: false,
|
||||
voice_setting: {
|
||||
voice_id: voiceId,
|
||||
speed: 1,
|
||||
vol: 1,
|
||||
pitch: 0,
|
||||
},
|
||||
audio_setting: {
|
||||
format: 'mp3',
|
||||
sample_rate: 32000,
|
||||
bitrate: 128000,
|
||||
channel: 2,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const respText = await resp.text();
|
||||
if (!resp.ok) {
|
||||
return { ok: false, error: `senseaudio speech ${resp.status}: ${respText.slice(0, 240)}` };
|
||||
}
|
||||
try {
|
||||
data = JSON.parse(respText) as typeof data;
|
||||
} catch {
|
||||
return { ok: false, error: `senseaudio speech non-JSON: ${respText.slice(0, 200)}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
if (data?.base_resp && data.base_resp.status_code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `senseaudio speech api error ${data.base_resp.status_code}: ${data.base_resp.status_msg || 'unknown'}`,
|
||||
};
|
||||
}
|
||||
const hex = data?.data?.audio;
|
||||
if (typeof hex !== 'string' || !hex) {
|
||||
return { ok: false, error: 'senseaudio speech response missing data.audio' };
|
||||
}
|
||||
if (hex.length % 2 !== 0 || !HEX_AUDIO_PATTERN.test(hex)) {
|
||||
return { ok: false, error: 'senseaudio speech response contained invalid hex audio' };
|
||||
}
|
||||
const bytes = Buffer.from(hex, 'hex');
|
||||
if (bytes.length === 0) return { ok: false, error: 'senseaudio speech decoded zero bytes' };
|
||||
|
||||
const id = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
|
||||
const filename = `byok-speech-${id}.mp3`;
|
||||
await writeFile(path.join(dir, filename), bytes);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
url: `/api/projects/${encodeURIComponent(ctx.projectId)}/files/${filename}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeAspectRatio(raw: unknown): string {
|
||||
if (typeof raw !== 'string') return '1:1';
|
||||
return ASPECT_TO_SIZE[raw] ? raw : '1:1';
|
||||
|
|
@ -290,7 +434,7 @@ export async function executeGenerateImage(
|
|||
const trimmedBase = baseUrl.replace(/\/+$/, '');
|
||||
let imageUrl: string;
|
||||
try {
|
||||
const resp = await fetch(`${trimmedBase}/v1/image/sync`, {
|
||||
const resp = await fetch(`${trimmedBase}/v1/image/sync`, withToolRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
|
|
@ -301,7 +445,7 @@ export async function executeGenerateImage(
|
|||
prompt,
|
||||
size,
|
||||
}),
|
||||
});
|
||||
}));
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
return {
|
||||
|
|
@ -339,7 +483,7 @@ export async function executeGenerateImage(
|
|||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
const imgResp = await fetch(imageUrl, { redirect: 'error' });
|
||||
const imgResp = await fetch(imageUrl, withToolRequestInit(ctx, { redirect: 'error' }));
|
||||
if (!imgResp.ok) {
|
||||
return { ok: false, error: `image download ${imgResp.status}` };
|
||||
}
|
||||
|
|
@ -466,7 +610,7 @@ export async function executeGenerateVideo(
|
|||
// Step 1: POST /v1/video/create → task_id.
|
||||
let taskId: string;
|
||||
try {
|
||||
const resp = await fetch(`${trimmedBase}/v1/video/create`, {
|
||||
const resp = await fetch(`${trimmedBase}/v1/video/create`, withToolRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
|
|
@ -480,7 +624,7 @@ export async function executeGenerateVideo(
|
|||
ratio,
|
||||
provider_specific: { generate_audio: generateAudio },
|
||||
}),
|
||||
});
|
||||
}));
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
return {
|
||||
|
|
@ -509,10 +653,10 @@ export async function executeGenerateVideo(
|
|||
try {
|
||||
statusResp = await fetch(
|
||||
`${trimmedBase}/v1/video/status?id=${encodeURIComponent(taskId)}`,
|
||||
{
|
||||
withToolRequestInit(ctx, {
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${apiKey}` },
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
|
|
@ -572,7 +716,7 @@ export async function executeGenerateVideo(
|
|||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
const videoResp = await fetch(videoUrl, { redirect: 'error' });
|
||||
const videoResp = await fetch(videoUrl, withToolRequestInit(ctx, { redirect: 'error' }));
|
||||
if (!videoResp.ok) {
|
||||
return { ok: false, error: `video download ${videoResp.status}` };
|
||||
}
|
||||
|
|
@ -595,4 +739,3 @@ export async function executeGenerateVideo(
|
|||
url: `/api/projects/${encodeURIComponent(ctx.projectId)}/files/${filename}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,45 @@
|
|||
import type { Express } from 'express';
|
||||
import type { RouteDeps } from './server-context.js';
|
||||
import { seedProviderIfMissing } from './media-config.js';
|
||||
import {
|
||||
buildLegacyMaxTokensParam,
|
||||
buildMaxCompletionTokensParam,
|
||||
buildOpenAIChatTokenParam,
|
||||
isUnsupportedMaxTokensError,
|
||||
} from './openai-chat-token-params.js';
|
||||
import {
|
||||
BYOK_SENSEAUDIO_TOOLS,
|
||||
executeGenerateImage,
|
||||
executeGenerateSpeech,
|
||||
executeGenerateVideo,
|
||||
isSenseAudioImageModel,
|
||||
type BYOKToolContext,
|
||||
} from './byok-tools.js';
|
||||
import { isSafeId as isSafeProjectId } from './projects.js';
|
||||
import { projectKindToTracking } from '@open-design/contracts/analytics';
|
||||
import { validateBaseUrlResolved } from './connectionTest.js';
|
||||
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
|
||||
import { googleStreamGenerateContentUrl } from './google-models.js';
|
||||
import { parseMediaExecutionPolicyInput } from './media-policy.js';
|
||||
|
||||
export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'chat' | 'agents' | 'critique' | 'validation' | 'lifecycle' | 'paths'> {}
|
||||
// Allowlist for the `/feedback` route. Mirrors the
|
||||
// ChatMessageFeedbackReasonCode union in packages/contracts/src/api/chat.ts.
|
||||
// Kept inline (not imported as a runtime value, since the contract type is
|
||||
// type-only) so a stale client can't poison Langfuse with unknown categories.
|
||||
const FEEDBACK_REASON_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
'matched_request',
|
||||
'strong_visual',
|
||||
'useful_structure',
|
||||
'easy_to_continue',
|
||||
'followed_design_system',
|
||||
'missed_request',
|
||||
'weak_visual',
|
||||
'incomplete_output',
|
||||
'hard_to_use',
|
||||
'missed_design_system',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'chat' | 'agents' | 'critique' | 'validation' | 'lifecycle' | 'paths' | 'telemetry'> {}
|
||||
|
||||
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
||||
const { db, design } = ctx;
|
||||
|
|
@ -122,13 +149,89 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Receives the user's thumbs-up/down (+ reason codes) for an assistant
|
||||
// turn and forwards it to Langfuse as a `score-create`. Web persists the
|
||||
// feedback itself via PUT /messages/:id; this endpoint exists only as a
|
||||
// telemetry side channel — the daemon is the single network egress for
|
||||
// Langfuse and gates on `telemetry.metrics + telemetry.content` consent.
|
||||
//
|
||||
// The consent + sink decision is fast (awaits a small file read, no
|
||||
// network); we await it so the response status honestly reflects whether
|
||||
// the score was enqueued, skipped for consent, or skipped because no
|
||||
// Langfuse sink is configured. The actual Langfuse network call happens
|
||||
// as a detached promise inside the bridge.
|
||||
app.post('/api/runs/:id/feedback', async (req, res) => {
|
||||
const runId = req.params.id;
|
||||
const body = (req.body ?? {}) as Partial<{
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
assistantMessageId: string;
|
||||
rating: 'positive' | 'negative';
|
||||
reasonCodes: string[];
|
||||
hasCustomReason: boolean;
|
||||
customReason: string;
|
||||
}>;
|
||||
if (!runId) {
|
||||
return sendApiError(res, 400, 'INVALID_RUN_ID', 'runId missing');
|
||||
}
|
||||
if (body.rating !== 'positive' && body.rating !== 'negative') {
|
||||
return sendApiError(res, 400, 'INVALID_RATING', 'rating must be positive or negative');
|
||||
}
|
||||
// Drop anything outside the contract-side reason allowlist and
|
||||
// deduplicate; otherwise a malformed or replayed client payload could
|
||||
// create unknown Langfuse categories or duplicate score ids in the
|
||||
// same batch.
|
||||
const reasonCodes = Array.isArray(body.reasonCodes)
|
||||
? Array.from(
|
||||
new Set(
|
||||
body.reasonCodes.filter(
|
||||
(c): c is string =>
|
||||
typeof c === 'string' && FEEDBACK_REASON_ALLOWLIST.has(c),
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const customReason = typeof body.customReason === 'string' ? body.customReason : '';
|
||||
const reportFeedback = ctx.telemetry?.reportFeedback;
|
||||
if (!reportFeedback) {
|
||||
res.status(202).json({ status: 'skipped_no_sink' });
|
||||
return;
|
||||
}
|
||||
// Build score metadata bag that lands in the Langfuse score body.
|
||||
// Mirrors the PostHog event so analysts can cross-reference.
|
||||
const scoreMetadata: Record<string, unknown> = {
|
||||
projectId: body.projectId,
|
||||
conversationId: body.conversationId,
|
||||
assistantMessageId: body.assistantMessageId,
|
||||
hasCustomReason: body.hasCustomReason === true,
|
||||
customReason,
|
||||
};
|
||||
const outcome = await reportFeedback({
|
||||
runId,
|
||||
rating: body.rating,
|
||||
reasonCodes,
|
||||
hasCustomReason: body.hasCustomReason === true,
|
||||
customReason,
|
||||
scoreMetadata,
|
||||
});
|
||||
res.status(202).json(outcome);
|
||||
});
|
||||
|
||||
app.post('/api/chat', (req, res) => {
|
||||
if (isDaemonShuttingDown()) {
|
||||
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
|
||||
}
|
||||
const run = design.runs.create();
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const mediaExecution = parseMediaExecutionPolicyInput(
|
||||
(body as { mediaExecution?: unknown }).mediaExecution,
|
||||
);
|
||||
if (!mediaExecution.ok) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
|
||||
}
|
||||
const runBody = { ...body, mediaExecution: mediaExecution.policy };
|
||||
const run = design.runs.create(runBody);
|
||||
design.runs.stream(run, req, res);
|
||||
design.runs.start(run, () => startChatRun(req.body || {}, run));
|
||||
design.runs.start(run, () => startChatRun(runBody, run));
|
||||
});
|
||||
|
||||
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
|
||||
|
|
@ -176,15 +279,21 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
);
|
||||
}
|
||||
try {
|
||||
const result = await listProviderModels({
|
||||
protocol,
|
||||
baseUrl: body.baseUrl,
|
||||
apiKey: body.apiKey,
|
||||
apiVersion:
|
||||
typeof body.apiVersion === 'string' ? body.apiVersion : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return res.json(result);
|
||||
const proxyDispatcher = proxyDispatcherRequestInit();
|
||||
try {
|
||||
const result = await listProviderModels({
|
||||
protocol,
|
||||
baseUrl: body.baseUrl,
|
||||
apiKey: body.apiKey,
|
||||
apiVersion:
|
||||
typeof body.apiVersion === 'string' ? body.apiVersion : undefined,
|
||||
signal: controller.signal,
|
||||
requestInit: proxyDispatcher.requestInit,
|
||||
});
|
||||
return res.json(result);
|
||||
} finally {
|
||||
await proxyDispatcher.close();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(
|
||||
`[provider:models] uncaught: ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
|
@ -577,9 +686,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
}
|
||||
|
||||
const sse = createSseResponse(res);
|
||||
sse.send('start', { model });
|
||||
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
|
||||
try {
|
||||
proxyDispatcher = proxyDispatcherRequestInit();
|
||||
sse.send('start', { model });
|
||||
const response = await fetch(url, {
|
||||
...proxyDispatcher.requestInit,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -628,6 +740,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
console.error(`[proxy:anthropic] internal error: ${err.message}`);
|
||||
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
|
||||
sse.end();
|
||||
} finally {
|
||||
await proxyDispatcher?.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -669,15 +783,20 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
const payload: any = {
|
||||
model,
|
||||
messages: payloadMessages,
|
||||
max_tokens:
|
||||
...buildOpenAIChatTokenParam(
|
||||
model,
|
||||
typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 8192,
|
||||
),
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const sse = createSseResponse(res);
|
||||
sse.send('start', { model });
|
||||
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
|
||||
try {
|
||||
proxyDispatcher = proxyDispatcherRequestInit();
|
||||
sse.send('start', { model });
|
||||
const response = await fetch(url, {
|
||||
...proxyDispatcher.requestInit,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -724,6 +843,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
console.error(`[proxy:openai] internal error: ${err.message}`);
|
||||
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
|
||||
sse.end();
|
||||
} finally {
|
||||
await proxyDispatcher?.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -779,38 +900,70 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
payloadMessages.unshift({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
|
||||
const effectiveMaxTokens =
|
||||
typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 8192;
|
||||
const payload = {
|
||||
...(usesVersionedOpenAIPath ? { model } : {}),
|
||||
messages: payloadMessages,
|
||||
max_tokens:
|
||||
typeof maxTokens === 'number' && maxTokens > 0 ? maxTokens : 8192,
|
||||
...buildLegacyMaxTokensParam(effectiveMaxTokens),
|
||||
stream: true,
|
||||
};
|
||||
const retryPayload = {
|
||||
...(usesVersionedOpenAIPath ? { model } : {}),
|
||||
messages: payloadMessages,
|
||||
...buildMaxCompletionTokensParam(effectiveMaxTokens),
|
||||
stream: true,
|
||||
};
|
||||
|
||||
const sse = createSseResponse(res);
|
||||
sse.send('start', { model });
|
||||
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
proxyDispatcher = proxyDispatcherRequestInit();
|
||||
sse.send('start', { model });
|
||||
const requestInit = {
|
||||
...proxyDispatcher.requestInit,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': apiKey,
|
||||
},
|
||||
redirect: 'error' as const,
|
||||
};
|
||||
let response = await fetch(url, {
|
||||
...requestInit,
|
||||
body: JSON.stringify(payload),
|
||||
redirect: 'error',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(
|
||||
`[proxy:azure] upstream error: ${response.status} ${redactAuthTokens(errorText)}`,
|
||||
);
|
||||
sendProxyError(sse, `Upstream error: ${response.status}`, {
|
||||
code: proxyErrorCode(response.status),
|
||||
details: errorText,
|
||||
retryable: response.status === 429 || response.status >= 500,
|
||||
});
|
||||
return sse.end();
|
||||
let errorText = await response.text();
|
||||
if (
|
||||
response.status === 400 &&
|
||||
isUnsupportedMaxTokensError(errorText)
|
||||
) {
|
||||
console.warn(
|
||||
`[proxy:azure] retrying request with max_completion_tokens deployment=${model}`,
|
||||
);
|
||||
response = await fetch(url, {
|
||||
...requestInit,
|
||||
body: JSON.stringify(retryPayload),
|
||||
});
|
||||
if (response.ok) {
|
||||
errorText = '';
|
||||
} else {
|
||||
errorText = await response.text();
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[proxy:azure] upstream error: ${response.status} ${redactAuthTokens(errorText)}`,
|
||||
);
|
||||
sendProxyError(sse, `Upstream error: ${response.status}`, {
|
||||
code: proxyErrorCode(response.status),
|
||||
details: errorText,
|
||||
retryable: response.status === 429 || response.status >= 500,
|
||||
});
|
||||
return sse.end();
|
||||
}
|
||||
}
|
||||
|
||||
let ended = false;
|
||||
|
|
@ -837,6 +990,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
console.error(`[proxy:azure] internal error: ${err.message}`);
|
||||
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
|
||||
sse.end();
|
||||
} finally {
|
||||
await proxyDispatcher?.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -865,8 +1020,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
);
|
||||
}
|
||||
|
||||
const clean = effectiveBaseUrl.replace(/\/+$/, '');
|
||||
const url = `${clean}/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`;
|
||||
const url = googleStreamGenerateContentUrl(effectiveBaseUrl, model);
|
||||
console.log(
|
||||
`[proxy:google] ${req.method} ${validated.parsed!.hostname} model=${model}`,
|
||||
);
|
||||
|
|
@ -887,9 +1041,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
}
|
||||
|
||||
const sse = createSseResponse(res);
|
||||
sse.send('start', { model });
|
||||
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
|
||||
try {
|
||||
proxyDispatcher = proxyDispatcherRequestInit();
|
||||
sse.send('start', { model });
|
||||
const response = await fetch(url, {
|
||||
...proxyDispatcher.requestInit,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -937,6 +1094,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
console.error(`[proxy:google] internal error: ${err.message}`);
|
||||
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
|
||||
sse.end();
|
||||
} finally {
|
||||
await proxyDispatcher?.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -974,9 +1133,12 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
}
|
||||
|
||||
const sse = createSseResponse(res);
|
||||
sse.send('start', { model });
|
||||
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
|
||||
try {
|
||||
proxyDispatcher = proxyDispatcherRequestInit();
|
||||
sse.send('start', { model });
|
||||
const response = await fetch(url, {
|
||||
...proxyDispatcher.requestInit,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify(payload),
|
||||
|
|
@ -1012,6 +1174,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
console.error(`[proxy:ollama] internal error: ${err.message}`);
|
||||
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
|
||||
sse.end();
|
||||
} finally {
|
||||
await proxyDispatcher?.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1107,12 +1271,15 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
? byokImageModel
|
||||
: undefined;
|
||||
|
||||
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
|
||||
|
||||
const toolCtx: BYOKToolContext = {
|
||||
projectRoot: ctx.paths.PROJECT_ROOT,
|
||||
projectsRoot: ctx.paths.PROJECTS_DIR,
|
||||
projectId,
|
||||
upstreamApiKey: apiKey,
|
||||
upstreamBaseUrl: effectiveBaseUrl,
|
||||
requestInit: {},
|
||||
// Spread-conditional because tsconfig's exactOptionalPropertyTypes
|
||||
// forbids `field: undefined` on an optional slot. The byok-tools
|
||||
// executor reads `ctx.defaultImageModel` with `isSenseAudioImageModel`
|
||||
|
|
@ -1141,6 +1308,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
tool_choice: 'auto',
|
||||
};
|
||||
const response = await fetch(url, {
|
||||
...toolCtx.requestInit,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -1255,32 +1423,35 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
const executeOneTool = async (call: {
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}): Promise<{ ok: boolean; url?: string; error?: string; kind?: 'image' | 'video' }> => {
|
||||
}): Promise<{ ok: boolean; url?: string; error?: string; kind?: 'image' | 'video' | 'speech' }> => {
|
||||
const fnName = call?.function?.name ?? '';
|
||||
if (fnName !== 'generate_image' && fnName !== 'generate_video') {
|
||||
if (fnName !== 'generate_image' && fnName !== 'generate_video' && fnName !== 'generate_speech') {
|
||||
return {
|
||||
ok: false,
|
||||
error: `unknown tool: ${fnName || 'unnamed'}`,
|
||||
};
|
||||
}
|
||||
const toolKind = fnName === 'generate_image' ? 'image' : fnName === 'generate_video' ? 'video' : 'speech';
|
||||
let args: any = {};
|
||||
try {
|
||||
args = JSON.parse(call.function.arguments || '{}');
|
||||
} catch {
|
||||
return { ok: false, error: 'tool arguments were not valid JSON' };
|
||||
return { ok: false, error: 'tool arguments were not valid JSON', kind: toolKind };
|
||||
}
|
||||
if (fnName === 'generate_image') {
|
||||
const result = await executeGenerateImage(args, toolCtx);
|
||||
return { ...result, kind: 'image' };
|
||||
}
|
||||
if (fnName === 'generate_speech') {
|
||||
const result = await executeGenerateSpeech(args, toolCtx);
|
||||
return { ...result, kind: 'speech' };
|
||||
}
|
||||
// generate_video — longer (up to 5 min), async-with-polling.
|
||||
const result = await executeGenerateVideo(args, toolCtx);
|
||||
return { ...result, kind: 'video' };
|
||||
};
|
||||
|
||||
const sse = createSseResponse(res);
|
||||
sse.send('start', { model });
|
||||
|
||||
// SenseAudio's gateway issues one API key that works for both
|
||||
// /v1/chat/completions and the image / TTS surfaces. Mirror the
|
||||
// BYOK key into media-config so the CLI agent path (`od media
|
||||
|
|
@ -1307,6 +1478,9 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
});
|
||||
|
||||
try {
|
||||
proxyDispatcher = proxyDispatcherRequestInit();
|
||||
toolCtx.requestInit = proxyDispatcher.requestInit;
|
||||
sse.send('start', { model });
|
||||
for (let loop = 0; loop < MAX_BYOK_TOOL_LOOPS; loop++) {
|
||||
const turn = await runSenseAudioTurn(sse, workingMessages);
|
||||
if (turn.kind === 'error') return sse.end();
|
||||
|
|
@ -1339,9 +1513,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
const content = result.ok
|
||||
? result.kind === 'video'
|
||||
? `Video generated successfully. URL: ${result.url}. Reply to the user with a clickable markdown link, e.g. [▶ Play video](${result.url}). Do NOT use markdown image syntax — the chat renderer does not embed <video> tags.`
|
||||
: result.kind === 'speech'
|
||||
? `Speech generated successfully. URL: ${result.url}. Reply to the user with a clickable markdown link to the MP3, e.g. [▶ Play voiceover](${result.url}).`
|
||||
: `Image generated successfully. URL: ${result.url}. Reply to the user with: `
|
||||
: result.kind === 'video'
|
||||
? `Video generation failed: ${result.error}. Apologize briefly and suggest a retry with a more specific prompt or a shorter duration.`
|
||||
: result.kind === 'speech'
|
||||
? `Speech generation failed: ${result.error}. Apologize briefly and suggest a retry with a shorter script or a valid voice id.`
|
||||
: `Image generation failed: ${result.error}. Apologize briefly and suggest a retry with a more specific prompt.`;
|
||||
workingMessages.push({
|
||||
role: 'tool',
|
||||
|
|
@ -1362,6 +1540,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
console.error(`[proxy:senseaudio] internal error: ${err.message}`);
|
||||
sendProxyError(sse, err.message, { code: 'INTERNAL_ERROR' });
|
||||
sse.end();
|
||||
} finally {
|
||||
await proxyDispatcher?.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ export function diagnoseClaudeCliFailure(
|
|||
const authFailure =
|
||||
/\b401\b/.test(text) ||
|
||||
/apikeysource["'\s:]+none/i.test(text) ||
|
||||
/not logged in/i.test(text) ||
|
||||
/please run \/login/i.test(text) ||
|
||||
/(auth|oauth|credential|token).*(fail|invalid|missing|expired|not found|none|unauthorized)/i.test(text) ||
|
||||
/(unauthorized|invalid api key|missing api key|could not authenticate|authentication failed)/i.test(text);
|
||||
if (authFailure && hasCustomBaseUrl) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
121
apps/daemon/src/codex-cli.ts
Normal file
121
apps/daemon/src/codex-cli.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Thin wrapper over `codex mcp add|remove|get` so the Settings panel can
|
||||
// offer a one-click "Install to Codex" toggle instead of asking the user
|
||||
// to paste TOML into ~/.codex/config.toml. We shell out to the bundled
|
||||
// Codex CLI rather than rewriting config.toml ourselves so we inherit
|
||||
// Codex's own merge / dedupe / validation rules.
|
||||
//
|
||||
// CodexRunner is injected so tests can stub spawn without poking the
|
||||
// global child_process module; production uses defaultCodexRunner which
|
||||
// is a thin spawn() wrapper with a 30s timeout.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export interface CodexRunnerResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface CodexRunner {
|
||||
run(args: string[], opts?: { env?: Record<string, string> }): Promise<CodexRunnerResult>;
|
||||
}
|
||||
|
||||
const defaultCodexRunner: CodexRunner = {
|
||||
run(args, opts) {
|
||||
return new Promise<CodexRunnerResult>((resolve, reject) => {
|
||||
const child = spawn('codex', args, {
|
||||
env: { ...process.env, ...(opts?.env ?? {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('codex CLI timed out after 30s'));
|
||||
}, 30_000);
|
||||
child.stdout?.on('data', (d) => {
|
||||
stdout += String(d);
|
||||
});
|
||||
child.stderr?.on('data', (d) => {
|
||||
stderr += String(d);
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ exitCode: code ?? -1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let _runner: CodexRunner | null = null;
|
||||
|
||||
// Tests inject a stub runner; production callers use the default. Pass
|
||||
// null to restore the default (called from afterEach in test suites).
|
||||
export function setCodexRunner(runner: CodexRunner | null): void {
|
||||
_runner = runner;
|
||||
}
|
||||
|
||||
function activeRunner(): CodexRunner {
|
||||
return _runner ?? defaultCodexRunner;
|
||||
}
|
||||
|
||||
export interface CodexInstallStatus {
|
||||
// True when the `codex` CLI was found and is runnable. False = the
|
||||
// user does not have Codex CLI on PATH (the UI should show the
|
||||
// one-click button as disabled with an explanatory tooltip).
|
||||
available: boolean;
|
||||
// True when an MCP server with `name` is already registered in
|
||||
// ~/.codex/config.toml. Drives the toggle's "install" vs "uninstall"
|
||||
// label.
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
export async function probeCodexInstall(name: string): Promise<CodexInstallStatus> {
|
||||
try {
|
||||
const result = await activeRunner().run(['mcp', 'get', name]);
|
||||
return { available: true, installed: result.exitCode === 0 };
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code === 'ENOENT') {
|
||||
return { available: false, installed: false };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CodexInstallSpec {
|
||||
// MCP server name as it will appear in ~/.codex/config.toml. We
|
||||
// hard-code "open-design" at the route layer but keep the parameter
|
||||
// explicit so the helper can later be reused for other server names.
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function installCodexMcp(spec: CodexInstallSpec): Promise<void> {
|
||||
const argv: string[] = ['mcp', 'add', spec.name];
|
||||
for (const [key, value] of Object.entries(spec.env)) {
|
||||
argv.push('--env', `${key}=${value}`);
|
||||
}
|
||||
argv.push('--', spec.command, ...spec.args);
|
||||
const result = await activeRunner().run(argv);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`codex mcp add failed: ${failureDetail(result)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallCodexMcp(name: string): Promise<void> {
|
||||
const result = await activeRunner().run(['mcp', 'remove', name]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`codex mcp remove failed: ${failureDetail(result)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function failureDetail(result: CodexRunnerResult): string {
|
||||
return result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`;
|
||||
}
|
||||
|
|
@ -21,13 +21,19 @@ import { promises as dnsPromises } from 'node:dns';
|
|||
import { promises as fsp } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Agent, EnvHttpProxyAgent, Socks5ProxyAgent } from 'undici';
|
||||
import type { Dispatcher, Pool } from 'undici';
|
||||
import {
|
||||
applyAgentLaunchEnv,
|
||||
getAgentDef,
|
||||
resolveAgentLaunch,
|
||||
spawnEnvForAgent,
|
||||
} from './agents.js';
|
||||
import { createCommandInvocation } from '@open-design/platform';
|
||||
import {
|
||||
createCommandInvocation,
|
||||
mergeProxyAwareEnv,
|
||||
resolveSystemProxyEnv,
|
||||
} from '@open-design/platform';
|
||||
import { attachAcpSession } from './acp.js';
|
||||
import { attachPiRpcSession } from './pi-rpc.js';
|
||||
import { createClaudeStreamHandler } from './claude-stream.js';
|
||||
|
|
@ -40,20 +46,30 @@ import {
|
|||
cursorAuthGuidance,
|
||||
probeAgentAuthStatus,
|
||||
} from './runtimes/auth.js';
|
||||
import {
|
||||
buildLegacyMaxTokensParam,
|
||||
buildMaxCompletionTokensParam,
|
||||
buildOpenAIChatTokenParam,
|
||||
isUnsupportedMaxTokensError,
|
||||
} from './openai-chat-token-params.js';
|
||||
import type { AgentCliEnvPrefs } from './app-config.js';
|
||||
import type { RuntimeAgentDef } from './runtimes/types.js';
|
||||
import { resolveModelForAgent } from './runtimes/models.js';
|
||||
import {
|
||||
isBlockedExternalApiHostname,
|
||||
isLoopbackApiHost,
|
||||
validateBaseUrl,
|
||||
type AgentTestRequest,
|
||||
type BaseUrlValidationResult,
|
||||
type ConnectionTestDiagnostics,
|
||||
type ConnectionTestKind,
|
||||
type ConnectionTestPhase,
|
||||
type ConnectionTestProtocol,
|
||||
type ConnectionTestResponse,
|
||||
type ParsedBaseUrl,
|
||||
type ProviderTestRequest,
|
||||
} from '@open-design/contracts/api/connectionTest';
|
||||
import { googleGenerateContentUrl } from './google-models.js';
|
||||
|
||||
export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
|
||||
|
||||
|
|
@ -158,6 +174,7 @@ export async function assertExternalAssetUrl(
|
|||
// Override with OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS for slow networks
|
||||
// or distant providers; invalid values fall back to the default.
|
||||
const DEFAULT_PROVIDER_TIMEOUT_MS = 12_000;
|
||||
const LOOPBACK_NO_PROXY_TOKENS = ['localhost', '127.0.0.1', '[::1]'] as const;
|
||||
// CLI boot time is dominated by adapter auth/session restore; the heavy
|
||||
// adapters (Codex, Cursor Agent) regularly take 5–10 s on a cold first
|
||||
// run, so 45 s leaves headroom without making a hung child invisible.
|
||||
|
|
@ -201,6 +218,336 @@ function agentTimeoutMs(): number {
|
|||
DEFAULT_AGENT_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeNoProxyWithLoopbackDefaults(noProxy: string | undefined): string | null {
|
||||
if (noProxy?.split(/[\s,]+/).some((token) => token.trim() === '*')) return '*';
|
||||
const seen = new Set<string>();
|
||||
const values: string[] = [];
|
||||
for (const rawToken of [
|
||||
...(noProxy ? noProxy.split(/[\s,]+/) : []),
|
||||
...LOOPBACK_NO_PROXY_TOKENS,
|
||||
]) {
|
||||
const token = rawToken.trim() === '::1' ? '[::1]' : rawToken.trim();
|
||||
if (!token || seen.has(token)) continue;
|
||||
seen.add(token);
|
||||
values.push(token);
|
||||
}
|
||||
return values.length > 0 ? values.join(',') : null;
|
||||
}
|
||||
|
||||
function defaultPortForProtocol(protocol: string): string {
|
||||
if (protocol === 'http:') return '80';
|
||||
if (protocol === 'https:') return '443';
|
||||
return '';
|
||||
}
|
||||
|
||||
function splitNoProxyHostAndPort(token: string): { host: string; port: string } {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) return { host: '', port: '' };
|
||||
if (trimmed.startsWith('[')) {
|
||||
const closingBracket = trimmed.indexOf(']');
|
||||
if (closingBracket === -1) return { host: trimmed.toLowerCase(), port: '' };
|
||||
const host = trimmed.slice(0, closingBracket + 1).toLowerCase();
|
||||
const port = trimmed.slice(closingBracket + 1).replace(/^:/, '');
|
||||
return { host, port };
|
||||
}
|
||||
const firstColon = trimmed.indexOf(':');
|
||||
const lastColon = trimmed.lastIndexOf(':');
|
||||
if (firstColon !== -1 && firstColon === lastColon) {
|
||||
return {
|
||||
host: trimmed.slice(0, firstColon).toLowerCase(),
|
||||
port: trimmed.slice(firstColon + 1),
|
||||
};
|
||||
}
|
||||
return { host: trimmed.toLowerCase(), port: '' };
|
||||
}
|
||||
|
||||
function noProxyTokenMatchesUrl(token: string, url: URL): boolean {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed === '*') return true;
|
||||
if (trimmed === '<local>') return !url.hostname.includes('.') && !url.hostname.includes(':');
|
||||
const { host, port } = splitNoProxyHostAndPort(trimmed.replace(/^\*\./, '.'));
|
||||
if (!host) return false;
|
||||
const normalizedHost = host === '::1' ? '[::1]' : host;
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const matchesHost = normalizedHost.startsWith('.')
|
||||
? hostname === normalizedHost.slice(1) || hostname.endsWith(normalizedHost)
|
||||
: hostname === normalizedHost || hostname.endsWith(`.${normalizedHost}`);
|
||||
if (!matchesHost) return false;
|
||||
if (!port) return true;
|
||||
return (url.port || defaultPortForProtocol(url.protocol)) === port;
|
||||
}
|
||||
|
||||
function shouldBypassProxyForUrl(target: string | URL, noProxy: string | null): boolean {
|
||||
if (!noProxy) return false;
|
||||
let url: URL;
|
||||
try {
|
||||
url = target instanceof URL ? target : new URL(target);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return noProxy.split(/[\s,]+/).some((token) => noProxyTokenMatchesUrl(token, url));
|
||||
}
|
||||
|
||||
function socksProxyAgentOptions(
|
||||
options: Pool.Options,
|
||||
): ConstructorParameters<typeof Socks5ProxyAgent>[1] {
|
||||
return {
|
||||
...(options.bodyTimeout === undefined ? {} : { bodyTimeout: options.bodyTimeout }),
|
||||
...(options.headersTimeout === undefined ? {} : { headersTimeout: options.headersTimeout }),
|
||||
};
|
||||
}
|
||||
|
||||
class NoProxyAwareSocksProxyAgent {
|
||||
private readonly directAgent: Agent;
|
||||
|
||||
private readonly socksAgent: Socks5ProxyAgent;
|
||||
|
||||
private readonly socksDispatchTimeouts: Pick<Dispatcher.DispatchOptions, 'bodyTimeout' | 'headersTimeout'>;
|
||||
|
||||
constructor(
|
||||
private readonly noProxy: string | null,
|
||||
socksProxy: string,
|
||||
options: Pool.Options,
|
||||
) {
|
||||
this.directAgent = new Agent(options as ConstructorParameters<typeof Agent>[0]);
|
||||
this.socksAgent = new Socks5ProxyAgent(socksProxy, socksProxyAgentOptions(options));
|
||||
this.socksDispatchTimeouts = {
|
||||
...(options.bodyTimeout === undefined ? {} : { bodyTimeout: options.bodyTimeout }),
|
||||
...(options.headersTimeout === undefined
|
||||
? {}
|
||||
: { headersTimeout: options.headersTimeout }),
|
||||
};
|
||||
}
|
||||
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||
const origin = options.origin;
|
||||
const targetUrl =
|
||||
typeof origin === 'string' || origin instanceof URL
|
||||
? new URL(options.path, origin)
|
||||
: null;
|
||||
const dispatcher =
|
||||
targetUrl && shouldBypassProxyForUrl(targetUrl, this.noProxy)
|
||||
? this.directAgent
|
||||
: this.socksAgent;
|
||||
return dispatcher.dispatch(
|
||||
dispatcher === this.socksAgent ? { ...this.socksDispatchTimeouts, ...options } : options,
|
||||
handler,
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await Promise.all([this.directAgent.close(), this.socksAgent.close()]);
|
||||
}
|
||||
|
||||
async destroy(error?: Error | null): Promise<void> {
|
||||
await Promise.all([
|
||||
this.directAgent.destroy(error ?? null),
|
||||
this.socksAgent.destroy(error ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class NoProxyAwareEnvProxyAgent {
|
||||
private readonly directAgent: Agent;
|
||||
|
||||
constructor(
|
||||
private readonly noProxy: string,
|
||||
private readonly proxyAgent: EnvHttpProxyAgent,
|
||||
options: Pool.Options,
|
||||
) {
|
||||
this.directAgent = new Agent(options as ConstructorParameters<typeof Agent>[0]);
|
||||
}
|
||||
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||
const origin = options.origin;
|
||||
const targetUrl =
|
||||
typeof origin === 'string' || origin instanceof URL
|
||||
? new URL(options.path, origin)
|
||||
: null;
|
||||
return (targetUrl && shouldBypassProxyForUrl(targetUrl, this.noProxy) ? this.directAgent : this.proxyAgent).dispatch(
|
||||
options,
|
||||
handler,
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await Promise.all([this.directAgent.close(), this.proxyAgent.close()]);
|
||||
}
|
||||
|
||||
async destroy(error?: Error | null): Promise<void> {
|
||||
await Promise.all([
|
||||
this.directAgent.destroy(error ?? null),
|
||||
this.proxyAgent.destroy(error ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class NoProxyAwareMixedProxyAgent {
|
||||
private readonly directAgent: Agent;
|
||||
|
||||
private readonly proxyAgent: EnvHttpProxyAgent;
|
||||
|
||||
private readonly socksAgent: Socks5ProxyAgent;
|
||||
|
||||
private readonly socksDispatchTimeouts: Pick<Dispatcher.DispatchOptions, 'bodyTimeout' | 'headersTimeout'>;
|
||||
|
||||
constructor(
|
||||
private readonly noProxy: string | null,
|
||||
private readonly hasHttpProxy: boolean,
|
||||
private readonly hasHttpsProxy: boolean,
|
||||
proxyOptions: ConstructorParameters<typeof EnvHttpProxyAgent>[0],
|
||||
socksProxy: string,
|
||||
options: Pool.Options,
|
||||
) {
|
||||
this.directAgent = new Agent(options as ConstructorParameters<typeof Agent>[0]);
|
||||
this.proxyAgent = new EnvHttpProxyAgent(proxyOptions);
|
||||
this.socksAgent = new Socks5ProxyAgent(socksProxy, socksProxyAgentOptions(options));
|
||||
this.socksDispatchTimeouts = {
|
||||
...(options.bodyTimeout === undefined ? {} : { bodyTimeout: options.bodyTimeout }),
|
||||
...(options.headersTimeout === undefined
|
||||
? {}
|
||||
: { headersTimeout: options.headersTimeout }),
|
||||
};
|
||||
}
|
||||
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||
const origin = options.origin;
|
||||
const targetUrl =
|
||||
typeof origin === 'string' || origin instanceof URL
|
||||
? new URL(options.path, origin)
|
||||
: null;
|
||||
if (targetUrl && shouldBypassProxyForUrl(targetUrl, this.noProxy)) {
|
||||
return this.directAgent.dispatch(options, handler);
|
||||
}
|
||||
if (
|
||||
targetUrl && ((targetUrl.protocol === 'http:' && this.hasHttpProxy) ||
|
||||
(targetUrl.protocol === 'https:' && this.hasHttpsProxy))
|
||||
) {
|
||||
return this.proxyAgent.dispatch(options, handler);
|
||||
}
|
||||
return this.socksAgent.dispatch({ ...this.socksDispatchTimeouts, ...options }, handler);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await Promise.all([this.directAgent.close(), this.proxyAgent.close(), this.socksAgent.close()]);
|
||||
}
|
||||
|
||||
async destroy(error?: Error | null): Promise<void> {
|
||||
await Promise.all([
|
||||
this.directAgent.destroy(error ?? null),
|
||||
this.proxyAgent.destroy(error ?? null),
|
||||
this.socksAgent.destroy(error ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionTestProxyDispatcher =
|
||||
| EnvHttpProxyAgent
|
||||
| NoProxyAwareEnvProxyAgent
|
||||
| NoProxyAwareMixedProxyAgent
|
||||
| NoProxyAwareSocksProxyAgent;
|
||||
|
||||
function envProxyAgentOptions(
|
||||
options: Pool.Options,
|
||||
httpProxy: string | undefined,
|
||||
httpsProxy: string | undefined,
|
||||
noProxy: string | null,
|
||||
): ConstructorParameters<typeof EnvHttpProxyAgent>[0] {
|
||||
return {
|
||||
...options,
|
||||
...(httpProxy ? { httpProxy } : {}),
|
||||
...(httpsProxy ? { httpsProxy } : {}),
|
||||
...(noProxy ? { noProxy } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildConnectionTestProxyDispatcher(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
options: Pool.Options = {},
|
||||
): ConnectionTestProxyDispatcher | null {
|
||||
const proxyEnv = mergeProxyAwareEnv(
|
||||
process.platform,
|
||||
resolveSystemProxyEnv(),
|
||||
env,
|
||||
);
|
||||
const allProxy = proxyEnv.ALL_PROXY ?? proxyEnv.all_proxy;
|
||||
const socksProxy = socksProxyUrl(allProxy);
|
||||
const httpProxyFromAll = isHttpOrHttpsProxy(allProxy);
|
||||
const httpProxy = proxyEnv.HTTP_PROXY ?? proxyEnv.http_proxy ?? httpProxyFromAll;
|
||||
const httpsProxy = proxyEnv.HTTPS_PROXY ?? proxyEnv.https_proxy ?? httpProxyFromAll;
|
||||
const noProxy = mergeNoProxyWithLoopbackDefaults(proxyEnv.NO_PROXY ?? proxyEnv.no_proxy);
|
||||
const proxyOptions = envProxyAgentOptions(options, httpProxy, httpsProxy, noProxy);
|
||||
if (socksProxy && (httpProxy || httpsProxy) && (!httpProxy || !httpsProxy)) {
|
||||
return new NoProxyAwareMixedProxyAgent(
|
||||
noProxy,
|
||||
Boolean(httpProxy),
|
||||
Boolean(httpsProxy),
|
||||
proxyOptions,
|
||||
socksProxy,
|
||||
options,
|
||||
);
|
||||
}
|
||||
if (!httpProxy && !httpsProxy && socksProxy) {
|
||||
return new NoProxyAwareSocksProxyAgent(noProxy, socksProxy, options);
|
||||
}
|
||||
if (!httpProxy && !httpsProxy) return null;
|
||||
const proxyAgent = new EnvHttpProxyAgent(proxyOptions);
|
||||
return noProxy?.split(/[\s,]+/).some((token) => token.trim() === '<local>')
|
||||
? new NoProxyAwareEnvProxyAgent(noProxy, proxyAgent, options)
|
||||
: proxyAgent;
|
||||
}
|
||||
|
||||
function isHttpOrHttpsProxy(proxyUrl: string | undefined): string | undefined {
|
||||
const trimmed = proxyUrl?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
try {
|
||||
const { protocol } = new URL(trimmed);
|
||||
return protocol === 'http:' || protocol === 'https:' ? trimmed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function socksProxyUrl(proxyUrl: string | undefined): string | undefined {
|
||||
const trimmed = proxyUrl?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol === 'socks:' || url.protocol === 'socks5:') return trimmed;
|
||||
if (url.protocol === 'socks5h:') {
|
||||
url.protocol = 'socks5:';
|
||||
return url.toString();
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function proxyDispatcherRequestInit(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
options: Pool.Options = {},
|
||||
): {
|
||||
close(): Promise<void>;
|
||||
requestInit: Pick<RequestInit, 'dispatcher'>;
|
||||
} {
|
||||
const dispatcher = buildConnectionTestProxyDispatcher(env, options);
|
||||
if (dispatcher == null) {
|
||||
return {
|
||||
async close() {},
|
||||
requestInit: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
close: () => dispatcher.close(),
|
||||
requestInit: {
|
||||
dispatcher: dispatcher as unknown as NonNullable<RequestInit['dispatcher']>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const AGENT_COMPLETION_DEBOUNCE_MS = 500;
|
||||
const AGENT_KILL_GRACE_MS = 2_000;
|
||||
// Truncates the assistant reply we surface in the success copy so a
|
||||
|
|
@ -469,6 +816,7 @@ async function validateLocalOpenAiModel(
|
|||
parsed: ParsedBaseUrl,
|
||||
signal: AbortSignal,
|
||||
start: number,
|
||||
requestInit: Pick<RequestInit, 'dispatcher'> = {},
|
||||
): Promise<ConnectionTestResponse | null> {
|
||||
if (input.protocol !== 'openai' || !isLoopbackApiHost(parsed.hostname)) {
|
||||
return null;
|
||||
|
|
@ -478,6 +826,7 @@ async function validateLocalOpenAiModel(
|
|||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...requestInit,
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${String(input.apiKey)}` },
|
||||
signal,
|
||||
|
|
@ -510,11 +859,124 @@ async function validateLocalOpenAiModel(
|
|||
};
|
||||
}
|
||||
|
||||
function isSenseAudioNonChatModel(model: string): boolean {
|
||||
return (
|
||||
model.startsWith('senseaudio-image-') ||
|
||||
model.startsWith('doubao-seedream-') ||
|
||||
model === 'sensenova-u1-fast' ||
|
||||
model.startsWith('doubao-seedance-') ||
|
||||
model.startsWith('senseaudio-asr-') ||
|
||||
model.startsWith('senseaudio-tts-') ||
|
||||
model.startsWith('senseaudio-music-')
|
||||
);
|
||||
}
|
||||
|
||||
async function validateSenseAudioNonChatModel(
|
||||
input: ProviderTestRequest,
|
||||
signal: AbortSignal,
|
||||
start: number,
|
||||
requestInit: Pick<RequestInit, 'dispatcher'> = {},
|
||||
): Promise<ConnectionTestResponse | null> {
|
||||
if (input.protocol !== 'senseaudio' || !isSenseAudioNonChatModel(input.model)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = appendVersionedApiPath(String(input.baseUrl), '/models');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...requestInit,
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${String(input.apiKey)}` },
|
||||
signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
} catch (err) {
|
||||
const latencyMs = Date.now() - start;
|
||||
const kind = networkErrorToKind(err);
|
||||
return {
|
||||
ok: false,
|
||||
kind,
|
||||
latencyMs,
|
||||
model: input.model,
|
||||
detail: redactSecrets(err instanceof Error ? err.message : String(err), [
|
||||
input.apiKey,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
let rawText = '';
|
||||
let data: unknown = {};
|
||||
let parseError: unknown = null;
|
||||
try {
|
||||
rawText = await response.text();
|
||||
} catch {
|
||||
rawText = '';
|
||||
}
|
||||
try {
|
||||
data = rawText ? JSON.parse(rawText) : {};
|
||||
} catch (err) {
|
||||
parseError = err;
|
||||
}
|
||||
|
||||
if (parseError && response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'unknown',
|
||||
latencyMs,
|
||||
model: input.model,
|
||||
status: response.status,
|
||||
detail: redactSecrets(
|
||||
parseError instanceof Error ? parseError.message : String(parseError),
|
||||
[input.apiKey],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const redactedDetail = redactSecrets(
|
||||
extractProviderErrorDetail(data, rawText).slice(0, 240),
|
||||
[input.apiKey],
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
kind: statusToKind(response.status, redactedDetail),
|
||||
latencyMs,
|
||||
model: input.model,
|
||||
status: response.status,
|
||||
detail: redactedDetail,
|
||||
};
|
||||
}
|
||||
|
||||
const modelIds = extractOpenAiModelIds(data);
|
||||
if (!modelIds.includes(input.model)) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'not_found_model',
|
||||
latencyMs,
|
||||
model: input.model,
|
||||
status: response.status,
|
||||
detail: `Model "${input.model}" is not reported by SenseAudio /models.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
kind: 'success',
|
||||
latencyMs,
|
||||
model: input.model,
|
||||
status: response.status,
|
||||
detail: 'SenseAudio model is available, but this media model is not chat-testable from Settings.',
|
||||
};
|
||||
}
|
||||
|
||||
interface ProviderCallShape {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
extractText: (data: unknown) => string;
|
||||
retryBodyOnUnsupportedMaxTokens?: unknown;
|
||||
}
|
||||
|
||||
function buildProviderCall(input: ProviderTestRequest): ProviderCallShape {
|
||||
|
|
@ -567,7 +1029,7 @@ function buildProviderCall(input: ProviderTestRequest): ProviderCallShape {
|
|||
},
|
||||
body: {
|
||||
model,
|
||||
max_tokens: PROVIDER_MAX_TOKENS,
|
||||
...buildOpenAIChatTokenParam(model, PROVIDER_MAX_TOKENS),
|
||||
messages: [{ role: 'user', content: SMOKE_PROMPT }],
|
||||
stream: false,
|
||||
},
|
||||
|
|
@ -600,17 +1062,22 @@ function buildProviderCall(input: ProviderTestRequest): ProviderCallShape {
|
|||
},
|
||||
body: {
|
||||
...(usesVersionedOpenAIPath ? { model } : {}),
|
||||
max_tokens: PROVIDER_MAX_TOKENS,
|
||||
...buildLegacyMaxTokensParam(PROVIDER_MAX_TOKENS),
|
||||
messages: [{ role: 'user', content: SMOKE_PROMPT }],
|
||||
stream: false,
|
||||
},
|
||||
retryBodyOnUnsupportedMaxTokens: {
|
||||
...(usesVersionedOpenAIPath ? { model } : {}),
|
||||
messages: [{ role: 'user', content: SMOKE_PROMPT }],
|
||||
stream: false,
|
||||
...buildMaxCompletionTokensParam(PROVIDER_MAX_TOKENS),
|
||||
},
|
||||
extractText: extractOpenAIMessageText,
|
||||
};
|
||||
}
|
||||
case 'google': {
|
||||
const trimmedBase = baseUrl.replace(/\/+$/, '');
|
||||
return {
|
||||
url: `${trimmedBase}/v1beta/models/${encodeURIComponent(model)}:generateContent`,
|
||||
url: googleGenerateContentUrl(baseUrl, model),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
|
|
@ -716,24 +1183,81 @@ export async function testProviderConnection(
|
|||
input.signal?.addEventListener('abort', abortFromParent, { once: true });
|
||||
}
|
||||
const timer = setTimeout(() => controller.abort(), providerTimeoutMs());
|
||||
let proxyDispatcher: ReturnType<typeof proxyDispatcherRequestInit> | null = null;
|
||||
|
||||
try {
|
||||
proxyDispatcher = proxyDispatcherRequestInit();
|
||||
const modelError = await validateLocalOpenAiModel(
|
||||
input,
|
||||
validated.parsed,
|
||||
controller.signal,
|
||||
start,
|
||||
proxyDispatcher.requestInit,
|
||||
);
|
||||
if (modelError) return modelError;
|
||||
|
||||
const response = await fetch(call.url, {
|
||||
const senseAudioNonChatResult = await validateSenseAudioNonChatModel(
|
||||
input,
|
||||
controller.signal,
|
||||
start,
|
||||
proxyDispatcher.requestInit,
|
||||
);
|
||||
if (senseAudioNonChatResult) return senseAudioNonChatResult;
|
||||
|
||||
const requestInit = {
|
||||
...proxyDispatcher.requestInit,
|
||||
method: 'POST',
|
||||
headers: call.headers,
|
||||
body: JSON.stringify(call.body),
|
||||
signal: controller.signal,
|
||||
redirect: 'error',
|
||||
redirect: 'error' as const,
|
||||
};
|
||||
let response = await fetch(call.url, {
|
||||
...requestInit,
|
||||
body: JSON.stringify(call.body),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
let latencyMs = Date.now() - start;
|
||||
if (
|
||||
!response.ok &&
|
||||
call.retryBodyOnUnsupportedMaxTokens !== undefined
|
||||
) {
|
||||
let detailText = '';
|
||||
try {
|
||||
detailText = await response.text();
|
||||
} catch {
|
||||
detailText = '';
|
||||
}
|
||||
if (response.status === 400 && isUnsupportedMaxTokensError(detailText)) {
|
||||
console.warn(
|
||||
`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → retrying with max_completion_tokens`,
|
||||
);
|
||||
response = await fetch(call.url, {
|
||||
...requestInit,
|
||||
body: JSON.stringify(call.retryBodyOnUnsupportedMaxTokens),
|
||||
});
|
||||
latencyMs = Date.now() - start;
|
||||
} else {
|
||||
const redactedDetail = redactSecrets(detailText.slice(0, 240), [
|
||||
input.apiKey,
|
||||
]);
|
||||
const kind = statusToKind(response.status, redactedDetail);
|
||||
const detail =
|
||||
redactedDetail ||
|
||||
(response.status === 404
|
||||
? 'HTTP 404 from provider; check the Base URL path.'
|
||||
: '');
|
||||
console.warn(
|
||||
`[test:provider] ${input.protocol} ${validated.parsed.hostname} model=${input.model} → ${response.status} in ${latencyMs}ms (${kind})${detail ? ` ${detail}` : ''}`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
kind,
|
||||
latencyMs,
|
||||
model,
|
||||
status: response.status,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (response.ok) {
|
||||
let data: unknown;
|
||||
let rawText = '';
|
||||
|
|
@ -878,6 +1402,7 @@ export async function testProviderConnection(
|
|||
} finally {
|
||||
clearTimeout(timer);
|
||||
input.signal?.removeEventListener('abort', abortFromParent);
|
||||
await proxyDispatcher?.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1067,7 +1592,13 @@ function attachAgentStreamHandlers(
|
|||
child,
|
||||
prompt,
|
||||
cwd,
|
||||
model: model ?? null,
|
||||
// Same substitution as the chat-run path in server.ts — adapters whose
|
||||
// CLI rejects the synthetic 'default' (e.g. AMR / vela, which forces
|
||||
// session/set_model before session/prompt) need the def's first
|
||||
// concrete fallback id here too, otherwise Test connection deadlocks
|
||||
// on the same `session/set_model must be called before session/prompt`
|
||||
// error the chat-run path already handles.
|
||||
model: resolveModelForAgent(def as never, model ?? null),
|
||||
mcpServers: [],
|
||||
send,
|
||||
});
|
||||
|
|
@ -1126,6 +1657,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: input.agentId,
|
||||
detail: `Unknown agent id: ${input.agentId}`,
|
||||
diagnostics: { phase: 'binary_resolution' },
|
||||
};
|
||||
}
|
||||
const configuredAgentEnv = agentCliEnvForAgent(
|
||||
|
|
@ -1141,6 +1673,7 @@ async function testAgentConnectionInternal(
|
|||
latencyMs: Date.now() - start,
|
||||
model,
|
||||
agentName: def.name,
|
||||
diagnostics: { phase: 'binary_resolution' },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1152,7 +1685,37 @@ async function testAgentConnectionInternal(
|
|||
let abortHandler: (() => void) | null = null;
|
||||
const sink = createAgentSink();
|
||||
|
||||
const resultFromAgentText = (text: string): ConnectionTestResponse => {
|
||||
// Phase tracker for structured diagnostics (#2248). The order matches
|
||||
// the lifecycle: binary_resolution → spawn → connection_smoke_test →
|
||||
// output_parse. Each result helper below stamps the *current* phase
|
||||
// into the response so consumers don't have to scrape `detail` to
|
||||
// know how far the test got. Phase is mutated at the points where
|
||||
// the daemon meaningfully advances (just before spawn, when the
|
||||
// child first produces stdout, etc.) — not on every event.
|
||||
let phase: ConnectionTestPhase = 'binary_resolution';
|
||||
const buildDiagnostics = (
|
||||
overrides: Partial<ConnectionTestDiagnostics> = {},
|
||||
): ConnectionTestDiagnostics => {
|
||||
const rawStderr = sink.getStderrTail().trim();
|
||||
const rawStdout = sink.getRawStdoutTail().trim();
|
||||
// `exactOptionalPropertyTypes: true` means we can't pass `undefined`
|
||||
// to an optional field directly — conditionally spread instead so
|
||||
// empty values just don't appear in the response.
|
||||
return {
|
||||
phase,
|
||||
...(executableResolution.launchPath
|
||||
? { binaryPath: executableResolution.launchPath }
|
||||
: {}),
|
||||
...(rawStderr ? { stderrTail: redactSecrets(rawStderr) } : {}),
|
||||
...(rawStdout ? { stdoutTail: redactSecrets(rawStdout) } : {}),
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const resultFromAgentText = (
|
||||
text: string,
|
||||
exit?: { code: number | null; signal: NodeJS.Signals | null },
|
||||
): ConnectionTestResponse => {
|
||||
const latencyMs = Date.now() - start;
|
||||
const rawSample = truncateSample(text);
|
||||
const sample = redactSecrets(rawSample);
|
||||
|
|
@ -1168,6 +1731,10 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail,
|
||||
diagnostics: buildDiagnostics({
|
||||
phase: 'output_parse',
|
||||
...(exit ? { exitCode: exit.code, signal: exit.signal } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!isSmokeOkReply(text)) {
|
||||
|
|
@ -1176,6 +1743,14 @@ async function testAgentConnectionInternal(
|
|||
);
|
||||
}
|
||||
console.log(`[test:agent] ${def.name} → ok in ${(latencyMs / 1000).toFixed(1)}s`);
|
||||
// resultFromChildExit can route ACP forced shutdown (code === null,
|
||||
// signal === 'SIGTERM' + acpCleanCompletion) through this success
|
||||
// helper. Hard-coding `exitCode: 0` would silently overwrite the
|
||||
// SIGTERM signal and violate the raw code/signal contract in
|
||||
// packages/contracts/src/api/connectionTest.ts. Pass through the
|
||||
// real `winner.code` / `winner.signal` when the caller has them and
|
||||
// only synthesize `exitCode: 0` when no exit context is available
|
||||
// (theoretical text-without-exit path).
|
||||
return {
|
||||
ok: true,
|
||||
kind: 'success',
|
||||
|
|
@ -1183,6 +1758,11 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
sample,
|
||||
diagnostics: buildDiagnostics(
|
||||
exit
|
||||
? { phase: 'connection_smoke_test', exitCode: exit.code, signal: exit.signal }
|
||||
: { phase: 'connection_smoke_test', exitCode: 0 },
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1201,6 +1781,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: auth.message ?? cursorAuthGuidance(),
|
||||
diagnostics: buildDiagnostics(),
|
||||
};
|
||||
}
|
||||
if (detail && isLikelyModelErrorText(detail)) {
|
||||
|
|
@ -1214,6 +1795,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail,
|
||||
diagnostics: buildDiagnostics({ phase: 'output_parse' }),
|
||||
};
|
||||
}
|
||||
console.warn(
|
||||
|
|
@ -1226,6 +1808,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail,
|
||||
diagnostics: buildDiagnostics(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1240,6 +1823,7 @@ async function testAgentConnectionInternal(
|
|||
latencyMs,
|
||||
model,
|
||||
agentName: def.name,
|
||||
diagnostics: buildDiagnostics(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1255,6 +1839,10 @@ async function testAgentConnectionInternal(
|
|||
);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
// buildArgs runs *after* binary resolution but *before* spawn, so
|
||||
// phase is still 'binary_resolution' here. Stamp diagnostics so the
|
||||
// contract advertised in packages/contracts/src/api/connectionTest.ts
|
||||
// ("Always set on local agent test responses") actually holds.
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'agent_spawn_failed',
|
||||
|
|
@ -1262,6 +1850,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: redactSecrets(detail),
|
||||
diagnostics: buildDiagnostics(),
|
||||
};
|
||||
}
|
||||
const stdinMode =
|
||||
|
|
@ -1277,6 +1866,17 @@ async function testAgentConnectionInternal(
|
|||
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
|
||||
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
|
||||
if (auth?.status === 'missing') {
|
||||
// Preflight auth probe runs after binary resolution but before the
|
||||
// smoke spawn — phase is still 'binary_resolution'. The smoke
|
||||
// sink is empty here (no spawn happened), so the probe itself is
|
||||
// the only source of stderr/stdout/exit context. Fold what the
|
||||
// probe captured into the diagnostics block; `...overrides` in
|
||||
// buildDiagnostics() lets these win over the empty sink tails.
|
||||
const probeOverrides: Partial<ConnectionTestDiagnostics> = {};
|
||||
if (auth.stdoutTail) probeOverrides.stdoutTail = redactSecrets(auth.stdoutTail);
|
||||
if (auth.stderrTail) probeOverrides.stderrTail = redactSecrets(auth.stderrTail);
|
||||
if (auth.exitCode !== undefined) probeOverrides.exitCode = auth.exitCode;
|
||||
if (auth.signal !== undefined) probeOverrides.signal = auth.signal;
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'agent_auth_required',
|
||||
|
|
@ -1284,6 +1884,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: auth.message ?? cursorAuthGuidance(),
|
||||
diagnostics: buildDiagnostics(probeOverrides),
|
||||
};
|
||||
}
|
||||
const invocation = createCommandInvocation({
|
||||
|
|
@ -1291,6 +1892,12 @@ async function testAgentConnectionInternal(
|
|||
args,
|
||||
env,
|
||||
});
|
||||
// We are about to hand off to child_process.spawn(). Any failure
|
||||
// from here on (ENOENT, bad argv, non-zero exit) belongs to the
|
||||
// 'spawn' phase rather than 'binary_resolution', so flip the tracker
|
||||
// *before* spawning. resultFromAgentText flips it again to
|
||||
// 'connection_smoke_test' / 'output_parse' once we get text out.
|
||||
phase = 'spawn';
|
||||
child = spawn(invocation.command, invocation.args, {
|
||||
env,
|
||||
stdio: [stdinMode, 'pipe', 'pipe'],
|
||||
|
|
@ -1344,6 +1951,9 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: `${detail}${guidance}`,
|
||||
diagnostics: buildDiagnostics({
|
||||
phase: isMissing ? 'binary_resolution' : 'spawn',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1373,10 +1983,11 @@ async function testAgentConnectionInternal(
|
|||
(winner.code === 0 && !winner.signal) || acpForcedShutdown;
|
||||
if (buffered) {
|
||||
const rawSample = truncateSample(buffered);
|
||||
const exitInfo = { code: winner.code, signal: winner.signal };
|
||||
if (rawSample && isLikelyModelErrorText(rawSample)) {
|
||||
return resultFromAgentText(buffered);
|
||||
return resultFromAgentText(buffered, exitInfo);
|
||||
}
|
||||
if (exitedCleanly) return resultFromAgentText(buffered);
|
||||
if (exitedCleanly) return resultFromAgentText(buffered, exitInfo);
|
||||
}
|
||||
const stderrTail = sink.getStderrTail().trim();
|
||||
const rawStdoutTail = sink.getRawStdoutTail().trim();
|
||||
|
|
@ -1401,6 +2012,11 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: auth.message ?? cursorAuthGuidance(),
|
||||
diagnostics: buildDiagnostics({
|
||||
phase: 'connection_smoke_test',
|
||||
exitCode: winner.code,
|
||||
signal: winner.signal,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const claudeDiagnostic = diagnoseClaudeCliFailure({
|
||||
|
|
@ -1422,6 +2038,11 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: claudeDiagnostic.detail,
|
||||
diagnostics: buildDiagnostics({
|
||||
phase: 'spawn',
|
||||
exitCode: winner.code,
|
||||
signal: winner.signal,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const detail = redactSecrets(
|
||||
|
|
@ -1446,6 +2067,11 @@ async function testAgentConnectionInternal(
|
|||
agentName: def.name,
|
||||
detail:
|
||||
`${detail || 'Agent exited without producing assistant text'}${guidance}`,
|
||||
diagnostics: buildDiagnostics({
|
||||
phase: buffered ? 'output_parse' : 'spawn',
|
||||
exitCode: winner.code,
|
||||
signal: winner.signal,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1502,6 +2128,10 @@ async function testAgentConnectionInternal(
|
|||
return resultFromChildExit(winner);
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
// Outer catch — the failure may have happened at any phase between
|
||||
// binary_resolution and output_parse, so stamp the current phase as
|
||||
// observed. buildDiagnostics is defined in the enclosing scope and
|
||||
// is safe to call here.
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'agent_spawn_failed',
|
||||
|
|
@ -1509,6 +2139,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: redactSecrets(detail),
|
||||
diagnostics: buildDiagnostics(),
|
||||
};
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
|
|
|
|||
|
|
@ -589,7 +589,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/api/connectors/:connectorId', async (req: Request, res: Response) => {
|
||||
app.get('/api/connectors/:connectorId', async (req: Request<{ connectorId: string }>, res: Response) => {
|
||||
try {
|
||||
const connectorId = req.params.connectorId;
|
||||
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
|
||||
|
|
@ -625,7 +625,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/api/connectors/:connectorId/connect', requireLocalDaemonRequest, async (req: Request, res: Response) => {
|
||||
app.post('/api/connectors/:connectorId/connect', requireLocalDaemonRequest, async (req: Request<{ connectorId: string }>, res: Response) => {
|
||||
try {
|
||||
const connectorId = req.params.connectorId;
|
||||
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
|
||||
|
|
@ -653,7 +653,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/api/connectors/oauth/callback/:connectorId', async (req: Request, res: Response) => {
|
||||
app.get('/api/connectors/oauth/callback/:connectorId', async (req: Request<{ connectorId: string }>, res: Response) => {
|
||||
try {
|
||||
const connectorId = req.params.connectorId;
|
||||
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
|
||||
|
|
@ -674,7 +674,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/api/connectors/:connectorId/authorization/cancel', requireLocalDaemonRequest, async (req: Request, res: Response) => {
|
||||
app.post('/api/connectors/:connectorId/authorization/cancel', requireLocalDaemonRequest, async (req: Request<{ connectorId: string }>, res: Response) => {
|
||||
try {
|
||||
const connectorId = req.params.connectorId;
|
||||
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
|
||||
|
|
@ -684,7 +684,7 @@ export function registerConnectorRoutes(app: Express, options: RegisterConnector
|
|||
}
|
||||
});
|
||||
|
||||
app.delete('/api/connectors/:connectorId/connection', requireLocalDaemonRequest, async (req: Request, res: Response) => {
|
||||
app.delete('/api/connectors/:connectorId/connection', requireLocalDaemonRequest, async (req: Request<{ connectorId: string }>, res: Response) => {
|
||||
try {
|
||||
const connectorId = req.params.connectorId;
|
||||
if (!connectorId) return options.sendApiError(res, 400, 'CONNECTOR_NOT_FOUND', 'connectorId is required');
|
||||
|
|
|
|||
|
|
@ -58,6 +58,35 @@ function isMissingOrExpiredComposioOAuthState(error: unknown): boolean {
|
|||
&& error.message === 'Composio OAuth state is missing or expired';
|
||||
}
|
||||
|
||||
function boundedJsonValueIncludesAuthStaleSignal(value: BoundedJsonValue | undefined): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === 'number') return value === 401;
|
||||
if (typeof value === 'boolean') return false;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.toLowerCase();
|
||||
return normalized === '401'
|
||||
|| /\bbad credentials\b/.test(normalized)
|
||||
|| /\bunauthori[sz]ed\b/.test(normalized)
|
||||
|| /\binvalid (?:access )?token\b/.test(normalized)
|
||||
|| /\btoken (?:is )?expired\b/.test(normalized)
|
||||
|| /\bexpired (?:access )?token\b/.test(normalized);
|
||||
}
|
||||
if (Array.isArray(value)) return value.some(boundedJsonValueIncludesAuthStaleSignal);
|
||||
return Object.values(value).some(boundedJsonValueIncludesAuthStaleSignal);
|
||||
}
|
||||
|
||||
function isConnectorAuthStaleError(error: unknown, request: Pick<ConnectorExecuteRequest, 'connectorId' | 'toolName'>): boolean {
|
||||
if (!(error instanceof ConnectorServiceError) || error.code !== 'CONNECTOR_EXECUTION_FAILED') return false;
|
||||
const details = error.details;
|
||||
return details?.connectorId === request.connectorId
|
||||
&& details.toolName === request.toolName
|
||||
&& boundedJsonValueIncludesAuthStaleSignal(details.error);
|
||||
}
|
||||
|
||||
function connectorAuthExpiredMessage(definition: ConnectorCatalogDefinition): string {
|
||||
return `${definition.name} authorization expired. Reconnect ${definition.name}.`;
|
||||
}
|
||||
|
||||
function hasStoredComposioConnection(credential: ConnectorCredentialRecord | undefined, providerConnectionId: string): boolean {
|
||||
return credential?.credentials.provider === 'composio'
|
||||
&& credential.credentials.providerConnectionId === providerConnectionId;
|
||||
|
|
@ -421,6 +450,11 @@ export class ConnectorStatusService {
|
|||
return cloneStatus(next);
|
||||
}
|
||||
|
||||
markAuthenticationExpired(definition: ConnectorCatalogDefinition, lastError: string, accountLabel?: string): ConnectorConnectionStatus {
|
||||
this.credentialStore?.delete(definition.id);
|
||||
return this.setError(definition, lastError, accountLabel);
|
||||
}
|
||||
|
||||
clear(connectorId: string): void {
|
||||
this.statuses.delete(connectorId);
|
||||
}
|
||||
|
|
@ -776,7 +810,15 @@ export class ConnectorService {
|
|||
|
||||
this.enforceRunLimits(context);
|
||||
|
||||
const providerOutput = await this.executeConnectorProviderTool(request, context, definition, tool);
|
||||
let providerOutput: BoundedJsonObject;
|
||||
try {
|
||||
providerOutput = await this.executeConnectorProviderTool(request, context, definition, tool);
|
||||
} catch (error) {
|
||||
if (isConnectorAuthStaleError(error, request)) {
|
||||
this.statusService.markAuthenticationExpired(definition, connectorAuthExpiredMessage(definition), connector.accountLabel);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const protectedOutput = protectConnectorOutput(providerOutput);
|
||||
const output = protectedOutput.output;
|
||||
const outputSummary = summarizeConnectorOutput(output);
|
||||
|
|
|
|||
|
|
@ -28,9 +28,11 @@
|
|||
// source root so an environment that puts `skills/` itself behind a
|
||||
// symlink (e.g. a content-addressable mount) is followed correctly.
|
||||
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { cp, lstat, rm, stat } from 'node:fs/promises';
|
||||
import { chmod, cp, lstat, mkdir, readdir, rm, stat, utimes } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
export const SKILLS_CWD_ALIAS = '.od-skills';
|
||||
|
||||
|
|
@ -52,6 +54,46 @@ export function skillCwdAliasSegment(dir: string): string {
|
|||
return `${folder}-${digest}`;
|
||||
}
|
||||
|
||||
// copy_file_range(2) — used by fs.cp under the hood — is rejected with
|
||||
// these errno codes when source and destination live on different
|
||||
// filesystems (commonly EXDEV; a container image layer copied onto a
|
||||
// ZFS/overlay bind mount surfaces EPERM). Node doesn't fall back to a
|
||||
// userspace copy on any of them, so we do.
|
||||
const RECOVERABLE_COPY_CODES = new Set(['EPERM', 'EXDEV', 'ENOTSUP', 'EOPNOTSUPP']);
|
||||
|
||||
type SkillCopyFn = (
|
||||
source: string,
|
||||
destination: string,
|
||||
options: { recursive: boolean; dereference: boolean; preserveTimestamps: boolean },
|
||||
) => Promise<void>;
|
||||
|
||||
// Recursive copy that mirrors `cp({ dereference: true })` without going
|
||||
// through copy_file_range. `stat()` (not `lstat`) follows symlinks, so
|
||||
// every staged entry lands as a real directory or regular file — keeping
|
||||
// `.od-skills/` a self-contained write barrier even on the fallback path.
|
||||
async function copyTreeDereferenced(srcDir: string, destDir: string): Promise<void> {
|
||||
await mkdir(destDir, { recursive: true });
|
||||
for (const entry of await readdir(srcDir, { withFileTypes: true })) {
|
||||
const from = path.join(srcDir, entry.name);
|
||||
const to = path.join(destDir, entry.name);
|
||||
const entryStat = await stat(from);
|
||||
if (entryStat.isDirectory()) {
|
||||
await copyTreeDereferenced(from, to);
|
||||
} else if (entryStat.isFile()) {
|
||||
await pipeline(createReadStream(from), createWriteStream(to));
|
||||
// createWriteStream opens the destination with the default 0644, so
|
||||
// restore the source's permission bits (notably the exec bit on
|
||||
// skill helper scripts) and mtime — `fs.cp` preserves these, and
|
||||
// skills shell out to staged scripts. Mask to 0o777 so the
|
||||
// agent-writable staging copy never inherits setuid/setgid/sticky.
|
||||
await chmod(to, entryStat.mode & 0o777);
|
||||
await utimes(to, entryStat.atime, entryStat.mtime);
|
||||
}
|
||||
// Sockets, FIFOs, and devices can't appear in a sane skill folder and
|
||||
// copying them would hang or fail — skip them.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `<sourceDir>` to `<cwd>/.od-skills/<folderName>/` so an agent can
|
||||
* reach skill side files via a cwd-relative path. Idempotent and
|
||||
|
|
@ -68,6 +110,11 @@ export async function stageActiveSkill(
|
|||
folderName: string,
|
||||
sourceDir: string,
|
||||
log: SkillStagingLogger = () => {},
|
||||
// Seam for tests: the real copy_file_range EPERM only reproduces on
|
||||
// specific cross-filesystem mounts (ZFS/overlay), so tests inject a
|
||||
// copy that rejects with a synthetic code to drive the fallback path.
|
||||
nativeCopy: SkillCopyFn = (source, destination, options) =>
|
||||
cp(source, destination, options),
|
||||
): Promise<SkillStagingResult> {
|
||||
if (!cwd) {
|
||||
return { staged: false, reason: 'no project cwd' };
|
||||
|
|
@ -123,16 +170,26 @@ export async function stageActiveSkill(
|
|||
// reflected and a partially-failed previous run cannot leave junk
|
||||
// behind.
|
||||
await rm(stagedPath, { recursive: true, force: true });
|
||||
await cp(sourceDir, stagedPath, {
|
||||
recursive: true,
|
||||
// Resolve every symlink we find inside the skill so the staged
|
||||
// copy is a fully self-contained set of regular files. This is
|
||||
// what makes the copy a true write barrier — no entry under
|
||||
// `.od-skills/...` can resolve back to a real file outside the
|
||||
// project cwd.
|
||||
dereference: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
try {
|
||||
await nativeCopy(sourceDir, stagedPath, {
|
||||
recursive: true,
|
||||
// Resolve every symlink we find inside the skill so the staged
|
||||
// copy is a fully self-contained set of regular files. This is
|
||||
// what makes the copy a true write barrier — no entry under
|
||||
// `.od-skills/...` can resolve back to a real file outside the
|
||||
// project cwd.
|
||||
dereference: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code ?? '';
|
||||
if (!RECOVERABLE_COPY_CODES.has(code)) throw err;
|
||||
log(
|
||||
`[od] skill-stage: native copy failed (${code}); retrying with stream copy`,
|
||||
);
|
||||
await rm(stagedPath, { recursive: true, force: true });
|
||||
await copyTreeDereferenced(sourceDir, stagedPath);
|
||||
}
|
||||
return { staged: true, stagedPath };
|
||||
} catch (err) {
|
||||
log(`[od] skill-stage failed: ${(err as Error).message}`);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ function migrate(db: SqliteDb): void {
|
|||
attachments_json TEXT,
|
||||
produced_files_json TEXT,
|
||||
feedback_json TEXT,
|
||||
pre_turn_file_names_json TEXT,
|
||||
started_at INTEGER,
|
||||
ended_at INTEGER,
|
||||
position INTEGER NOT NULL,
|
||||
|
|
@ -115,6 +116,7 @@ function migrate(db: SqliteDb): void {
|
|||
text TEXT NOT NULL,
|
||||
position_json TEXT NOT NULL,
|
||||
html_hint TEXT NOT NULL,
|
||||
style_json TEXT,
|
||||
note TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
|
|
@ -136,6 +138,12 @@ function migrate(db: SqliteDb): void {
|
|||
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tabs_state (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tabs_project
|
||||
ON tabs(project_id, position);
|
||||
|
||||
|
|
@ -228,6 +236,9 @@ function migrate(db: SqliteDb): void {
|
|||
if (!messageCols.some((c: DbRow) => c.name === 'feedback_json')) {
|
||||
db.exec(`ALTER TABLE messages ADD COLUMN feedback_json TEXT`);
|
||||
}
|
||||
if (!messageCols.some((c: DbRow) => c.name === 'pre_turn_file_names_json')) {
|
||||
db.exec(`ALTER TABLE messages ADD COLUMN pre_turn_file_names_json TEXT`);
|
||||
}
|
||||
const routineRunCols = db.prepare(`PRAGMA table_info(routine_runs)`).all() as DbRow[];
|
||||
if (!routineRunCols.some((c: DbRow) => c.name === 'error_code')) {
|
||||
db.exec(`ALTER TABLE routine_runs ADD COLUMN error_code TEXT`);
|
||||
|
|
@ -243,6 +254,9 @@ function migrate(db: SqliteDb): void {
|
|||
if (!previewCommentCols.some((c: DbRow) => c.name === 'pod_members_json')) {
|
||||
db.exec(`ALTER TABLE preview_comments ADD COLUMN pod_members_json TEXT`);
|
||||
}
|
||||
if (!previewCommentCols.some((c: DbRow) => c.name === 'style_json')) {
|
||||
db.exec(`ALTER TABLE preview_comments ADD COLUMN style_json TEXT`);
|
||||
}
|
||||
const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[];
|
||||
if (!deploymentCols.some((c: DbRow) => c.name === 'status')) {
|
||||
db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`);
|
||||
|
|
@ -874,6 +888,7 @@ export function listMessages(db: SqliteDb, conversationId: string) {
|
|||
comment_attachments_json AS commentAttachmentsJson,
|
||||
produced_files_json AS producedFilesJson,
|
||||
feedback_json AS feedbackJson,
|
||||
pre_turn_file_names_json AS preTurnFileNamesJson,
|
||||
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
|
||||
position
|
||||
FROM messages
|
||||
|
|
@ -895,7 +910,9 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
SET role = ?, content = ?, agent_id = ?, agent_name = ?,
|
||||
run_id = ?, run_status = ?, last_run_event_id = ?,
|
||||
events_json = ?, attachments_json = ?, comment_attachments_json = ?,
|
||||
produced_files_json = ?, feedback_json = ?, started_at = ?, ended_at = ?
|
||||
produced_files_json = ?, feedback_json = ?,
|
||||
pre_turn_file_names_json = ?,
|
||||
started_at = ?, ended_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
m.role,
|
||||
|
|
@ -910,6 +927,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
|
||||
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
|
||||
m.feedback ? JSON.stringify(m.feedback) : null,
|
||||
m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null,
|
||||
m.startedAt ?? null,
|
||||
m.endedAt ?? null,
|
||||
m.id,
|
||||
|
|
@ -921,17 +939,18 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
)
|
||||
.get(conversationId) as DbRow | undefined;
|
||||
const position = (max?.m ?? -1) + 1;
|
||||
// 18 values: id, conversation_id, role, content, agent_id, agent_name,
|
||||
// 19 values: id, conversation_id, role, content, agent_id, agent_name,
|
||||
// run_id, run_status, last_run_event_id, events_json, attachments_json,
|
||||
// comment_attachments_json, produced_files_json, feedback_json, started_at, ended_at,
|
||||
// position, created_at.
|
||||
// comment_attachments_json, produced_files_json, feedback_json,
|
||||
// pre_turn_file_names_json, started_at, ended_at, position, created_at.
|
||||
db.prepare(
|
||||
`INSERT INTO messages
|
||||
(id, conversation_id, role, content, agent_id, agent_name,
|
||||
run_id, run_status, last_run_event_id, events_json,
|
||||
attachments_json, comment_attachments_json, produced_files_json,
|
||||
feedback_json, started_at, ended_at, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
feedback_json, pre_turn_file_names_json,
|
||||
started_at, ended_at, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
m.id,
|
||||
conversationId,
|
||||
|
|
@ -947,6 +966,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
|
||||
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
|
||||
m.feedback ? JSON.stringify(m.feedback) : null,
|
||||
m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null,
|
||||
m.startedAt ?? null,
|
||||
m.endedAt ?? null,
|
||||
position,
|
||||
|
|
@ -968,6 +988,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
comment_attachments_json AS commentAttachmentsJson,
|
||||
produced_files_json AS producedFilesJson,
|
||||
feedback_json AS feedbackJson,
|
||||
pre_turn_file_names_json AS preTurnFileNamesJson,
|
||||
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
|
||||
position
|
||||
FROM messages WHERE id = ?`,
|
||||
|
|
@ -1042,7 +1063,7 @@ export function listPreviewComments(db: SqliteDb, projectId: string, conversatio
|
|||
file_path AS filePath, element_id AS elementId, selector, label,
|
||||
text, position_json AS positionJson, html_hint AS htmlHint,
|
||||
selection_kind AS selectionKind, member_count AS memberCount,
|
||||
pod_members_json AS podMembersJson,
|
||||
pod_members_json AS podMembersJson, style_json AS styleJson,
|
||||
note, status, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM preview_comments
|
||||
WHERE project_id = ? AND conversation_id = ?
|
||||
|
|
@ -1065,6 +1086,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
|
|||
const position = normalizePosition(target.position);
|
||||
const selectionKind = target.selectionKind === 'pod' ? 'pod' : 'element';
|
||||
const podMembers = selectionKind === 'pod' ? normalizePodMembers(target.podMembers) : [];
|
||||
const style = normalizeAnnotationStyle(target.style);
|
||||
const memberCount = selectionKind === 'pod'
|
||||
? (podMembers.length > 0
|
||||
? podMembers.length
|
||||
|
|
@ -1086,8 +1108,8 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
|
|||
`INSERT INTO preview_comments
|
||||
(id, project_id, conversation_id, file_path, element_id, selector, label,
|
||||
text, position_json, html_hint, selection_kind, member_count, pod_members_json,
|
||||
note, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
style_json, note, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, conversation_id, file_path, element_id) DO UPDATE SET
|
||||
selector = excluded.selector,
|
||||
label = excluded.label,
|
||||
|
|
@ -1097,6 +1119,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
|
|||
selection_kind = excluded.selection_kind,
|
||||
member_count = excluded.member_count,
|
||||
pod_members_json = excluded.pod_members_json,
|
||||
style_json = excluded.style_json,
|
||||
note = excluded.note,
|
||||
status = 'open',
|
||||
updated_at = excluded.updated_at`,
|
||||
|
|
@ -1114,6 +1137,7 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati
|
|||
selectionKind,
|
||||
selectionKind === 'pod' ? memberCount : null,
|
||||
selectionKind === 'pod' ? JSON.stringify(podMembers) : null,
|
||||
style ? JSON.stringify(style) : null,
|
||||
note,
|
||||
'open',
|
||||
createdAt,
|
||||
|
|
@ -1150,7 +1174,7 @@ function getPreviewComment(db: SqliteDb, projectId: string, conversationId: stri
|
|||
file_path AS filePath, element_id AS elementId, selector, label,
|
||||
text, position_json AS positionJson, html_hint AS htmlHint,
|
||||
selection_kind AS selectionKind, member_count AS memberCount,
|
||||
pod_members_json AS podMembersJson,
|
||||
pod_members_json AS podMembersJson, style_json AS styleJson,
|
||||
note, status, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM preview_comments
|
||||
WHERE id = ? AND project_id = ? AND conversation_id = ?`,
|
||||
|
|
@ -1173,6 +1197,7 @@ function normalizePreviewComment(row: DbRow) {
|
|||
text: row.text,
|
||||
position: parseJsonOrUndef(row.positionJson) ?? { x: 0, y: 0, width: 0, height: 0 },
|
||||
htmlHint: row.htmlHint,
|
||||
style: normalizeAnnotationStyle(parseJsonOrUndef(row.styleJson)),
|
||||
selectionKind: row.selectionKind === 'pod' ? 'pod' : 'element',
|
||||
memberCount:
|
||||
normalizedPodMembers && normalizedPodMembers.length > 0
|
||||
|
|
@ -1214,11 +1239,40 @@ function normalizePodMembers(input: unknown) {
|
|||
typeof member.htmlHint === 'string'
|
||||
? compactWhitespace(member.htmlHint).slice(0, 180)
|
||||
: '',
|
||||
style: normalizeAnnotationStyle(member.style),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeAnnotationStyle(input: unknown) {
|
||||
if (!input || typeof input !== 'object') return undefined;
|
||||
const raw = input as DbRow;
|
||||
const style: DbRow = {};
|
||||
for (const key of ANNOTATION_STYLE_KEYS) {
|
||||
const value = raw[key];
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = compactWhitespace(value);
|
||||
if (trimmed) style[key] = trimmed.slice(0, 120);
|
||||
}
|
||||
return Object.keys(style).length > 0 ? style : undefined;
|
||||
}
|
||||
|
||||
const ANNOTATION_STYLE_KEYS = [
|
||||
'color',
|
||||
'backgroundColor',
|
||||
'fontSize',
|
||||
'fontWeight',
|
||||
'lineHeight',
|
||||
'textAlign',
|
||||
'fontFamily',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'borderRadius',
|
||||
] as const;
|
||||
|
||||
function compactWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
|
@ -1256,6 +1310,7 @@ function normalizeMessage(row: DbRow) {
|
|||
commentAttachments: parseJsonOrUndef(row.commentAttachmentsJson),
|
||||
producedFiles: parseJsonOrUndef(row.producedFilesJson),
|
||||
feedback: parseJsonOrUndef(row.feedbackJson),
|
||||
preTurnFileNames: parseJsonOrUndef(row.preTurnFileNamesJson),
|
||||
createdAt: row.createdAt ?? undefined,
|
||||
startedAt: row.startedAt ?? undefined,
|
||||
endedAt: row.endedAt ?? undefined,
|
||||
|
|
@ -1488,15 +1543,24 @@ export function listTabs(db: SqliteDb, projectId: string) {
|
|||
FROM tabs WHERE project_id = ? ORDER BY position ASC`,
|
||||
)
|
||||
.all(projectId) as DbRow[];
|
||||
const state = db
|
||||
.prepare(`SELECT project_id FROM tabs_state WHERE project_id = ? LIMIT 1`)
|
||||
.get(projectId) as DbRow | undefined;
|
||||
const active = (rows as DbRow[]).find((r: DbRow) => r.isActive) ?? null;
|
||||
return {
|
||||
tabs: (rows as DbRow[]).map((r: DbRow) => r.name),
|
||||
active: active ? active.name : null,
|
||||
hasSavedState: rows.length > 0 || Boolean(state),
|
||||
};
|
||||
}
|
||||
|
||||
export function setTabs(db: SqliteDb, projectId: string, names: string[], activeName: string | null) {
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO tabs_state (project_id, updated_at)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(project_id) DO UPDATE SET updated_at = excluded.updated_at`,
|
||||
).run(projectId, Date.now());
|
||||
db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId);
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO tabs (project_id, name, position, is_active)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function registerDeployRoutes(app: Express, ctx: RegisterDeployRoutesDeps
|
|||
PROJECTS_DIR,
|
||||
req.params.id,
|
||||
fileName,
|
||||
{ metadata: deployProject?.metadata },
|
||||
{ metadata: deployProject?.metadata, includeProjectFiles: true },
|
||||
);
|
||||
const project = getProject(db, req.params.id);
|
||||
const cloudflarePagesProjectName =
|
||||
|
|
@ -171,7 +171,7 @@ export function registerDeployRoutes(app: Express, ctx: RegisterDeployRoutesDeps
|
|||
PROJECTS_DIR,
|
||||
req.params.id,
|
||||
fileName,
|
||||
{ metadata: preflightProject?.metadata, providerId },
|
||||
{ metadata: preflightProject?.metadata, providerId, includeProjectFiles: true },
|
||||
);
|
||||
res.json(body);
|
||||
} catch (err: any) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import os from 'node:os';
|
|||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { hash as blake3Hash } from 'blake3-wasm';
|
||||
import { readProjectFile, validateProjectPath } from './projects.js';
|
||||
import { listFiles, readProjectFile, validateProjectPath } from './projects.js';
|
||||
|
||||
export const VERCEL_PROVIDER_ID = 'vercel-self';
|
||||
export const CLOUDFLARE_PAGES_PROVIDER_ID = 'cloudflare-pages';
|
||||
|
|
@ -29,7 +29,12 @@ type CloudflarePagesConfigHints = {
|
|||
};
|
||||
type DeployFile = { file: string; data: Buffer | Uint8Array | string; contentType?: string; sourcePath?: string };
|
||||
type DeployFilePlan = { entryPath: string; html: string; files: DeployFile[]; missing: string[]; invalid: string[] };
|
||||
type DeployOptions = { metadata?: unknown; hookScriptUrl?: string; providerId?: DeployProviderId };
|
||||
type DeployOptions = {
|
||||
metadata?: unknown;
|
||||
hookScriptUrl?: string;
|
||||
providerId?: DeployProviderId;
|
||||
includeProjectFiles?: boolean;
|
||||
};
|
||||
type CloudflarePagesDeploySelection = { zoneId: string; zoneName: string; domainPrefix: string; hostname: string };
|
||||
type CloudflareDnsRecord = JsonObject & { id?: string; type?: string; name?: string; content?: string; comment?: string };
|
||||
type DeployLinkStatus = 'ready' | 'protected' | 'failed' | 'link-delayed';
|
||||
|
|
@ -318,6 +323,14 @@ export async function buildDeployFilePlan(projectsRoot: string, projectId: strin
|
|||
}
|
||||
}
|
||||
|
||||
if (options.includeProjectFiles) {
|
||||
await addVisibleProjectFilesToDeployPlan(files, {
|
||||
projectsRoot,
|
||||
projectId,
|
||||
metadata: options.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
entryPath,
|
||||
html,
|
||||
|
|
@ -341,6 +354,37 @@ export async function buildDeployFileSet(projectsRoot: string, projectId: string
|
|||
return plan.files;
|
||||
}
|
||||
|
||||
async function addVisibleProjectFilesToDeployPlan(
|
||||
files: Map<string, DeployFile>,
|
||||
input: { projectsRoot: string; projectId: string; metadata?: unknown },
|
||||
) {
|
||||
if (isLinkedFolderProject(input.metadata)) return;
|
||||
const projectFiles = await listFiles(input.projectsRoot, input.projectId, { metadata: input.metadata });
|
||||
for (const item of projectFiles) {
|
||||
if (!item?.name || files.has(item.name)) continue;
|
||||
const safePath = validateProjectPath(item.name);
|
||||
// The selected entry is already mapped to provider-root index.html. Keep
|
||||
// the original root index reserved so choosing index-v1.html does not get
|
||||
// overwritten by the old launcher at the same deploy path.
|
||||
if (safePath === 'index.html') continue;
|
||||
const projectFile = await readProjectFile(input.projectsRoot, input.projectId, safePath, input.metadata);
|
||||
files.set(safePath, {
|
||||
file: safePath,
|
||||
data: projectFile.buffer,
|
||||
contentType: projectFile.mime,
|
||||
sourcePath: safePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isLinkedFolderProject(metadata: unknown) {
|
||||
return Boolean(
|
||||
metadata
|
||||
&& typeof metadata === 'object'
|
||||
&& typeof (metadata as { baseDir?: unknown }).baseDir === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
export async function deployToVercel({ config, files, projectId }: { config: DeployConfig; files: DeployFile[]; projectId: string }) {
|
||||
if (!config?.token) {
|
||||
throw new DeployError('Vercel token is required.', 400);
|
||||
|
|
|
|||
54
apps/daemon/src/design-system-rename-args.ts
Normal file
54
apps/daemon/src/design-system-rename-args.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Pure argument parser for `od design-systems rename <id> --title <new>`.
|
||||
// Kept out of cli.ts (a top-level dispatch script that runs on import) so it
|
||||
// can be unit-tested directly, mirroring research/cli-args.ts.
|
||||
//
|
||||
// Accepts the new name either as a `--title <value>` / `--title=<value>` flag
|
||||
// or as the trailing positional(s) after the id (so `rename <id> "New name"`
|
||||
// works). String flags that take a separate value (`--daemon-url <url>`, etc.)
|
||||
// have that value skipped so it is never mistaken for the id or title.
|
||||
|
||||
export interface DesignSystemRenameArgs {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const STRING_FLAGS_WITH_VALUE = new Set(['daemon-url', 'query', 'tag', 'title']);
|
||||
|
||||
// A separate flag value must be a real token, not the next flag. Without this
|
||||
// guard, `--title --json` would read "--json" as the title and rename the
|
||||
// system to a flag name. A leading dash means the user must use the
|
||||
// `--title=<value>` form for a title that genuinely starts with a dash.
|
||||
function isFlagValue(token: string | undefined): token is string {
|
||||
return token !== undefined && !token.startsWith('-');
|
||||
}
|
||||
|
||||
export function parseDesignSystemRenameArgs(args: string[]): DesignSystemRenameArgs | null {
|
||||
let flagTitle: string | undefined;
|
||||
const positionals: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (arg.startsWith('--')) {
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq >= 0 ? arg.slice(2, eq) : arg.slice(2);
|
||||
const inlineValue = eq >= 0 ? arg.slice(eq + 1) : undefined;
|
||||
if (key === 'title') {
|
||||
if (inlineValue !== undefined) {
|
||||
flagTitle = inlineValue;
|
||||
} else if (isFlagValue(args[i + 1])) {
|
||||
flagTitle = args[++i];
|
||||
}
|
||||
// else: `--title` with no real value -> leave it unset so the missing
|
||||
// title fails usage validation below instead of swallowing a flag.
|
||||
} else if (inlineValue === undefined && STRING_FLAGS_WITH_VALUE.has(key) && isFlagValue(args[i + 1])) {
|
||||
i++; // consume the separate flag value so it is not read as a positional
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('-')) continue; // short flag, no positional
|
||||
positionals.push(arg);
|
||||
}
|
||||
const id = positionals[0];
|
||||
const title = (flagTitle ?? positionals.slice(1).join(' ') ?? '').trim();
|
||||
if (!id || !title) return null;
|
||||
return { id, title };
|
||||
}
|
||||
15
apps/daemon/src/design-systems-cli-help.ts
Normal file
15
apps/daemon/src/design-systems-cli-help.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Help surface for `od design-systems`. Kept pure and separate from cli.ts so a
|
||||
// test can assert the advertised subcommands without spawning the CLI or
|
||||
// stubbing process.exit / console.log.
|
||||
|
||||
export const DESIGN_SYSTEMS_USAGE = `Usage:
|
||||
od design-systems list List design systems.
|
||||
od design-systems show <id> Print one entry.
|
||||
od design-systems rename <id> --title <new> Rename an editable design system.`;
|
||||
|
||||
// `help`, `--help`, and `-h` all route to the usage text above. Without the
|
||||
// flag forms, `od design-systems --help` falls through to the generic library
|
||||
// list, which only advertises `list` and `show` and never mentions `rename`.
|
||||
export function isDesignSystemsHelpArg(arg: string | undefined): boolean {
|
||||
return arg === 'help' || arg === '--help' || arg === '-h';
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
|
||||
import { parseFrontmatter } from './frontmatter.js';
|
||||
import type { FrontmatterObject, FrontmatterValue } from './frontmatter.js';
|
||||
import { extractSwiftColors } from './swift-colors.js';
|
||||
|
||||
export type DesignSystemSurface = 'web' | 'image' | 'video' | 'audio';
|
||||
export type DesignSystemSource = 'built-in' | 'installed' | 'user';
|
||||
|
|
@ -2823,6 +2824,28 @@ function extractSwatches(raw: string): string[] {
|
|||
// Form B: "**Stripe Purple** (`#533afd`)"
|
||||
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
|
||||
while ((m = reB.exec(raw)) !== null) push(m[1] ?? '', m[2] ?? '');
|
||||
// Form C: markdown table rows, e.g.
|
||||
// | Window canvas | `--window-background` | `#1a1a1d` | base |
|
||||
// Use the first cell that holds a hex as the value, and the first plain
|
||||
// text cell (not the hex, not a `---` separator) as the name. Header and
|
||||
// separator rows carry no hex, so they are skipped. Form A/B run first, so
|
||||
// inline definitions still win in pickSwatchRow when a file mixes both.
|
||||
const reC = /^[ \t]*\|(.+)\|[ \t]*$/gm;
|
||||
while ((m = reC.exec(raw)) !== null) {
|
||||
const cells = (m[1] ?? '').split('|').map((cell) => cell.trim());
|
||||
const hexCell = cells.find((cell) => /#[0-9a-fA-F]{3,8}\b/.test(cell));
|
||||
if (!hexCell) continue;
|
||||
const hex = hexCell.match(/#[0-9a-fA-F]{3,8}/)?.[0] ?? '';
|
||||
const nameCell = cells.find(
|
||||
(cell) => cell.length > 0 && !/#[0-9a-fA-F]{3,8}/.test(cell) && !/^[-:\s]+$/.test(cell),
|
||||
);
|
||||
push(nameCell ?? '', hex);
|
||||
}
|
||||
// Form D: SwiftUI Color(...) declarations (HSB / RGB / white), converted to
|
||||
// hex. Swift repos define palette tokens in source rather than CSS, so a
|
||||
// captured ColorSystem.swift or a DESIGN.md that quotes it would otherwise
|
||||
// yield no swatches. Inline hex forms above still win in pickSwatchRow.
|
||||
for (const token of extractSwiftColors(raw)) push(token.name, token.hex);
|
||||
if (colors.length === 0) return [];
|
||||
return pickSwatchRow(colors).values;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@ import {
|
|||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_MODES,
|
||||
type SidecarStamp,
|
||||
} from '@open-design/sidecar-proto';
|
||||
import {
|
||||
resolveLogFilePath,
|
||||
resolveNamespaceRoot,
|
||||
resolveRuntimeNamespaceRoot,
|
||||
type SidecarRuntimeContext,
|
||||
} from '@open-design/sidecar';
|
||||
|
||||
|
|
@ -48,10 +49,14 @@ export const STANDALONE_LAUNCH_WARNING =
|
|||
|
||||
function buildSidecarLogSources(runtime: SidecarRuntimeContext<SidecarStamp> | null): LogSource[] {
|
||||
if (runtime == null) return [];
|
||||
const namespaceRoot = resolveNamespaceRoot({
|
||||
base: runtime.base,
|
||||
// In packaged builds `runtime.base` is `<namespaceRoot>/runtime`, so the log
|
||||
// tree lives a level UP at `<namespaceRoot>/logs`; `resolveRuntimeNamespaceRoot`
|
||||
// accounts for that (a plain `resolveNamespaceRoot` here resolved every
|
||||
// daemon/web log to an ENOENT phantom path and captured none of them).
|
||||
const namespaceRoot = resolveRuntimeNamespaceRoot({
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace: runtime.namespace,
|
||||
runtime,
|
||||
runtimeMode: SIDECAR_MODES.RUNTIME,
|
||||
});
|
||||
const apps = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP];
|
||||
const sources: LogSource[] = [];
|
||||
|
|
|
|||
|
|
@ -95,7 +95,10 @@ function cloneVoiceOptions(voices: ElevenLabsVoiceOption[]): ElevenLabsVoiceOpti
|
|||
|
||||
export async function listElevenLabsVoiceOptions(
|
||||
projectRoot: string,
|
||||
options: { limit?: number } = {},
|
||||
options: {
|
||||
limit?: number;
|
||||
requestInit?: Pick<RequestInit, 'dispatcher'>;
|
||||
} = {},
|
||||
): Promise<ElevenLabsVoiceOption[]> {
|
||||
const credentials = await resolveProviderConfig(projectRoot, 'elevenlabs');
|
||||
if (!credentials.apiKey) {
|
||||
|
|
@ -122,6 +125,7 @@ export async function listElevenLabsVoiceOptions(
|
|||
}
|
||||
|
||||
const resp = await fetch(`${baseUrl}/v2/voices?page_size=${pageSize}`, {
|
||||
...options.requestInit,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'xi-api-key': credentials.apiKey,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
validateProjectPath,
|
||||
} from './projects.js';
|
||||
import { exportProjectTranscript } from './transcript-export.js';
|
||||
import { googleGenerateContentUrl } from './google-models.js';
|
||||
|
||||
// Re-export the request/response types so existing daemon-internal
|
||||
// imports (and the route handler) keep their referenced names. The
|
||||
|
|
@ -595,9 +596,8 @@ function buildFinalizeProviderRequest(params: FinalizeProviderCallParams): Final
|
|||
}
|
||||
|
||||
if (params.protocol === 'google') {
|
||||
const clean = params.baseUrl.replace(/\/+$/, '');
|
||||
return {
|
||||
url: `${clean}/v1beta/models/${encodeURIComponent(params.model)}:generateContent`,
|
||||
url: googleGenerateContentUrl(params.baseUrl, params.model),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-goog-api-key': params.apiKey,
|
||||
|
|
|
|||
33
apps/daemon/src/google-models.ts
Normal file
33
apps/daemon/src/google-models.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export function googleGenerativeLanguageBaseUrl(baseUrl: string): string {
|
||||
const url = new URL(baseUrl);
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
const pathname = url.pathname
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/v\d+(?:beta)?$/i, '');
|
||||
url.pathname = pathname || '/';
|
||||
return url.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function normalizeGoogleModelId(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
return trimmed.startsWith('models/') ? trimmed.slice('models/'.length) : trimmed;
|
||||
}
|
||||
|
||||
export function googleModelPathSegment(model: string): string {
|
||||
return encodeURIComponent(normalizeGoogleModelId(model));
|
||||
}
|
||||
|
||||
export function googleGenerateContentUrl(baseUrl: string, model: string): string {
|
||||
return `${googleGenerativeLanguageBaseUrl(baseUrl)}/v1beta/models/${googleModelPathSegment(model)}:generateContent`;
|
||||
}
|
||||
|
||||
export function googleStreamGenerateContentUrl(baseUrl: string, model: string): string {
|
||||
return `${googleGenerativeLanguageBaseUrl(baseUrl)}/v1beta/models/${googleModelPathSegment(model)}:streamGenerateContent?alt=sse`;
|
||||
}
|
||||
|
||||
export function googleProviderModelsUrl(baseUrl: string, apiKey: string): string {
|
||||
const url = new URL(`${googleGenerativeLanguageBaseUrl(baseUrl)}/v1beta/models`);
|
||||
url.searchParams.set('key', apiKey);
|
||||
return url.toString();
|
||||
}
|
||||
59
apps/daemon/src/http/adapter.ts
Normal file
59
apps/daemon/src/http/adapter.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { Express, Request, Response } from 'express';
|
||||
import { createApiError } from '@open-design/contracts';
|
||||
import { rawInput } from './parse.js';
|
||||
import { sendApiError, sendJson, statusForError } from './response.js';
|
||||
import { guardSameOrigin, type OriginContext } from './origin-guard.js';
|
||||
import type { JsonRouteSpec } from './types.js';
|
||||
|
||||
export interface AdapterContext extends OriginContext {}
|
||||
|
||||
/**
|
||||
* Identity function that pins a route spec's generic parameters at the
|
||||
* definition site so callers do not have to repeat them. The returned spec
|
||||
* is consumed by `mountJsonRoute` (live) and by tests (direct invocation of
|
||||
* `route.parse` / `route.handle`).
|
||||
*/
|
||||
export function defineJsonRoute<Input, Output, Deps>(
|
||||
spec: JsonRouteSpec<Input, Output, Deps>,
|
||||
): JsonRouteSpec<Input, Output, Deps> {
|
||||
return spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts one JsonRouteSpec on an Express app. The Adapter is the only code
|
||||
* here that knows about req/res; the route's parse and handle functions
|
||||
* operate on `RouteInputContext` and `Deps` respectively, so they are unit
|
||||
* testable without Express.
|
||||
*/
|
||||
export function mountJsonRoute<Input, Output, Deps>(
|
||||
app: Express,
|
||||
spec: JsonRouteSpec<Input, Output, Deps>,
|
||||
deps: Deps,
|
||||
adapter: AdapterContext,
|
||||
): void {
|
||||
app[spec.method](spec.path, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (spec.requireSameOrigin) {
|
||||
const origin = guardSameOrigin(req, adapter);
|
||||
if (!origin.ok) {
|
||||
sendApiError(res, statusForError(origin.error), origin.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const parsed = spec.parse(rawInput(req));
|
||||
if (!parsed.ok) {
|
||||
sendApiError(res, statusForError(parsed.error), parsed.error);
|
||||
return;
|
||||
}
|
||||
const result = await spec.handle(parsed.value, deps);
|
||||
if (!result.ok) {
|
||||
sendApiError(res, statusForError(result.error), result.error);
|
||||
return;
|
||||
}
|
||||
sendJson(res, spec.successStatus ?? 200, result.value);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
sendApiError(res, 500, createApiError('INTERNAL_ERROR', message));
|
||||
}
|
||||
});
|
||||
}
|
||||
5
apps/daemon/src/http/index.ts
Normal file
5
apps/daemon/src/http/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './types.js';
|
||||
export * from './parse.js';
|
||||
export * from './response.js';
|
||||
export * from './origin-guard.js';
|
||||
export * from './adapter.js';
|
||||
20
apps/daemon/src/http/origin-guard.ts
Normal file
20
apps/daemon/src/http/origin-guard.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { Request } from 'express';
|
||||
import { createApiError } from '@open-design/contracts';
|
||||
import { isLocalSameOrigin } from '../origin-validation.js';
|
||||
import { err, ok, type Result } from './types.js';
|
||||
|
||||
export interface OriginContext {
|
||||
resolvedPortRef: { current: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter wrapper around `isLocalSameOrigin` that yields a `Result` so the
|
||||
* HTTP Adapter can fold the origin decision into the same error-handling
|
||||
* pipeline as parse/handle failures.
|
||||
*/
|
||||
export function guardSameOrigin(req: Request, origin: OriginContext): Result<void> {
|
||||
if (isLocalSameOrigin(req, origin.resolvedPortRef.current)) {
|
||||
return ok(undefined);
|
||||
}
|
||||
return err(createApiError('FORBIDDEN', 'cross-origin request rejected'));
|
||||
}
|
||||
23
apps/daemon/src/http/parse.ts
Normal file
23
apps/daemon/src/http/parse.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Request } from 'express';
|
||||
import { createApiError, type ApiError } from '@open-design/contracts';
|
||||
import type { RouteInputContext } from './types.js';
|
||||
|
||||
export function rawInput(req: Request): RouteInputContext {
|
||||
return {
|
||||
body: req.body,
|
||||
query: (req.query ?? {}) as Record<string, unknown>,
|
||||
params: (req.params ?? {}) as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
export function validationError(
|
||||
message: string,
|
||||
issues: Array<{ path: string; message: string }> = [],
|
||||
): ApiError {
|
||||
if (issues.length === 0) {
|
||||
return createApiError('BAD_REQUEST', message);
|
||||
}
|
||||
return createApiError('BAD_REQUEST', message, {
|
||||
details: { kind: 'validation', issues } as unknown as NonNullable<ApiError['details']>,
|
||||
});
|
||||
}
|
||||
32
apps/daemon/src/http/response.ts
Normal file
32
apps/daemon/src/http/response.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Response } from 'express';
|
||||
import { createApiErrorResponse, type ApiError, type ApiErrorCode } from '@open-design/contracts';
|
||||
|
||||
export function sendJson(res: Response, status: number, body: unknown): void {
|
||||
res.status(status).json(body);
|
||||
}
|
||||
|
||||
export function sendApiError(res: Response, status: number, error: ApiError): void {
|
||||
res.status(status).json(createApiErrorResponse(error));
|
||||
}
|
||||
|
||||
const ERROR_STATUS_BY_CODE: Partial<Record<ApiErrorCode, number>> = {
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
PAYLOAD_TOO_LARGE: 413,
|
||||
UNSUPPORTED_MEDIA_TYPE: 415,
|
||||
VALIDATION_FAILED: 422,
|
||||
RATE_LIMITED: 429,
|
||||
PROJECT_NOT_FOUND: 404,
|
||||
FILE_NOT_FOUND: 404,
|
||||
ARTIFACT_NOT_FOUND: 404,
|
||||
INTERNAL_ERROR: 500,
|
||||
AGENT_UNAVAILABLE: 503,
|
||||
UPSTREAM_UNAVAILABLE: 502,
|
||||
};
|
||||
|
||||
export function statusForError(error: ApiError): number {
|
||||
return ERROR_STATUS_BY_CODE[error.code] ?? 500;
|
||||
}
|
||||
32
apps/daemon/src/http/types.ts
Normal file
32
apps/daemon/src/http/types.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { ApiError } from '@open-design/contracts';
|
||||
|
||||
export type Result<T, E = ApiError> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
export const ok = <T, E = ApiError>(value: T): Result<T, E> => ({ ok: true, value });
|
||||
export const err = <T = never, E = ApiError>(error: E): Result<T, E> => ({ ok: false, error });
|
||||
|
||||
export interface RouteInputContext {
|
||||
body: unknown;
|
||||
query: Record<string, unknown>;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
export type InputParser<Input> = (raw: RouteInputContext) => Result<Input>;
|
||||
|
||||
export type Handler<Input, Output, Deps> = (
|
||||
input: Input,
|
||||
deps: Deps,
|
||||
) => Promise<Result<Output>> | Result<Output>;
|
||||
|
||||
export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
|
||||
|
||||
export interface JsonRouteSpec<Input, Output, Deps> {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
requireSameOrigin?: boolean;
|
||||
parse: InputParser<Input>;
|
||||
handle: Handler<Input, Output, Deps>;
|
||||
successStatus?: number;
|
||||
}
|
||||
|
|
@ -494,7 +494,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
|
|||
//
|
||||
// See nexu-io/open-design#368 and the architecture lock at
|
||||
// https://github.com/nexu-io/open-design/issues/368#issuecomment-4366243218.
|
||||
app.get('/api/projects/:id/export/*', async (req, res) => {
|
||||
app.get('/api/projects/:id/export/*splat', async (req, res) => {
|
||||
try {
|
||||
if (!isSafeId(req.params.id)) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
||||
|
|
@ -512,7 +512,8 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
|
|||
}
|
||||
|
||||
const project = getProject(db, req.params.id);
|
||||
const relPath = (req.params as any)[0];
|
||||
const splatParam = (req.params as { splat?: string | string[] }).splat;
|
||||
const relPath = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? '');
|
||||
|
||||
// PR #1312 round-5 (lefarcen P2): stat the owner file BEFORE
|
||||
// readProjectFile so a 100 MiB owner HTML is rejected after a
|
||||
|
|
|
|||
144
apps/daemon/src/installation.ts
Normal file
144
apps/daemon/src/installation.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Channel-root installation identity.
|
||||
//
|
||||
// `installationId` was historically stored in `<dataRoot>/app-config.json`,
|
||||
// which lives at `<userData>/namespaces/<namespace>/data/app-config.json`
|
||||
// in packaged builds. Two reinstall scenarios then silently rotated the
|
||||
// id:
|
||||
//
|
||||
// 1. **Namespace churn.** If a packaged build bakes a different
|
||||
// `namespace` token than the previous version, the daemon writes to
|
||||
// a different `<namespace>/data/` subtree and the old installationId
|
||||
// is invisible. The user shows up in PostHog as a brand-new person.
|
||||
//
|
||||
// 2. **Clean reinstall.** Even when the namespace is stable, anything
|
||||
// that wipes `<userData>/namespaces/<ns>/data/` (a future installer
|
||||
// that resets per-namespace data, a manual `rm -rf`) takes the id
|
||||
// down with it.
|
||||
//
|
||||
// To preserve person continuity across both, we mirror the id into a
|
||||
// stable file at the **channel root** — one level above the namespaces
|
||||
// directory — and treat that file as authoritative on read. The legacy
|
||||
// app-config.json field is still written so any code path that reads it
|
||||
// directly (legacy / future fallbacks) keeps seeing the same value.
|
||||
//
|
||||
// Locations:
|
||||
//
|
||||
// packaged (mac): ~/Library/Application Support/Open Design Nightly/installation.json
|
||||
// packaged (win): %APPDATA%/Open Design Nightly/installation.json
|
||||
// packaged (linux): $XDG_CONFIG_HOME/Open Design Nightly/installation.json
|
||||
// tools-dev / OSS: <dataDir>/installation.json (no namespace concept; fall back to dataDir)
|
||||
//
|
||||
// `OD_INSTALLATION_DIR` is the env override. Packaged sidecars.ts sets it
|
||||
// to the channel root explicitly; everything else falls back to dataDir
|
||||
// (where it sits next to app-config.json and behaves like the legacy
|
||||
// path — fine for dev because dev doesn't have namespace churn).
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Wire shape persisted at `<installationDir>/installation.json`.
|
||||
* Kept intentionally narrow: only fields that need to survive a
|
||||
* namespace-scoped data-dir wipe belong here.
|
||||
*/
|
||||
export interface InstallationFile {
|
||||
installationId?: string;
|
||||
// Future fields (privacy decision timestamp, telemetry flags) can join
|
||||
// this list as soon as we have a use case for "they must outlive a
|
||||
// namespace reset". Today, only installationId carries that contract.
|
||||
}
|
||||
|
||||
export function resolveInstallationDir(dataDir: string): string {
|
||||
const env = process.env.OD_INSTALLATION_DIR;
|
||||
if (env && env.length > 0) return env;
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
function installationFilePath(installationDir: string): string {
|
||||
return join(installationDir, 'installation.json');
|
||||
}
|
||||
|
||||
export async function readInstallationFile(
|
||||
installationDir: string,
|
||||
): Promise<InstallationFile> {
|
||||
try {
|
||||
const raw = await readFile(installationFilePath(installationDir), 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const out: InstallationFile = {};
|
||||
if (typeof obj.installationId === 'string' && obj.installationId.length > 0) {
|
||||
out.installationId = obj.installationId;
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; name?: string };
|
||||
if (e.code === 'ENOENT') return {};
|
||||
if (e.name === 'SyntaxError') return {};
|
||||
// Anything else (permission denied, EIO) — treat as empty so the
|
||||
// fallback path through app-config.json keeps the daemon alive.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize writes to the same installationDir so concurrent persists
|
||||
// can't truncate each other. Mirrors the writeAppConfig lock strategy.
|
||||
const writeLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Patch shape for {@link writeInstallationFile}.
|
||||
*
|
||||
* Distinct from `Partial<InstallationFile>` because `exactOptionalPropertyTypes`
|
||||
* blocks `{ installationId: undefined }` and we explicitly need a way to
|
||||
* **clear** the id (Settings → "Delete my data", or an explicit null write
|
||||
* via `writeAppConfig`). The convention here:
|
||||
*
|
||||
* - field present with a non-empty string → assign the new value
|
||||
* - field present with null / empty string → delete the field on disk
|
||||
* - field absent → leave the existing value alone
|
||||
*/
|
||||
export type InstallationFilePatch = {
|
||||
installationId?: string | null;
|
||||
};
|
||||
|
||||
export async function writeInstallationFile(
|
||||
installationDir: string,
|
||||
patch: InstallationFilePatch,
|
||||
): Promise<InstallationFile> {
|
||||
const prev = writeLocks.get(installationDir) ?? Promise.resolve();
|
||||
const task = prev.catch(() => undefined).then(() => doWrite(installationDir, patch));
|
||||
writeLocks.set(installationDir, task);
|
||||
try {
|
||||
return await task;
|
||||
} finally {
|
||||
if (writeLocks.get(installationDir) === task) writeLocks.delete(installationDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function doWrite(
|
||||
installationDir: string,
|
||||
patch: InstallationFilePatch,
|
||||
): Promise<InstallationFile> {
|
||||
const existing = await readInstallationFile(installationDir);
|
||||
const next: InstallationFile = { ...existing };
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'installationId')) {
|
||||
if (typeof patch.installationId === 'string' && patch.installationId.length > 0) {
|
||||
next.installationId = patch.installationId;
|
||||
} else {
|
||||
delete next.installationId;
|
||||
}
|
||||
}
|
||||
await mkdir(dirname(installationFilePath(installationDir)), { recursive: true });
|
||||
// The file is small, the user only writes it on consent + delete-my-data
|
||||
// flows. We deliberately don't use a temp-file + rename dance: a partial
|
||||
// write here just means `readInstallationFile` falls back to app-config.json,
|
||||
// which is the same fallback we use when the file simply doesn't exist yet.
|
||||
await writeFile(
|
||||
installationFilePath(installationDir),
|
||||
JSON.stringify(next, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
return next;
|
||||
}
|
||||
85
apps/daemon/src/integrations/vela-errors.ts
Normal file
85
apps/daemon/src/integrations/vela-errors.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
export type AmrAccountErrorCode = 'AMR_AUTH_REQUIRED' | 'AMR_INSUFFICIENT_BALANCE';
|
||||
|
||||
export interface AmrAccountFailure {
|
||||
code: AmrAccountErrorCode;
|
||||
message: string;
|
||||
action: 'relogin' | 'recharge';
|
||||
actionUrl?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_AMR_RECHARGE_URL = 'https://open-design.ai/amr/wallet';
|
||||
|
||||
const AMR_AUTH_REQUIRED_MESSAGE =
|
||||
'AMR sign-in is required. Sign in to AMR Cloud again, then retry this run.';
|
||||
|
||||
const AMR_INSUFFICIENT_BALANCE_MESSAGE =
|
||||
`AMR Cloud reported insufficient balance for this model. Recharge your AMR wallet at ${DEFAULT_AMR_RECHARGE_URL}, then retry this run.`;
|
||||
|
||||
function normalizeFailureText(text: string): string {
|
||||
return String(text || '').toLowerCase();
|
||||
}
|
||||
|
||||
function containsInsufficientBalanceSignal(value: string): boolean {
|
||||
if (
|
||||
value.includes('insufficient_balance') ||
|
||||
value.includes('insufficient balance') ||
|
||||
value.includes('insufficient wallet balance') ||
|
||||
value.includes('insufficient credits') ||
|
||||
value.includes('insufficient credit') ||
|
||||
value.includes('insufficient funds') ||
|
||||
value.includes('not enough balance') ||
|
||||
value.includes('not enough credits') ||
|
||||
value.includes('balance is empty') ||
|
||||
value.includes('balance too low') ||
|
||||
value.includes('billing balance')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return value.includes('quota') && /\b(wallet|balance|credit|billing|funds?)\b/.test(value);
|
||||
}
|
||||
|
||||
export function classifyAmrAccountFailure(text: string): AmrAccountFailure | null {
|
||||
const value = normalizeFailureText(text);
|
||||
if (!value.trim()) return null;
|
||||
|
||||
if (containsInsufficientBalanceSignal(value)) {
|
||||
return {
|
||||
code: 'AMR_INSUFFICIENT_BALANCE',
|
||||
message: AMR_INSUFFICIENT_BALANCE_MESSAGE,
|
||||
action: 'recharge',
|
||||
actionUrl: DEFAULT_AMR_RECHARGE_URL,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
value.includes('auth_required') ||
|
||||
value.includes('authentication required') ||
|
||||
value.includes('not authenticated') ||
|
||||
value.includes('unauthenticated') ||
|
||||
value.includes('not logged in') ||
|
||||
value.includes('login missing') ||
|
||||
value.includes('sign in again') ||
|
||||
value.includes('sign-in required') ||
|
||||
value.includes('signin required') ||
|
||||
value.includes('token has expired') ||
|
||||
value.includes('expired token') ||
|
||||
value.includes('invalid session') ||
|
||||
value.includes('session expired')
|
||||
) {
|
||||
return {
|
||||
code: 'AMR_AUTH_REQUIRED',
|
||||
message: AMR_AUTH_REQUIRED_MESSAGE,
|
||||
action: 'relogin',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function amrAccountFailureDetails(failure: AmrAccountFailure) {
|
||||
return {
|
||||
kind: 'amr_account',
|
||||
action: failure.action,
|
||||
...(failure.actionUrl ? { actionUrl: failure.actionUrl } : {}),
|
||||
};
|
||||
}
|
||||
21
apps/daemon/src/integrations/vela-profile.ts
Normal file
21
apps/daemon/src/integrations/vela-profile.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const AMR_PROFILE_ENV = 'OPEN_DESIGN_AMR_PROFILE';
|
||||
const DEFAULT_PROFILE = 'prod';
|
||||
const ALLOWED_PROFILES = new Set(['prod', 'test', 'local']);
|
||||
|
||||
export type AmrProfile = 'prod' | 'test' | 'local';
|
||||
|
||||
type EnvMap = NodeJS.ProcessEnv | Record<string, string | undefined>;
|
||||
|
||||
export function resolveAmrProfile(env: EnvMap = process.env): AmrProfile {
|
||||
const raw = (env[AMR_PROFILE_ENV] || '').trim();
|
||||
if (!raw) return DEFAULT_PROFILE;
|
||||
if (ALLOWED_PROFILES.has(raw)) return raw as AmrProfile;
|
||||
console.warn(
|
||||
`[amr] invalid ${AMR_PROFILE_ENV}="${raw}"; falling back to ${DEFAULT_PROFILE}`,
|
||||
);
|
||||
return DEFAULT_PROFILE;
|
||||
}
|
||||
|
||||
export function amrVelaProfileEnv(env: EnvMap = process.env): { VELA_PROFILE: AmrProfile } {
|
||||
return { VELA_PROFILE: resolveAmrProfile(env) };
|
||||
}
|
||||
279
apps/daemon/src/integrations/vela.ts
Normal file
279
apps/daemon/src/integrations/vela.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createCommandInvocation } from '@open-design/platform';
|
||||
|
||||
import { resolveAgentLaunch } from '../runtimes/launch.js';
|
||||
import { spawnEnvForAgent } from '../runtimes/env.js';
|
||||
import { getAgentDef } from '../runtimes/registry.js';
|
||||
import { resolveAmrProfile } from './vela-profile.js';
|
||||
|
||||
export { resolveAmrProfile } from './vela-profile.js';
|
||||
|
||||
export interface VelaUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
image?: string | null;
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export interface VelaLoginStatus {
|
||||
loggedIn: boolean;
|
||||
loginInFlight: boolean;
|
||||
profile: string;
|
||||
user: VelaUser | null;
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
interface VelaProfileShape {
|
||||
controlKey?: string;
|
||||
runtimeKey?: string;
|
||||
apiUrl?: string;
|
||||
linkUrl?: string;
|
||||
user?: VelaUser | null;
|
||||
}
|
||||
|
||||
interface VelaConfigFileShape {
|
||||
profiles?: Record<string, VelaProfileShape>;
|
||||
}
|
||||
|
||||
export function mergeVelaEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
configuredEnv: Record<string, string> = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...env,
|
||||
...configuredEnv,
|
||||
};
|
||||
}
|
||||
|
||||
function configDir(): string {
|
||||
return path.join(homedir(), '.amr');
|
||||
}
|
||||
|
||||
export function amrConfigPath(): string {
|
||||
return path.join(configDir(), 'config.json');
|
||||
}
|
||||
|
||||
function readConfigFile(): VelaConfigFileShape | null {
|
||||
const file = amrConfigPath();
|
||||
if (!existsSync(file)) return null;
|
||||
try {
|
||||
const data = readFileSync(file, 'utf8');
|
||||
const parsed = JSON.parse(data) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed as VelaConfigFileShape;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readVelaLoginStatus(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
configuredEnv: Record<string, string> = {},
|
||||
): VelaLoginStatus {
|
||||
const mergedEnv = mergeVelaEnv(env, configuredEnv);
|
||||
const profile = resolveAmrProfile(mergedEnv);
|
||||
const configPath = amrConfigPath();
|
||||
const loginInFlight = isVelaLoginInFlight();
|
||||
const runtimeKey = mergedEnv.VELA_RUNTIME_KEY?.trim() ?? '';
|
||||
const linkUrl = mergedEnv.VELA_LINK_URL?.trim() ?? '';
|
||||
if (runtimeKey && linkUrl) {
|
||||
return { loggedIn: true, loginInFlight, profile, user: null, configPath };
|
||||
}
|
||||
const file = readConfigFile();
|
||||
const stored = file?.profiles?.[profile];
|
||||
const storedRuntimeKey = stored?.runtimeKey?.trim() ?? '';
|
||||
if (!storedRuntimeKey) {
|
||||
return { loggedIn: false, loginInFlight, profile, user: null, configPath };
|
||||
}
|
||||
const rawUser = stored?.user ?? null;
|
||||
const user: VelaUser | null = rawUser
|
||||
? {
|
||||
id: typeof rawUser.id === 'string' ? rawUser.id : '',
|
||||
email: typeof rawUser.email === 'string' ? rawUser.email : '',
|
||||
...(typeof rawUser.name === 'string' ? { name: rawUser.name } : {}),
|
||||
...(typeof rawUser.image === 'string' ? { image: rawUser.image } : {}),
|
||||
...(typeof rawUser.plan === 'string' ? { plan: rawUser.plan } : {}),
|
||||
}
|
||||
: null;
|
||||
return { loggedIn: true, loginInFlight, profile, user, configPath };
|
||||
}
|
||||
|
||||
export function forgetVelaLogin(env: NodeJS.ProcessEnv = process.env): void {
|
||||
const file = amrConfigPath();
|
||||
if (!existsSync(file)) return;
|
||||
const parsed = readConfigFile();
|
||||
if (!parsed?.profiles) return;
|
||||
const profile = resolveAmrProfile(env);
|
||||
if (!Object.prototype.hasOwnProperty.call(parsed.profiles, profile)) return;
|
||||
const keptProfileConfig = { ...(parsed.profiles[profile] ?? {}) };
|
||||
delete keptProfileConfig.controlKey;
|
||||
delete keptProfileConfig.runtimeKey;
|
||||
delete keptProfileConfig.user;
|
||||
const nextProfiles = { ...parsed.profiles };
|
||||
nextProfiles[profile] = keptProfileConfig;
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ ...parsed, profiles: nextProfiles }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
export interface SpawnedVelaLogin {
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
profile: string;
|
||||
}
|
||||
|
||||
const activeLoginProcs = new Map<number, ChildProcess>();
|
||||
const LOGIN_STARTUP_GRACE_MS = 250;
|
||||
const LOGIN_CANCEL_KILL_GRACE_MS = 2000;
|
||||
|
||||
function isChildRunning(child: ChildProcess): boolean {
|
||||
return child.exitCode === null && child.signalCode === null;
|
||||
}
|
||||
|
||||
export function isVelaLoginInFlight(): boolean {
|
||||
for (const [pid, child] of activeLoginProcs) {
|
||||
if (isChildRunning(child)) return true;
|
||||
activeLoginProcs.delete(pid);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface CancelVelaLoginResult {
|
||||
canceled: boolean;
|
||||
pids: number[];
|
||||
}
|
||||
|
||||
export function cancelVelaLogin(): CancelVelaLoginResult {
|
||||
const pids: number[] = [];
|
||||
for (const [pid, child] of activeLoginProcs) {
|
||||
if (!isChildRunning(child)) {
|
||||
activeLoginProcs.delete(pid);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
activeLoginProcs.delete(pid);
|
||||
continue;
|
||||
}
|
||||
pids.push(pid);
|
||||
const killTimer = setTimeout(() => {
|
||||
try {
|
||||
if (isChildRunning(child)) child.kill('SIGKILL');
|
||||
} catch {
|
||||
activeLoginProcs.delete(pid);
|
||||
}
|
||||
}, LOGIN_CANCEL_KILL_GRACE_MS);
|
||||
killTimer.unref?.();
|
||||
}
|
||||
return { canceled: pids.length > 0, pids };
|
||||
}
|
||||
|
||||
export interface SpawnVelaLoginDeps {
|
||||
configuredEnv?: Record<string, string>;
|
||||
baseEnv?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
async function waitForImmediateLoginFailure(child: ChildProcess): Promise<void> {
|
||||
let stderr = '';
|
||||
let stdout = '';
|
||||
child.stderr?.setEncoding('utf8');
|
||||
child.stdout?.setEncoding('utf8');
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
if (stderr.length < 4096) stderr += String(chunk);
|
||||
});
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
if (stdout.length < 4096) stdout += String(chunk);
|
||||
});
|
||||
|
||||
const result = await new Promise<
|
||||
| { kind: 'running' }
|
||||
| { kind: 'exit'; code: number | null; signal: NodeJS.Signals | null }
|
||||
| { kind: 'error'; error: Error }
|
||||
>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (
|
||||
value:
|
||||
| { kind: 'running' }
|
||||
| { kind: 'exit'; code: number | null; signal: NodeJS.Signals | null }
|
||||
| { kind: 'error'; error: Error },
|
||||
) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
const timer = setTimeout(
|
||||
() => finish({ kind: 'running' }),
|
||||
LOGIN_STARTUP_GRACE_MS,
|
||||
);
|
||||
child.once('exit', (code, signal) => finish({ kind: 'exit', code, signal }));
|
||||
child.once('error', (error) => finish({ kind: 'error', error }));
|
||||
});
|
||||
|
||||
if (result.kind === 'running') return;
|
||||
if (result.kind === 'error') {
|
||||
throw new Error(`vela login failed to start: ${result.error.message}`);
|
||||
}
|
||||
if (result.code === 0) return;
|
||||
const detail = (stderr || stdout).trim();
|
||||
throw new Error(
|
||||
detail ||
|
||||
`vela login exited before authentication completed (code ${result.code ?? 'null'}, signal ${result.signal ?? 'null'})`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function spawnVelaLogin(
|
||||
deps: SpawnVelaLoginDeps = {},
|
||||
): Promise<SpawnedVelaLogin> {
|
||||
if (isVelaLoginInFlight()) {
|
||||
throw new Error('vela login already running');
|
||||
}
|
||||
const def = getAgentDef('amr');
|
||||
if (!def) throw new Error('AMR runtime def not registered');
|
||||
const baseEnv = deps.baseEnv ?? process.env;
|
||||
const configuredEnv = deps.configuredEnv ?? {};
|
||||
const launch = resolveAgentLaunch(def, configuredEnv);
|
||||
const bin = launch.selectedPath;
|
||||
if (!bin) {
|
||||
throw new Error('vela binary not found; install vela or configure VELA_BIN');
|
||||
}
|
||||
const env = spawnEnvForAgent('amr', baseEnv, configuredEnv);
|
||||
// Route through createCommandInvocation so an npm/Node-style `vela.cmd` or
|
||||
// `vela.bat` shim on Windows gets wrapped under `cmd.exe /d /s /c …` with
|
||||
// verbatim args, matching what `execAgentFile` / chat-run spawning do. A
|
||||
// direct `spawn(bin, args)` on a `.cmd` shim quietly fails to find the
|
||||
// shim's actual entry point. POSIX is unchanged (no wrapping needed).
|
||||
const invocation = createCommandInvocation({ command: bin, args: ['login'], env });
|
||||
const child = spawn(invocation.command, invocation.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env,
|
||||
detached: false,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
if (typeof child.pid !== 'number') {
|
||||
throw new Error('failed to spawn vela login');
|
||||
}
|
||||
activeLoginProcs.set(child.pid, child);
|
||||
const cleanup = () => {
|
||||
if (typeof child.pid === 'number') activeLoginProcs.delete(child.pid);
|
||||
};
|
||||
child.once('exit', cleanup);
|
||||
child.once('error', cleanup);
|
||||
await waitForImmediateLoginFailure(child);
|
||||
// We don't surface URL/code in this API — vela CLI opens the browser itself
|
||||
// (via OpenBrowser in apps/cli/internal/commands/login.go). Callers poll
|
||||
// readVelaLoginStatus() to detect completion.
|
||||
return {
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
profile: resolveAmrProfile(env),
|
||||
};
|
||||
}
|
||||
|
|
@ -45,6 +45,54 @@ function stringifyContent(value: unknown): string {
|
|||
}
|
||||
}
|
||||
|
||||
function parseJsonObjectsFromContent(value: string): JsonObject[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
const direct = safeParseJson(trimmed);
|
||||
if (isRecord(direct)) return [direct];
|
||||
const objects: JsonObject[] = [];
|
||||
for (const line of trimmed.split(/\r?\n/u)) {
|
||||
const parsedLine = safeParseJson(line.trim());
|
||||
if (isRecord(parsedLine)) objects.push(parsedLine);
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
function extractConnectorApiError(value: JsonObject): JsonObject | null {
|
||||
if (isRecord(value.error)) {
|
||||
if (typeof value.error.code === 'string') return value.error;
|
||||
if (isRecord(value.error.data) && isRecord(value.error.data.error)) {
|
||||
const wrappedError = value.error.data.error;
|
||||
if (typeof wrappedError.code === 'string') return wrappedError;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function connectorToolSelectionErrorMessage(content: string): string | null {
|
||||
if (!content.includes('CONNECTOR_TOOL_NOT_FOUND')) return null;
|
||||
let error: JsonObject | null = null;
|
||||
for (const parsed of parseJsonObjectsFromContent(content)) {
|
||||
const parsedError = extractConnectorApiError(parsed);
|
||||
if (parsedError?.code === 'CONNECTOR_TOOL_NOT_FOUND') {
|
||||
error = parsedError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!error) return null;
|
||||
const details = isRecord(error.details) ? error.details : {};
|
||||
const connectorId = typeof details.connectorId === 'string' && details.connectorId
|
||||
? details.connectorId
|
||||
: undefined;
|
||||
const toolName = typeof details.toolName === 'string' && details.toolName
|
||||
? details.toolName
|
||||
: 'the requested connector tool';
|
||||
const target = connectorId
|
||||
? `Connector tool ${toolName} is not allowed for connector ${connectorId}.`
|
||||
: `Connector tool ${toolName} is not allowed.`;
|
||||
return `${target} Re-list the connector catalog and choose one of the currently allowed read-only tools.`;
|
||||
}
|
||||
|
||||
function extractErrorMessage(value: unknown, fallback: string): string {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = safeParseJson(value);
|
||||
|
|
@ -71,6 +119,16 @@ function extractErrorMessage(value: unknown, fallback: string): string {
|
|||
return fallback;
|
||||
}
|
||||
|
||||
function isRecoverableCodexReconnect(message: string): boolean {
|
||||
return (
|
||||
message.startsWith('Reconnecting...') &&
|
||||
(
|
||||
message.includes('timeout waiting for child process to exit') ||
|
||||
message.includes('stream disconnected before completion')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function formatOpenCodeUsage(tokens: unknown): Usage | null {
|
||||
if (!isRecord(tokens)) return null;
|
||||
const usage: Usage = {};
|
||||
|
|
@ -272,11 +330,7 @@ function handleCodexEvent(obj: unknown, onEvent: StreamEventHandler, state: Pars
|
|||
if (obj.type === 'error') {
|
||||
const message = extractErrorMessage(obj.message ?? obj.error, 'Codex error');
|
||||
// Reconnecting events are recoverable — treat as status warning, not fatal
|
||||
if (
|
||||
typeof message === 'string' &&
|
||||
message.includes('Reconnecting...') &&
|
||||
message.includes('timeout waiting for child process to exit')
|
||||
) {
|
||||
if (isRecoverableCodexReconnect(message)) {
|
||||
onEvent({ type: 'status', label: message });
|
||||
return true;
|
||||
}
|
||||
|
|
@ -346,12 +400,18 @@ if (obj.type === 'error') {
|
|||
},
|
||||
});
|
||||
}
|
||||
const content = stringifyContent(item.aggregated_output ?? '');
|
||||
onEvent({
|
||||
type: 'tool_result',
|
||||
toolUseId: item.id,
|
||||
content: stringifyContent(item.aggregated_output ?? ''),
|
||||
content,
|
||||
isError: typeof item.exit_code === 'number' ? item.exit_code !== 0 : item.status === 'failed',
|
||||
});
|
||||
const connectorToolError = connectorToolSelectionErrorMessage(content);
|
||||
if (connectorToolError && !state.codexErrorEmitted) {
|
||||
state.codexErrorEmitted = true;
|
||||
onEvent({ type: 'error', message: connectorToolError });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@ import { readAppConfig } from './app-config.js';
|
|||
import type { AppVersionInfo } from './app-version.js';
|
||||
import { listMessages } from './db.js';
|
||||
import {
|
||||
readTelemetrySinkConfig,
|
||||
reportRunCompleted,
|
||||
reportRunFeedback,
|
||||
type ArtifactSummary,
|
||||
type EventsSummary,
|
||||
type FeedbackReportContext,
|
||||
type MessageSummary,
|
||||
type ReportContext,
|
||||
type RuntimeInfo,
|
||||
|
|
@ -357,3 +360,71 @@ export async function reportRunCompletedFromDaemon(
|
|||
console.warn('[langfuse-bridge] report failed:', String(err));
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReportRunFeedbackFromDaemonOpts {
|
||||
dataDir: string;
|
||||
runId: string;
|
||||
rating: 'positive' | 'negative';
|
||||
reasonCodes: string[];
|
||||
hasCustomReason: boolean;
|
||||
/** Raw "other" free text. Empty when no custom reason. */
|
||||
customReason: string;
|
||||
/** Extra context for Langfuse score metadata (projectId / conversationId / assistantMessageId). */
|
||||
scoreMetadata?: Record<string, unknown>;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result for the POST /api/runs/:id/feedback handler. Telemetry is
|
||||
* best-effort and the network call runs after the response is sent, but
|
||||
* the handler still tells the caller whether the report was at least
|
||||
* enqueued — useful for QA and e2e.
|
||||
*/
|
||||
export type FeedbackReportOutcome =
|
||||
| { status: 'accepted' }
|
||||
| { status: 'skipped_consent' }
|
||||
| { status: 'skipped_no_sink' };
|
||||
|
||||
export async function reportRunFeedbackFromDaemon(
|
||||
opts: ReportRunFeedbackFromDaemonOpts,
|
||||
): Promise<FeedbackReportOutcome> {
|
||||
let cfg;
|
||||
try {
|
||||
cfg = await readAppConfig(opts.dataDir);
|
||||
} catch (err) {
|
||||
console.warn('[langfuse-bridge] feedback config read failed:', String(err));
|
||||
return { status: 'skipped_no_sink' };
|
||||
}
|
||||
const prefs = cfg.telemetry ?? {};
|
||||
if (prefs.metrics !== true || prefs.content !== true) {
|
||||
return { status: 'skipped_consent' };
|
||||
}
|
||||
// Pre-resolve the sink before claiming `accepted`. Avoids advertising a
|
||||
// successful enqueue to callers when there's no Langfuse endpoint
|
||||
// configured to ship the score to.
|
||||
const sink = readTelemetrySinkConfig();
|
||||
if (!sink) {
|
||||
return { status: 'skipped_no_sink' };
|
||||
}
|
||||
const ctx: FeedbackReportContext = {
|
||||
runId: opts.runId,
|
||||
installationId: cfg.installationId ?? null,
|
||||
prefs,
|
||||
rating: opts.rating,
|
||||
reasonCodes: opts.reasonCodes,
|
||||
hasCustomReason: opts.hasCustomReason,
|
||||
customReason: opts.customReason,
|
||||
...(opts.scoreMetadata ? { metadata: opts.scoreMetadata } : {}),
|
||||
};
|
||||
// Fire-and-forget the actual network send so the route can respond
|
||||
// immediately. The handler's response already encodes the consent +
|
||||
// sink-presence outcome above; failures inside the send are operational
|
||||
// telemetry, not a client-facing signal.
|
||||
void reportRunFeedback(
|
||||
ctx,
|
||||
opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {},
|
||||
).catch((err) => {
|
||||
console.warn('[langfuse-bridge] feedback report failed:', String(err));
|
||||
});
|
||||
return { status: 'accepted' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,29 @@ export interface ReportRunOpts {
|
|||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload sent to Langfuse when a user thumbs-up/down's an assistant turn.
|
||||
*
|
||||
* The `runId` doubles as the Langfuse trace id (same convention used by
|
||||
* buildTracePayload), so the score lands on the existing trace if the run
|
||||
* was previously reported. If the run wasn't reported (e.g. content
|
||||
* consent was off at run completion, then turned on before the user
|
||||
* scored), Langfuse will accept the score anyway and the trace will
|
||||
* materialize when/if the daemon backfills it.
|
||||
*/
|
||||
export interface FeedbackReportContext {
|
||||
runId: string;
|
||||
installationId: string | null;
|
||||
prefs: TelemetryPrefs;
|
||||
rating: 'positive' | 'negative';
|
||||
reasonCodes: string[];
|
||||
/** Raw "other" free text the user typed. Trimmed; empty string when absent. */
|
||||
customReason: string;
|
||||
hasCustomReason: boolean;
|
||||
/** Optional context bag that ends up in Langfuse score metadata. */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function readLangfuseConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): LangfuseConfig | null {
|
||||
|
|
@ -658,3 +681,105 @@ export async function reportRunCompleted(
|
|||
}
|
||||
await postLangfuseBatch(config, batch, fetchImpl);
|
||||
}
|
||||
|
||||
// Build a Langfuse `score-create` batch for a user-supplied turn rating.
|
||||
//
|
||||
// Langfuse scores let evals filter traces by user feedback. We emit one
|
||||
// NUMERIC score (`user_rating`, +1 / -1) plus optional CATEGORICAL scores
|
||||
// for each reason code, so the Langfuse UI's score filters work out of
|
||||
// the box. Raw custom-reason text rides in the score metadata when the
|
||||
// user opted into telemetry.content; the consent gate lives in
|
||||
// reportRunFeedback below, so this builder stays content-agnostic.
|
||||
//
|
||||
// Limitation: stable score ids (`${traceId}-rating`, `${traceId}-reason-${code}`)
|
||||
// mean re-submission overwrites cleanly, but reason codes the user removes
|
||||
// in a follow-up submission do not get a tombstone. A future change can
|
||||
// thread `removedReasonCodes` through and emit overwriting "cleared"
|
||||
// scores for them; not done here to keep this PR scoped to the bridge.
|
||||
export function buildFeedbackPayload(ctx: FeedbackReportContext): unknown[] {
|
||||
const traceId = ctx.runId;
|
||||
const nowIso = new Date().toISOString();
|
||||
const batch: unknown[] = [];
|
||||
|
||||
const ratingMetadata: Record<string, unknown> = {
|
||||
reasonCodes: ctx.reasonCodes,
|
||||
reasonCount: ctx.reasonCodes.length,
|
||||
hasCustomReason: ctx.hasCustomReason,
|
||||
// Raw text — gated upstream by telemetry.content consent.
|
||||
customReason: ctx.customReason || undefined,
|
||||
installationId: ctx.installationId ?? undefined,
|
||||
...(ctx.metadata ?? {}),
|
||||
};
|
||||
|
||||
batch.push({
|
||||
id: randomUUID(),
|
||||
type: 'score-create',
|
||||
timestamp: nowIso,
|
||||
body: {
|
||||
id: `${traceId}-rating`,
|
||||
traceId,
|
||||
name: 'user_rating',
|
||||
value: ctx.rating === 'positive' ? 1 : -1,
|
||||
dataType: 'NUMERIC',
|
||||
comment: ctx.rating,
|
||||
metadata: ratingMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
for (const code of ctx.reasonCodes) {
|
||||
batch.push({
|
||||
id: randomUUID(),
|
||||
type: 'score-create',
|
||||
timestamp: nowIso,
|
||||
body: {
|
||||
// Stable per (run, code) so re-submission overwrites cleanly.
|
||||
id: `${traceId}-reason-${code}`,
|
||||
traceId,
|
||||
name: 'user_rating_reason',
|
||||
value: code,
|
||||
dataType: 'CATEGORICAL',
|
||||
// Group the reason under the rating it was submitted with so a
|
||||
// "matched_request" tag on a thumbs-down run is still visibly
|
||||
// negative in the Langfuse UI.
|
||||
comment: ctx.rating,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
export async function reportRunFeedback(
|
||||
ctx: FeedbackReportContext,
|
||||
opts: ReportRunOpts = {},
|
||||
): Promise<void> {
|
||||
if (ctx.prefs.metrics !== true) return;
|
||||
if (ctx.prefs.content !== true) return;
|
||||
|
||||
const config = resolveReportConfig(opts);
|
||||
if (!config) return;
|
||||
|
||||
let batch: unknown[];
|
||||
try {
|
||||
batch = buildFeedbackPayload(ctx);
|
||||
} catch (error) {
|
||||
console.warn(`[langfuse-trace] Feedback payload build error: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify({ batch });
|
||||
const serializedBytes = Buffer.byteLength(serialized, 'utf8');
|
||||
if (serializedBytes > HARD_BATCH_MAX_BYTES) {
|
||||
console.warn(
|
||||
`[langfuse-trace] Feedback batch too large (${serializedBytes}B > ${HARD_BATCH_MAX_BYTES}B), dropping feedback for ${ctx.runId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
||||
if (config.kind === 'relay') {
|
||||
await postRelayBatch(config, serialized, fetchImpl);
|
||||
return;
|
||||
}
|
||||
await postLangfuseBatch(config, batch, fetchImpl);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ export interface BuildMcpInstallPayloadInputs {
|
|||
* caller wants propagated into the snippet. The caller decides
|
||||
* what's worth propagating; this builder just merges. */
|
||||
sidecarEnv: Record<string, string>;
|
||||
/** Browser-facing Open Design studio base URL (e.g.
|
||||
* `http://127.0.0.1:65321`). Used by MCP clients to build deep
|
||||
* links to `/projects/.../conversations/.../files/...` so the
|
||||
* outer agent can suggest a URL that shows both the file preview
|
||||
* and the chat history for the run. Null when the daemon was
|
||||
* launched without a known web port (CLI-only / headless). */
|
||||
webBaseUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface McpInstallPayload {
|
||||
|
|
@ -35,6 +42,10 @@ export interface McpInstallPayload {
|
|||
args: string[];
|
||||
env: Record<string, string>;
|
||||
daemonUrl: string;
|
||||
/** Browser-facing studio base URL the daemon is paired with, when
|
||||
* known. MCP clients use this plus run/project context to build a
|
||||
* studio deep link the outer agent can hand back to the user. */
|
||||
webBaseUrl: string | null;
|
||||
platform: NodeJS.Platform;
|
||||
cliExists: boolean;
|
||||
nodeExists: boolean;
|
||||
|
|
@ -85,6 +96,10 @@ export function buildMcpInstallPayload(
|
|||
args,
|
||||
env,
|
||||
daemonUrl: `http://127.0.0.1:${inputs.port}`,
|
||||
webBaseUrl:
|
||||
typeof inputs.webBaseUrl === 'string' && inputs.webBaseUrl.length > 0
|
||||
? inputs.webBaseUrl
|
||||
: null,
|
||||
// Surface platform so the install panel can localize path hints
|
||||
// (~/.cursor vs %USERPROFILE%\.cursor) and keyboard shortcuts
|
||||
// (Cmd vs Ctrl).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { Express } from 'express';
|
||||
import fs from 'node:fs';
|
||||
import { SIDECAR_ENV } from '@open-design/sidecar-proto';
|
||||
import { buildMcpInstallPayload } from './mcp-install-info.js';
|
||||
import { buildMcpInstallPayload, type McpInstallPayload } from './mcp-install-info.js';
|
||||
import { installCodexMcp, probeCodexInstall, uninstallCodexMcp } from './codex-cli.js';
|
||||
import { MCP_TEMPLATES, buildAcpMcpServers, buildClaudeMcpJson, isManagedProjectCwd, readMcpConfig, writeMcpConfig } from './mcp-config.js';
|
||||
import { beginAuth, exchangeCodeForToken, refreshAccessToken } from './mcp-oauth.js';
|
||||
import { clearToken, getToken, isTokenExpired, readAllTokens, setToken } from './mcp-tokens.js';
|
||||
|
|
@ -24,20 +25,15 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) {
|
|||
const INSTALL_INFO_TTL_MS = 5000;
|
||||
let installInfoCache: { t: number; payload: object } | null = null;
|
||||
|
||||
app.get('/api/mcp/install-info', (req, res) => {
|
||||
if (!isLocalSameOrigin(req, getResolvedPort())) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
const now = Date.now();
|
||||
if (installInfoCache && now - installInfoCache.t < INSTALL_INFO_TTL_MS) {
|
||||
return res.json(installInfoCache.payload);
|
||||
}
|
||||
// process.execPath is the absolute path to the Node-compatible
|
||||
// runtime that is running the daemon RIGHT NOW. In packaged builds
|
||||
// this may be Electron running with ELECTRON_RUN_AS_NODE=1 rather
|
||||
// than a separate bundled Node binary; the helper surfaces that env
|
||||
// requirement with the command so IDE-spawned MCP clients can
|
||||
// reproduce the same mode from a minimal OS launcher environment.
|
||||
// Resolve the install snippet for the current daemon. Shared by the
|
||||
// public GET /api/mcp/install-info endpoint (renders TOML/JSON for
|
||||
// the user to copy) and POST /api/mcp/install/codex (which feeds the
|
||||
// exact same fields into `codex mcp add` so the one-click install
|
||||
// configures Codex with byte-for-byte the same command as the copy
|
||||
// snippet would). Keeping this in one place is the whole point of
|
||||
// the factoring — divergence here would mean Codex behaves
|
||||
// differently depending on which install path the user took.
|
||||
function computeInstallPayload(): McpInstallPayload {
|
||||
const cliPath = OD_BIN;
|
||||
// The daemon was bootstrapped as a sidecar (tools-dev, packaged) iff
|
||||
// bootstrapSidecarRuntime stamped OD_SIDECAR_IPC_PATH into the env.
|
||||
|
|
@ -53,9 +49,25 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) {
|
|||
if (isSidecarMode) {
|
||||
sidecarEnv[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
|
||||
}
|
||||
const payload = buildMcpInstallPayload({
|
||||
// tools-dev / packaged launchers export OD_WEB_PORT so the daemon
|
||||
// knows where the browser-facing Open Design studio is running.
|
||||
// CLI-only / headless launches set neither and webBaseUrl falls
|
||||
// through as null — MCP clients then just omit the studio deep
|
||||
// link from their responses.
|
||||
const webPortRaw = process.env.OD_WEB_PORT;
|
||||
const webPortNum = webPortRaw ? Number(webPortRaw) : Number.NaN;
|
||||
const webBaseUrl = Number.isFinite(webPortNum) && webPortNum > 0
|
||||
? `http://127.0.0.1:${webPortNum}`
|
||||
: null;
|
||||
return buildMcpInstallPayload({
|
||||
cliPath,
|
||||
cliExists: fs.existsSync(cliPath),
|
||||
// process.execPath is the absolute path to the Node-compatible
|
||||
// runtime running the daemon RIGHT NOW. In packaged builds this
|
||||
// may be Electron with ELECTRON_RUN_AS_NODE=1 rather than a
|
||||
// separate bundled Node binary; the helper surfaces that env
|
||||
// requirement on the command so IDE-spawned MCP clients can
|
||||
// reproduce the same mode from a minimal OS launcher env.
|
||||
execPath: process.execPath,
|
||||
nodeExists: fs.existsSync(process.execPath),
|
||||
port: getResolvedPort(),
|
||||
|
|
@ -64,11 +76,74 @@ export function registerMcpRoutes(app: Express, ctx: RegisterMcpRoutesDeps) {
|
|||
electronAsNode: process.env.ELECTRON_RUN_AS_NODE === '1',
|
||||
isSidecarMode,
|
||||
sidecarEnv,
|
||||
webBaseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/api/mcp/install-info', (req, res) => {
|
||||
if (!isLocalSameOrigin(req, getResolvedPort())) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
const now = Date.now();
|
||||
if (installInfoCache && now - installInfoCache.t < INSTALL_INFO_TTL_MS) {
|
||||
return res.json(installInfoCache.payload);
|
||||
}
|
||||
const payload = computeInstallPayload();
|
||||
installInfoCache = { t: now, payload };
|
||||
res.json(payload);
|
||||
});
|
||||
|
||||
// Codex one-click install. Codex CLI exposes `codex mcp add/remove/get`,
|
||||
// so we shell out to it rather than rewriting ~/.codex/config.toml
|
||||
// ourselves — that way we inherit Codex's merge / validation rules
|
||||
// and only need to track its argv. See apps/daemon/src/codex-cli.ts.
|
||||
const CODEX_MCP_NAME = 'open-design';
|
||||
|
||||
app.get('/api/mcp/install/codex/status', async (req, res) => {
|
||||
if (!isLocalSameOrigin(req, getResolvedPort())) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
try {
|
||||
const status = await probeCodexInstall(CODEX_MCP_NAME);
|
||||
res.json(status);
|
||||
} catch (err) {
|
||||
sendApiError(res, 500, 'CODEX_PROBE_FAILED', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mcp/install/codex', async (req, res) => {
|
||||
if (!isLocalSameOrigin(req, getResolvedPort())) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
const payload = computeInstallPayload();
|
||||
if (!payload.cliExists || !payload.nodeExists) {
|
||||
return sendApiError(res, 500, 'INSTALL_INFO_INCOMPLETE', payload.buildHint ?? 'install payload not ready');
|
||||
}
|
||||
try {
|
||||
await installCodexMcp({
|
||||
name: CODEX_MCP_NAME,
|
||||
command: payload.command,
|
||||
args: payload.args,
|
||||
env: payload.env,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
sendApiError(res, 500, 'CODEX_INSTALL_FAILED', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mcp/install/codex', async (req, res) => {
|
||||
if (!isLocalSameOrigin(req, getResolvedPort())) {
|
||||
return res.status(403).json({ error: 'cross-origin request rejected' });
|
||||
}
|
||||
try {
|
||||
await uninstallCodexMcp(CODEX_MCP_NAME);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
sendApiError(res, 500, 'CODEX_UNINSTALL_FAILED', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// External MCP server configuration. Open Design connects to these as a
|
||||
// CLIENT and surfaces their tools to the underlying agent at spawn time.
|
||||
// GET returns user-saved entries plus the built-in template list so the UI
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue