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 codex/skill-catalog-tree
# Conflicts: # apps/daemon/src/cli.ts
This commit is contained in:
commit
e9112c812f
903 changed files with 70892 additions and 6892 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
|
||||
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:'
|
||||
|
|
|
|||
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"
|
||||
|
|
|
|||
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
|
||||
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"
|
||||
|
|
|
|||
93
.github/workflows/ci.yml
vendored
93
.github/workflows/ci.yml
vendored
|
|
@ -80,13 +80,14 @@ jobs:
|
|||
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|.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)
|
||||
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
|
||||
|
|
@ -151,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]
|
||||
|
|
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ name: fork-pr-workflow-approval
|
|||
# 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 capture, visual verify); privileged
|
||||
# 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:
|
||||
|
|
|
|||
125
.github/workflows/landing-page-ci.yml
vendored
125
.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 }}
|
||||
|
|
@ -69,7 +88,7 @@ jobs:
|
|||
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', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**') }}
|
||||
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 }}-
|
||||
|
||||
|
|
@ -92,18 +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
|
||||
env:
|
||||
PUBLIC_GA_MEASUREMENT_ID: ${{ vars.PUBLIC_GA_MEASUREMENT_ID }}
|
||||
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" \
|
||||
|
|
@ -112,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" \
|
||||
|
|
@ -153,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
|
||||
|
|
@ -70,7 +86,7 @@ jobs:
|
|||
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', 'skills/**', 'design-templates/**', 'templates/live-artifacts/**') }}
|
||||
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 }}-
|
||||
|
||||
|
|
@ -93,11 +109,25 @@ 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
|
||||
env:
|
||||
PUBLIC_GA_MEASUREMENT_ID: ${{ vars.PUBLIC_GA_MEASUREMENT_ID }}
|
||||
OD_LANDING_NOINDEX: '1'
|
||||
run: pnpm --filter @open-design/landing-page build:static
|
||||
|
||||
- name: Verify zero external JavaScript
|
||||
|
|
@ -134,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 }}
|
||||
|
|
@ -143,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"
|
||||
}
|
||||
5
.github/workflows/release-beta.yml
vendored
5
.github/workflows/release-beta.yml
vendored
|
|
@ -177,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
|
||||
|
|
@ -459,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
|
||||
|
|
@ -477,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"
|
||||
|
|
|
|||
6
.github/workflows/release-preview.yml
vendored
6
.github/workflows/release-preview.yml
vendored
|
|
@ -456,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
|
||||
|
|
@ -474,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"
|
||||
|
|
@ -727,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
|
||||
|
|
@ -743,6 +744,7 @@ 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
|
||||
|
|
|
|||
6
.github/workflows/release-stable.yml
vendored
6
.github/workflows/release-stable.yml
vendored
|
|
@ -503,7 +503,7 @@ jobs:
|
|||
"--namespace", "${{ needs.metadata.outputs.win_namespace }}",
|
||||
"--portable",
|
||||
"--app-version", "${{ needs.metadata.outputs.release_version }}",
|
||||
"--to", "nsis",
|
||||
"--to", "all",
|
||||
"--json"
|
||||
)
|
||||
"cache_failed=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
|
|
@ -521,7 +521,7 @@ jobs:
|
|||
--namespace "${{ needs.metadata.outputs.win_namespace }}" `
|
||||
--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"
|
||||
|
|
@ -888,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
|
||||
|
|
@ -921,6 +922,7 @@ 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
|
||||
|
|
|
|||
267
.github/workflows/visual-pr-comment.yml
vendored
267
.github/workflows/visual-pr-comment.yml
vendored
|
|
@ -27,20 +27,21 @@ concurrency:
|
|||
jobs:
|
||||
comment:
|
||||
name: Publish PR visual diff comment
|
||||
# Fork PR capture artifacts are untrusted. Publish comments/R2 reports for
|
||||
# same-repository PRs automatically; fork captures require maintainer
|
||||
# workflow_dispatch with a specific capture run id and PR number.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
# 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_name == 'workflow_run' && github.event.workflow_run.head_repository.full_name || github.repository }}
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
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
|
||||
|
|
@ -57,7 +58,7 @@ jobs:
|
|||
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 }}
|
||||
|
||||
|
|
@ -68,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")"
|
||||
|
|
@ -88,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
|
||||
|
|
@ -116,13 +104,73 @@ 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
|
||||
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
|
||||
{
|
||||
|
|
@ -132,12 +180,96 @@ jobs:
|
|||
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
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
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 }}
|
||||
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
||||
|
|
@ -151,58 +283,15 @@ jobs:
|
|||
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
|
||||
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
|
||||
if [ "$pr_state" != "OPEN" ]; then
|
||||
echo "Refusing trusted checkout for PR $PR_NUMBER because state is $pr_state." >&2
|
||||
exit 1
|
||||
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 "Refusing trusted checkout for PR $PR_NUMBER because it is draft." >&2
|
||||
exit 1
|
||||
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 "Artifact head_sha ($ARTIFACT_HEAD_SHA) does not match live PR head ($current_head)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$current_base" != "$ARTIFACT_BASE_SHA" ]; then
|
||||
echo "Artifact base_sha ($ARTIFACT_BASE_SHA) does not match live PR base ($current_base)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "base_sha=$current_base" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout trusted base revision
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
clean: false
|
||||
fetch-depth: 0
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ steps.trusted-pr.outputs.base_sha }}
|
||||
|
||||
- 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: Stop stale visual runs
|
||||
id: stale
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ steps.manifest.outputs.pr_number }}
|
||||
ARTIFACT_HEAD_SHA: ${{ steps.manifest.outputs.head_sha }}
|
||||
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)"
|
||||
current_head="$(jq -r '.headRefOid' <<< "$pr_json")"
|
||||
current_base="$(jq -r '.baseRefOid' <<< "$pr_json")"
|
||||
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"
|
||||
|
|
@ -216,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 }}
|
||||
|
|
@ -235,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 }}
|
||||
|
|
@ -248,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
|
||||
|
|
@ -270,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 }}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ root `pnpm tools-pr` script without a new explicit maintainer decision.
|
|||
## 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`.
|
||||
|
|
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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>
|
||||
|
||||
|
|
|
|||
56
README.md
56
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>
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
@ -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** |
|
||||
|
|
@ -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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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-24" 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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1005,7 +1005,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-24" 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 +1022,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-24" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-24" />
|
||||
<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.8.0",
|
||||
"version": "0.8.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/cli.js",
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
"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",
|
||||
|
|
@ -56,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');
|
||||
|
|
@ -70,6 +70,7 @@ interface AttachAcpSessionOptions {
|
|||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
stageTimeoutMs?: number;
|
||||
modelUnavailableErrorCode?: 'AMR_MODEL_UNAVAILABLE';
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
|
|
@ -426,6 +427,7 @@ export function attachAcpSession({
|
|||
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 +445,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 +470,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');
|
||||
};
|
||||
|
||||
|
|
@ -575,6 +607,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 +671,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 +712,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 +744,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.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -142,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'])],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -240,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 {
|
||||
|
|
@ -251,6 +255,16 @@ 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,
|
||||
|
|
@ -281,7 +295,7 @@ export async function executeGenerateSpeech(
|
|||
base_resp?: { status_code?: number; status_msg?: string };
|
||||
};
|
||||
try {
|
||||
const resp = await fetch(appendSenseAudioApiPath(baseUrl, '/t2a_v2'), {
|
||||
const resp = await fetch(appendSenseAudioApiPath(baseUrl, '/t2a_v2'), withToolRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
|
|
@ -305,7 +319,7 @@ export async function executeGenerateSpeech(
|
|||
channel: 2,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}));
|
||||
const respText = await resp.text();
|
||||
if (!resp.ok) {
|
||||
return { ok: false, error: `senseaudio speech ${resp.status}: ${respText.slice(0, 240)}` };
|
||||
|
|
@ -420,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}`,
|
||||
|
|
@ -431,7 +445,7 @@ export async function executeGenerateImage(
|
|||
prompt,
|
||||
size,
|
||||
}),
|
||||
});
|
||||
}));
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
return {
|
||||
|
|
@ -469,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}` };
|
||||
}
|
||||
|
|
@ -596,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}`,
|
||||
|
|
@ -610,7 +624,7 @@ export async function executeGenerateVideo(
|
|||
ratio,
|
||||
provider_specific: { generate_audio: generateAudio },
|
||||
}),
|
||||
});
|
||||
}));
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
return {
|
||||
|
|
@ -639,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 {
|
||||
|
|
@ -702,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}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ import {
|
|||
} 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';
|
||||
|
||||
// Allowlist for the `/feedback` route. Mirrors the
|
||||
// ChatMessageFeedbackReasonCode union in packages/contracts/src/api/chat.ts.
|
||||
|
|
@ -220,9 +221,17 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
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) ------------------------
|
||||
|
|
@ -270,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)}`,
|
||||
|
|
@ -671,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',
|
||||
|
|
@ -722,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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -771,9 +791,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',
|
||||
|
|
@ -820,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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -891,9 +916,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 requestInit = {
|
||||
...proxyDispatcher.requestInit,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -962,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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1011,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',
|
||||
|
|
@ -1061,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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1098,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),
|
||||
|
|
@ -1136,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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1231,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`
|
||||
|
|
@ -1265,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',
|
||||
|
|
@ -1408,8 +1452,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
};
|
||||
|
||||
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
|
||||
|
|
@ -1436,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();
|
||||
|
|
@ -1495,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) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
|
|||
import { splitResearchSubcommand } from './research/cli-args.js';
|
||||
import { resolveDaemonUrl } from './daemon-url.js';
|
||||
import { buildSkillCatalogTree } from '@open-design/contracts';
|
||||
import { requestJsonIpc } from '@open-design/sidecar';
|
||||
import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
|
|
@ -151,6 +153,7 @@ const PROJECT_STRING_FLAGS = new Set([
|
|||
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
|
||||
'pending-prompt', 'project', 'conversation', 'message', 'path', 'as',
|
||||
'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps', 'editor',
|
||||
'title', 'against',
|
||||
]);
|
||||
const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']);
|
||||
// `od automation …` mirrors the Automations tab. Same surface, same
|
||||
|
|
@ -195,6 +198,8 @@ const RECOVERABLE_EXIT_CODES = {
|
|||
'snapshot-stale': 72,
|
||||
'genui-surface-awaiting': 73,
|
||||
'daemon-protocol-error': 74,
|
||||
'desktop-auth-pending': 74,
|
||||
'desktop-import-token-rejected': 75,
|
||||
};
|
||||
const PLUGIN_LIST_FILTER_FLAGS = new Set([
|
||||
...PLUGIN_STRING_FLAGS,
|
||||
|
|
@ -479,7 +484,8 @@ async function runMediaGenerate(rawArgs) {
|
|||
|
||||
const daemonUrl = await cliDaemonUrl(flags);
|
||||
const projectId = flags.project || process.env.OD_PROJECT_ID;
|
||||
if (!projectId) {
|
||||
const token = process.env.OD_TOOL_TOKEN;
|
||||
if (!projectId && !token) {
|
||||
console.error(
|
||||
'project id required. Pass --project <id> or set OD_PROJECT_ID. The daemon injects this when it spawns the code agent.',
|
||||
);
|
||||
|
|
@ -513,12 +519,17 @@ async function runMediaGenerate(rawArgs) {
|
|||
if (flags['prompt-influence'] != null) body.promptInfluence = Number(flags['prompt-influence']);
|
||||
if (flags.loop === true) body.loop = true;
|
||||
|
||||
const url = `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/generate`;
|
||||
const url = token
|
||||
? `${daemonUrl.replace(/\/$/, '')}/api/tools/media/generate`
|
||||
: `${daemonUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/media/generate`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -750,6 +761,22 @@ function parseFlags(argv, opts = {}) {
|
|||
return out;
|
||||
}
|
||||
|
||||
function positionalArgs(argv, stringFlags = new Set()) {
|
||||
const out = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (!a) continue;
|
||||
if (!a.startsWith('--')) {
|
||||
out.push(a);
|
||||
continue;
|
||||
}
|
||||
const eq = a.indexOf('=');
|
||||
const key = eq >= 0 ? a.slice(2, eq) : a.slice(2);
|
||||
if (eq < 0 && stringFlags.has(key)) i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function cliDaemonUrl(flags) {
|
||||
return resolveDaemonUrl({ flagUrl: flags?.['daemon-url'] });
|
||||
}
|
||||
|
|
@ -789,7 +816,7 @@ Common options:
|
|||
it, and forwards it to the upstream API.
|
||||
--daemon-url <url>
|
||||
|
||||
Output: a single line of JSON: {"file": { name, size, kind, mime, ... }}.
|
||||
Output: a single line of JSON: {"file": { name, size, kind, mime, ... }}
|
||||
|
||||
Skills should call this and then reference the returned filename in their
|
||||
artifact / message body. The daemon writes the bytes into the project's
|
||||
|
|
@ -886,21 +913,38 @@ function exitWithStructuredError({ code, message, data }) {
|
|||
async function structuredHttpFailure(resp, fallbackCode = 'daemon-not-running') {
|
||||
let parsed;
|
||||
try { parsed = await resp.json(); } catch { parsed = {}; }
|
||||
const errCode = parsed?.error?.code;
|
||||
const errCode = normalizeRecoverableErrorCode(parsed?.error?.code, parsed?.error?.message);
|
||||
if (errCode && errCode in RECOVERABLE_EXIT_CODES) {
|
||||
exitWithStructuredError({
|
||||
code: errCode,
|
||||
message: parsed.error.message ?? `HTTP ${resp.status}`,
|
||||
data: parsed.error.data,
|
||||
data: structuredErrorData(parsed.error),
|
||||
});
|
||||
}
|
||||
exitWithStructuredError({
|
||||
code: fallbackCode,
|
||||
message: parsed?.error?.message ?? `HTTP ${resp.status}: ${await resp.text().catch(() => '')}`,
|
||||
data: parsed?.error?.data,
|
||||
data: structuredErrorData(parsed?.error),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRecoverableErrorCode(code, message) {
|
||||
if (code === 'DESKTOP_AUTH_PENDING') return 'desktop-auth-pending';
|
||||
if (code === 'FORBIDDEN' && /desktop import token rejected/i.test(String(message ?? ''))) {
|
||||
return 'desktop-import-token-rejected';
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function structuredErrorData(error) {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const data = {};
|
||||
if ('data' in error && error.data !== undefined) Object.assign(data, error.data);
|
||||
if ('details' in error && error.details !== undefined) data.details = error.details;
|
||||
if (typeof error.retryable === 'boolean') data.retryable = error.retryable;
|
||||
return Object.keys(data).length > 0 ? data : undefined;
|
||||
}
|
||||
|
||||
async function runPlugin(args) {
|
||||
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
||||
printPluginHelp();
|
||||
|
|
@ -932,6 +976,7 @@ async function runPlugin(args) {
|
|||
case 'scaffold': return runPluginScaffold(rest);
|
||||
case 'validate': return runPluginValidate(rest);
|
||||
case 'pack': return runPluginPack(rest);
|
||||
case 'candidates': return runPluginCandidates(rest);
|
||||
case 'login': return runPluginLogin(rest);
|
||||
case 'whoami': return runPluginWhoami(rest);
|
||||
case 'export': return runPluginExport(rest);
|
||||
|
|
@ -3020,6 +3065,85 @@ function coerceCliValue(raw) {
|
|||
return raw;
|
||||
}
|
||||
|
||||
async function runPluginCandidates(rest) {
|
||||
const sub = rest[0];
|
||||
const args = rest.slice(1);
|
||||
const flags = parseFlags(args, {
|
||||
string: new Set(['daemon-url', 'project', 'action']),
|
||||
boolean: new Set(['help', 'h', 'json', 'include-dismissed']),
|
||||
});
|
||||
if (!sub || flags.help || flags.h) {
|
||||
console.log(`Usage:
|
||||
od plugin candidates list --project <projectId> [--json] [--include-dismissed]
|
||||
od plugin candidates draft <candidateId> --project <projectId> [--json]
|
||||
od plugin candidates dismiss <candidateId> --project <projectId> [--json]
|
||||
|
||||
Lists and formalizes persisted skill-to-plugin candidates.`);
|
||||
process.exit(!sub ? 2 : 0);
|
||||
}
|
||||
const projectId = typeof flags.project === 'string' && flags.project.length > 0 ? flags.project : '';
|
||||
if (!projectId) {
|
||||
console.error('--project <projectId> is required');
|
||||
process.exit(2);
|
||||
}
|
||||
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
|
||||
if (sub === 'list') {
|
||||
const qs = flags['include-dismissed'] ? '?includeDismissed=true' : '';
|
||||
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates${qs}`);
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (!resp.ok) {
|
||||
console.error(`GET plugin candidates failed: ${resp.status} ${JSON.stringify(data)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
|
||||
if (candidates.length === 0) {
|
||||
console.log('No plugin candidates.');
|
||||
return;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
console.log(`${candidate.id}\t${candidate.status}\t${candidate.title}\t${candidate.draftPath ?? ''}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const candidateId = args.find((a) => !a.startsWith('-') && a !== flags.project && a !== flags.action);
|
||||
if (!candidateId) {
|
||||
console.error(`candidate id is required for ${sub}`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (sub === 'draft') {
|
||||
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(candidateId)}/draft`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
} else if (resp.ok) {
|
||||
console.log(`[candidate] draft: ${data.draftPath}`);
|
||||
console.log(`[candidate] validation ok=${data.validation?.ok}`);
|
||||
} else {
|
||||
console.error(`[candidate] draft failed: ${data?.message ?? JSON.stringify(data)}`);
|
||||
}
|
||||
process.exit(resp.ok ? 0 : resp.status === 422 ? 4 : 1);
|
||||
}
|
||||
if (sub === 'dismiss') {
|
||||
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(candidateId)}/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (flags.json) process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
else if (resp.ok) console.log(`[candidate] dismissed ${candidateId}`);
|
||||
else console.error(`[candidate] dismiss failed: ${data?.message ?? JSON.stringify(data)}`);
|
||||
process.exit(resp.ok ? 0 : 1);
|
||||
}
|
||||
console.error(`unknown subcommand: od plugin candidates ${sub}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Phase 4 / spec §14.1 — `od plugin publish --to <catalog>`.
|
||||
//
|
||||
// Reads the installed plugin's manifest metadata (or the snapshot's
|
||||
|
|
@ -4156,6 +4280,8 @@ function printPluginHelp() {
|
|||
(manifest parse + atom + ref checks).
|
||||
od plugin pack <folder> [--out <path>] Build a .tgz archive of a plugin
|
||||
folder for distribution.
|
||||
od plugin candidates list --project <id>
|
||||
List persisted skill-to-plugin candidates.
|
||||
od plugin publish-repo <folder> Create/update the author's public
|
||||
GitHub repo for a plugin folder.
|
||||
od plugin open-design-pr <folder> Push a community-catalog branch and
|
||||
|
|
@ -4204,6 +4330,7 @@ async function runProject(args) {
|
|||
console.log(`Usage:
|
||||
od project create [--name "<title>"] [--skill <id>] [--design-system <id>]
|
||||
[--plugin <id>] [--inputs <json>] [--metadata-json <path|->]
|
||||
od project import <baseDir> [--name "<title>"]
|
||||
od project list List projects.
|
||||
od project info <id> Print one project.
|
||||
od project delete <id> Delete a project.
|
||||
|
|
@ -4310,6 +4437,35 @@ Common options:
|
|||
console.log(`[project] created ${data.project?.id ?? id} (conversation ${data.conversationId})`);
|
||||
return;
|
||||
}
|
||||
case 'import': {
|
||||
const [baseDir] = positionalArgs(rest, PROJECT_STRING_FLAGS);
|
||||
const importBaseDir = typeof baseDir === 'string' ? baseDir.trim() : '';
|
||||
if (!importBaseDir) {
|
||||
console.error('Usage: od project import <baseDir> [--name "<title>"]');
|
||||
process.exit(2);
|
||||
}
|
||||
const body = { baseDir: importBaseDir };
|
||||
if (typeof flags.name === 'string' && flags.name.length > 0) body.name = flags.name;
|
||||
if (typeof flags.skill === 'string' && flags.skill.length > 0) body.skillId = flags.skill;
|
||||
if (typeof flags['design-system'] === 'string' && flags['design-system'].length > 0) {
|
||||
body.designSystemId = flags['design-system'];
|
||||
}
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
const importToken = await mintCliImportToken(importBaseDir);
|
||||
if (importToken != null) {
|
||||
headers['x-od-desktop-import-token'] = importToken;
|
||||
}
|
||||
const resp = await fetch(`${base}/api/import/folder`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) return structuredHttpFailure(resp);
|
||||
const data = await resp.json();
|
||||
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
console.log(`[project] imported ${data.project?.id ?? '-'} (conversation ${data.conversationId ?? '-'})`);
|
||||
return;
|
||||
}
|
||||
case 'delete': {
|
||||
const id = rest.find((a) => !a.startsWith('-'));
|
||||
if (!id) {
|
||||
|
|
@ -4536,6 +4692,8 @@ async function runFiles(args) {
|
|||
od files upload <projectId> <localpath> [--as <relpath>]
|
||||
Upload a local file.
|
||||
od files delete <projectId> <name> Delete a project file.
|
||||
od files diff <projectId> <relpathA> [<relpathB> | --against -]
|
||||
Print a unified diff.
|
||||
|
||||
Common options:
|
||||
--daemon-url <url> Open Design daemon HTTP base.
|
||||
|
|
@ -4648,15 +4806,178 @@ Common options:
|
|||
console.log(`[files] deleted ${name}`);
|
||||
return;
|
||||
}
|
||||
case 'diff': {
|
||||
const positional = positionalArgs(rest, PROJECT_STRING_FLAGS);
|
||||
const [id, relA, relB] = positional;
|
||||
const against = typeof flags.against === 'string' ? flags.against : null;
|
||||
if (!id || !relA || (!relB && !against) || (relB && against)) {
|
||||
console.error('Usage: od files diff <projectId> <relpathA> [<relpathB> | --against -]');
|
||||
process.exit(2);
|
||||
}
|
||||
const left = await fetchProjectFileText(base, id, relA);
|
||||
const rightLabel = against ?? relB;
|
||||
const right = against === '-'
|
||||
? await readStdinUtf8()
|
||||
: await fetchProjectFileText(base, id, rightLabel);
|
||||
const diff = createUnifiedDiff(`a/${relA}`, `b/${rightLabel}`, left, right);
|
||||
if (flags.json) return process.stdout.write(JSON.stringify({ diff }, null, 2) + '\n');
|
||||
process.stdout.write(diff);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
console.error(`unknown subcommand: od files ${sub}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeProjectRelpath(rel) {
|
||||
return String(rel).split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
async function fetchProjectFileText(base, id, rel) {
|
||||
const resp = await fetch(
|
||||
`${base}/api/projects/${encodeURIComponent(id)}/files/${encodeProjectRelpath(rel)}`,
|
||||
);
|
||||
if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found');
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
return buf.toString('utf8');
|
||||
}
|
||||
|
||||
async function readStdinUtf8() {
|
||||
const fs = await import('node:fs');
|
||||
return fs.readFileSync(0, 'utf8');
|
||||
}
|
||||
|
||||
async function mintCliImportToken(baseDir) {
|
||||
const socketPath = process.env[SIDECAR_ENV.IPC_PATH];
|
||||
if (typeof socketPath !== 'string' || socketPath.length === 0) return null;
|
||||
let result;
|
||||
try {
|
||||
result = await requestJsonIpc(
|
||||
socketPath,
|
||||
{ type: SIDECAR_MESSAGES.MINT_IMPORT_TOKEN, input: { baseDir } },
|
||||
{ timeoutMs: 800 },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (result?.ok === true && typeof result.token === 'string' && result.token.length > 0) {
|
||||
return result.token;
|
||||
}
|
||||
if (result?.ok === false && result.code === 'DESKTOP_AUTH_PENDING') {
|
||||
exitWithStructuredError({
|
||||
code: 'desktop-auth-pending',
|
||||
message: result.message ?? 'desktop auth required but secret not yet registered',
|
||||
data: { retryable: result.retryable === true },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createUnifiedDiff(leftLabel, rightLabel, leftText, rightText) {
|
||||
if (leftText === rightText) return '';
|
||||
const leftLines = splitDiffLines(leftText);
|
||||
const rightLines = splitDiffLines(rightText);
|
||||
let prefix = 0;
|
||||
while (
|
||||
prefix < leftLines.length
|
||||
&& prefix < rightLines.length
|
||||
&& leftLines[prefix] === rightLines[prefix]
|
||||
) {
|
||||
prefix++;
|
||||
}
|
||||
let leftEnd = leftLines.length;
|
||||
let rightEnd = rightLines.length;
|
||||
while (
|
||||
leftEnd > prefix
|
||||
&& rightEnd > prefix
|
||||
&& leftLines[leftEnd - 1] === rightLines[rightEnd - 1]
|
||||
) {
|
||||
leftEnd--;
|
||||
rightEnd--;
|
||||
}
|
||||
const oldMid = leftLines.slice(prefix, leftEnd);
|
||||
const newMid = rightLines.slice(prefix, rightEnd);
|
||||
const body = diffLineBody(oldMid, newMid);
|
||||
if (body.length === 0) {
|
||||
body.push(...oldMid.map((line) => diffLine('-', line)), ...newMid.map((line) => diffLine('+', line)));
|
||||
}
|
||||
const oldStart = oldMid.length === 0 ? prefix : prefix + 1;
|
||||
const newStart = newMid.length === 0 ? prefix : prefix + 1;
|
||||
return [
|
||||
`--- ${leftLabel}`,
|
||||
`+++ ${rightLabel}`,
|
||||
`@@ -${formatDiffRange(oldStart, oldMid.length)} +${formatDiffRange(newStart, newMid.length)} @@`,
|
||||
...body,
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
function splitDiffLines(text) {
|
||||
const value = String(text);
|
||||
if (value.length === 0) return [];
|
||||
return value.match(/.*?(?:\r\n|\n|\r|$)/gs).filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function formatDiffRange(start, length) {
|
||||
return length === 1 ? String(start) : `${start},${length}`;
|
||||
}
|
||||
|
||||
function diffLineBody(oldLines, newLines) {
|
||||
if (oldLines.length === 0) return newLines.map((line) => diffLine('+', line));
|
||||
if (newLines.length === 0) return oldLines.map((line) => diffLine('-', line));
|
||||
if (oldLines.length * newLines.length > 1_000_000) {
|
||||
return [...oldLines.map((line) => diffLine('-', line)), ...newLines.map((line) => diffLine('+', line))];
|
||||
}
|
||||
const width = newLines.length + 1;
|
||||
const lcs = Array.from(
|
||||
{ length: oldLines.length + 1 },
|
||||
() => new Uint32Array(width),
|
||||
);
|
||||
for (let i = oldLines.length - 1; i >= 0; i--) {
|
||||
for (let j = newLines.length - 1; j >= 0; j--) {
|
||||
lcs[i][j] = oldLines[i] === newLines[j]
|
||||
? lcs[i + 1][j + 1] + 1
|
||||
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < oldLines.length && j < newLines.length) {
|
||||
if (oldLines[i] === newLines[j]) {
|
||||
out.push(diffLine(' ', oldLines[i]));
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
out.push(diffLine('-', oldLines[i]));
|
||||
i++;
|
||||
} else {
|
||||
out.push(diffLine('+', newLines[j]));
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < oldLines.length) out.push(diffLine('-', oldLines[i++]));
|
||||
while (j < newLines.length) out.push(diffLine('+', newLines[j++]));
|
||||
return out;
|
||||
}
|
||||
|
||||
function diffLine(prefix, line) {
|
||||
const value = String(line);
|
||||
if (value.endsWith('\r\n')) return `${prefix}${renderDiffLineContent(value.slice(0, -1))}`;
|
||||
if (value.endsWith('\n')) return `${prefix}${renderDiffLineContent(value.slice(0, -1))}`;
|
||||
if (value.endsWith('\r')) return `${prefix}${renderDiffLineContent(value)}`;
|
||||
return `${prefix}${renderDiffLineContent(value)}\n\\ No newline at end of file`;
|
||||
}
|
||||
|
||||
function renderDiffLineContent(value) {
|
||||
return String(value).replace(/\r/g, '\\r');
|
||||
}
|
||||
|
||||
async function runConversation(args) {
|
||||
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage:
|
||||
od conversation new <projectId> [--title "<title>"]
|
||||
Create a conversation in a project.
|
||||
od conversation list <projectId> List conversations in a project.
|
||||
od conversation info <conversationId> Print one conversation.
|
||||
|
||||
|
|
@ -4670,6 +4991,25 @@ Common options:
|
|||
const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS });
|
||||
const base = (await projectDaemonUrl(flags)).replace(/\/$/, '');
|
||||
switch (sub) {
|
||||
case 'new': {
|
||||
const [id] = positionalArgs(rest, PROJECT_STRING_FLAGS);
|
||||
if (!id) {
|
||||
console.error('Usage: od conversation new <projectId> [--title "<title>"]');
|
||||
process.exit(2);
|
||||
}
|
||||
const body = {};
|
||||
if (typeof flags.title === 'string') body.title = flags.title;
|
||||
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/conversations`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found');
|
||||
const data = await resp.json();
|
||||
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
||||
console.log(`[conversation] created ${data.conversation?.id ?? '-'}`);
|
||||
return;
|
||||
}
|
||||
case 'list': {
|
||||
const id = rest.find((a) => !a.startsWith('-'));
|
||||
if (!id) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -48,13 +54,16 @@ import {
|
|||
} 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,
|
||||
|
|
@ -165,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.
|
||||
|
|
@ -208,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
|
||||
|
|
@ -476,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;
|
||||
|
|
@ -485,6 +826,7 @@ async function validateLocalOpenAiModel(
|
|||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...requestInit,
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${String(input.apiKey)}` },
|
||||
signal,
|
||||
|
|
@ -517,6 +859,118 @@ 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>;
|
||||
|
|
@ -729,17 +1183,29 @@ 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 senseAudioNonChatResult = await validateSenseAudioNonChatModel(
|
||||
input,
|
||||
controller.signal,
|
||||
start,
|
||||
proxyDispatcher.requestInit,
|
||||
);
|
||||
if (senseAudioNonChatResult) return senseAudioNonChatResult;
|
||||
|
||||
const requestInit = {
|
||||
...proxyDispatcher.requestInit,
|
||||
method: 'POST',
|
||||
headers: call.headers,
|
||||
signal: controller.signal,
|
||||
|
|
@ -936,6 +1402,7 @@ export async function testProviderConnection(
|
|||
} finally {
|
||||
clearTimeout(timer);
|
||||
input.signal?.removeEventListener('abort', abortFromParent);
|
||||
await proxyDispatcher?.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1125,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,
|
||||
});
|
||||
|
|
@ -1184,6 +1657,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: input.agentId,
|
||||
detail: `Unknown agent id: ${input.agentId}`,
|
||||
diagnostics: { phase: 'binary_resolution' },
|
||||
};
|
||||
}
|
||||
const configuredAgentEnv = agentCliEnvForAgent(
|
||||
|
|
@ -1199,6 +1673,7 @@ async function testAgentConnectionInternal(
|
|||
latencyMs: Date.now() - start,
|
||||
model,
|
||||
agentName: def.name,
|
||||
diagnostics: { phase: 'binary_resolution' },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1210,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);
|
||||
|
|
@ -1226,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)) {
|
||||
|
|
@ -1234,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',
|
||||
|
|
@ -1241,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 },
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1259,6 +1781,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: auth.message ?? cursorAuthGuidance(),
|
||||
diagnostics: buildDiagnostics(),
|
||||
};
|
||||
}
|
||||
if (detail && isLikelyModelErrorText(detail)) {
|
||||
|
|
@ -1272,6 +1795,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail,
|
||||
diagnostics: buildDiagnostics({ phase: 'output_parse' }),
|
||||
};
|
||||
}
|
||||
console.warn(
|
||||
|
|
@ -1284,6 +1808,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail,
|
||||
diagnostics: buildDiagnostics(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1298,6 +1823,7 @@ async function testAgentConnectionInternal(
|
|||
latencyMs,
|
||||
model,
|
||||
agentName: def.name,
|
||||
diagnostics: buildDiagnostics(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1313,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',
|
||||
|
|
@ -1320,6 +1850,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: redactSecrets(detail),
|
||||
diagnostics: buildDiagnostics(),
|
||||
};
|
||||
}
|
||||
const stdinMode =
|
||||
|
|
@ -1335,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',
|
||||
|
|
@ -1342,6 +1884,7 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: auth.message ?? cursorAuthGuidance(),
|
||||
diagnostics: buildDiagnostics(probeOverrides),
|
||||
};
|
||||
}
|
||||
const invocation = createCommandInvocation({
|
||||
|
|
@ -1349,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'],
|
||||
|
|
@ -1402,6 +1951,9 @@ async function testAgentConnectionInternal(
|
|||
model,
|
||||
agentName: def.name,
|
||||
detail: `${detail}${guidance}`,
|
||||
diagnostics: buildDiagnostics({
|
||||
phase: isMissing ? 'binary_resolution' : 'spawn',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1431,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();
|
||||
|
|
@ -1459,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({
|
||||
|
|
@ -1480,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(
|
||||
|
|
@ -1504,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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1560,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',
|
||||
|
|
@ -1567,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);
|
||||
|
|
|
|||
|
|
@ -116,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,
|
||||
|
|
@ -137,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);
|
||||
|
||||
|
|
@ -247,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'`);
|
||||
|
|
@ -1053,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 = ?
|
||||
|
|
@ -1076,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
|
||||
|
|
@ -1097,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,
|
||||
|
|
@ -1108,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`,
|
||||
|
|
@ -1125,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,
|
||||
|
|
@ -1161,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 = ?`,
|
||||
|
|
@ -1184,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
|
||||
|
|
@ -1225,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();
|
||||
}
|
||||
|
|
@ -1500,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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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);
|
||||
|
|
@ -352,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ interface DesignSystemsPayload { designSystems?: CatalogItem[] }
|
|||
interface ResourcePayload { skill?: { body?: string; content?: string }; designSystem?: { body?: string; content?: string }; body?: string; content?: string }
|
||||
interface ProjectSummary { id: string; name: string; metadata?: JsonObject }
|
||||
interface ProjectsPayload { projects?: ProjectSummary[] }
|
||||
interface ProjectPayload { project?: ProjectSummary; id?: string; name?: string; metadata?: JsonObject }
|
||||
interface ProjectPayload { project?: ProjectSummary; id?: string; name?: string; metadata?: JsonObject; resolvedDir?: string }
|
||||
interface ActiveContext { active?: boolean; projectId?: string; projectName?: string | null; fileName?: string | null; ageMs?: number | null }
|
||||
type ResolvedProject = { id: string; name: string; source: 'uuid' | 'id' | 'exact' | 'slug' | 'substring' };
|
||||
interface ProjectListCache { baseUrl: string; t: number; list: ProjectSummary[] }
|
||||
|
|
@ -127,7 +127,7 @@ const TOOL_DEFS = [
|
|||
{
|
||||
name: 'get_project',
|
||||
description:
|
||||
'Single project metadata: name, active skill/design-system ids, entryFile, kind, timestamps.',
|
||||
'Single project metadata: name, active skill/design-system ids, entryFile, kind, timestamps, resolvedDir.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { project: PROJECT_ARG },
|
||||
|
|
@ -510,12 +510,14 @@ async function handleMcpToolCall(baseUrl: string, name: unknown, args: McpArgs)
|
|||
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
|
||||
const data = await getJson<ProjectPayload>(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
|
||||
const project = data?.project ?? data;
|
||||
const resolvedDir = typeof data?.resolvedDir === 'string' ? data.resolvedDir : null;
|
||||
return ok(
|
||||
withActiveEcho(
|
||||
{
|
||||
...project,
|
||||
entryFile: project?.metadata?.entryFile ?? null,
|
||||
kind: project?.metadata?.kind ?? null,
|
||||
resolvedDir,
|
||||
},
|
||||
active,
|
||||
resolved,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [
|
|||
{ id: 'hyperframes', label: 'HyperFrames', hint: 'Local HTML -> MP4 renderer', integrated: true, credentialsRequired: false, settingsVisible: false },
|
||||
{ id: 'nanobanana', label: 'Nano Banana', hint: 'Google official by default; custom gateway configurable', integrated: true, defaultBaseUrl: 'https://generativelanguage.googleapis.com', supportsCustomModel: true },
|
||||
{ id: 'imagerouter', label: 'ImageRouter', hint: 'OpenAI-compatible image + video routing', integrated: true, defaultBaseUrl: 'https://api.imagerouter.io/v1/openai', docsUrl: 'https://docs.imagerouter.io/api-reference/image-generation/', supportsCustomModel: true, customModelPlaceholder: 'openai/gpt-image-2 or xAI/grok-imagine-video' },
|
||||
{ id: 'custom-image', label: 'Custom Image API', hint: 'OpenAI-compatible /v1/images/generations endpoint', integrated: true, docsUrl: 'https://platform.openai.com/docs/api-reference/images', supportsCustomModel: true, customModelPlaceholder: 'my-image-model' },
|
||||
{ id: 'custom-image', label: 'Custom Image API', hint: 'OpenAI-compatible /v1/images/generations (local or cloud)', integrated: true, docsUrl: 'https://platform.openai.com/docs/api-reference/images', supportsCustomModel: true, customModelPlaceholder: 'my-image-model' },
|
||||
{ id: 'comfyui', label: 'ComfyUI', hint: 'Local JSON workflow server (planned adapter)', integrated: false, defaultBaseUrl: 'http://127.0.0.1:8188', docsUrl: 'https://docs.comfy.org/development/core-concepts/workflow' },
|
||||
{ id: 'bfl', label: 'Black Forest Labs', hint: 'FLUX 1.1 Pro / FLUX Pro / Dev', integrated: false, defaultBaseUrl: 'https://api.bfl.ai' },
|
||||
{ id: 'fal', label: 'Fal.ai', hint: 'Sora / Seedance / Veo / FLUX', integrated: false, defaultBaseUrl: 'https://fal.run' },
|
||||
{ id: 'leonardo', label: 'Leonardo.ai', hint: 'Phoenix / Kino XL / FLUX', integrated: true, credentialsRequired: true, settingsVisible: true, defaultBaseUrl: 'https://cloud.leonardo.ai/api/rest/v1' },
|
||||
|
|
|
|||
113
apps/daemon/src/media-policy.ts
Normal file
113
apps/daemon/src/media-policy.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { MediaExecutionMode, MediaExecutionPolicy, MediaSurface } from '@open-design/contracts';
|
||||
|
||||
const MEDIA_EXECUTION_MODES = new Set<MediaExecutionMode>(['enabled', 'disabled']);
|
||||
const MEDIA_SURFACES = new Set<MediaSurface>(['image', 'video', 'audio']);
|
||||
|
||||
export interface MediaPolicyTarget {
|
||||
surface: MediaSurface;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export function defaultMediaExecutionPolicy(): MediaExecutionPolicy {
|
||||
return { mode: 'enabled' };
|
||||
}
|
||||
|
||||
export function normalizeMediaExecutionPolicyForRun(value: unknown): MediaExecutionPolicy {
|
||||
const parsed = parseMediaExecutionPolicyInput(value);
|
||||
return parsed.ok ? parsed.policy : defaultMediaExecutionPolicy();
|
||||
}
|
||||
|
||||
export function parseMediaExecutionPolicyInput(value: unknown):
|
||||
| { ok: true; policy: MediaExecutionPolicy }
|
||||
| { ok: false; message: string } {
|
||||
if (value === undefined || value === null) {
|
||||
return { ok: true, policy: defaultMediaExecutionPolicy() };
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return { ok: false, message: 'mediaExecution must be an object when provided' };
|
||||
}
|
||||
|
||||
const rawMode = typeof value.mode === 'string' ? value.mode : 'enabled';
|
||||
if (!MEDIA_EXECUTION_MODES.has(rawMode as MediaExecutionMode)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'mediaExecution.mode must be enabled or disabled',
|
||||
};
|
||||
}
|
||||
const mode = rawMode as MediaExecutionMode;
|
||||
|
||||
const policy: MediaExecutionPolicy = { mode };
|
||||
|
||||
if (value.allowedSurfaces !== undefined) {
|
||||
if (!Array.isArray(value.allowedSurfaces)) {
|
||||
return { ok: false, message: 'mediaExecution.allowedSurfaces must be an array' };
|
||||
}
|
||||
const surfaces: MediaSurface[] = [];
|
||||
for (const surface of value.allowedSurfaces) {
|
||||
const candidate = surface as MediaSurface;
|
||||
if (typeof surface !== 'string' || !MEDIA_SURFACES.has(candidate)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'mediaExecution.allowedSurfaces may only include image, video, or audio',
|
||||
};
|
||||
}
|
||||
if (!surfaces.includes(candidate)) surfaces.push(candidate);
|
||||
}
|
||||
policy.allowedSurfaces = surfaces;
|
||||
}
|
||||
|
||||
if (value.allowedModels !== undefined) {
|
||||
if (!Array.isArray(value.allowedModels)) {
|
||||
return { ok: false, message: 'mediaExecution.allowedModels must be an array' };
|
||||
}
|
||||
const models: string[] = [];
|
||||
for (const rawModel of value.allowedModels) {
|
||||
if (typeof rawModel !== 'string' || rawModel.trim().length === 0) {
|
||||
return { ok: false, message: 'mediaExecution.allowedModels must contain non-empty strings' };
|
||||
}
|
||||
const model = rawModel.trim();
|
||||
if (!models.includes(model)) models.push(model);
|
||||
}
|
||||
policy.allowedModels = models;
|
||||
}
|
||||
|
||||
return { ok: true, policy };
|
||||
}
|
||||
|
||||
export function mediaPolicyDenial(
|
||||
policy: MediaExecutionPolicy,
|
||||
target: MediaPolicyTarget,
|
||||
): { code: string; message: string } | null {
|
||||
if (policy.mode === 'disabled') {
|
||||
return {
|
||||
code: 'MEDIA_EXECUTION_DISABLED',
|
||||
message: 'media generation is disabled for this run',
|
||||
};
|
||||
}
|
||||
if (
|
||||
Array.isArray(policy.allowedSurfaces) &&
|
||||
policy.allowedSurfaces.length > 0 &&
|
||||
!policy.allowedSurfaces.includes(target.surface)
|
||||
) {
|
||||
return {
|
||||
code: 'MEDIA_SURFACE_DENIED',
|
||||
message: `media surface "${target.surface}" is not allowed for this run`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
target.model &&
|
||||
Array.isArray(policy.allowedModels) &&
|
||||
policy.allowedModels.length > 0 &&
|
||||
!policy.allowedModels.includes(target.model)
|
||||
) {
|
||||
return {
|
||||
code: 'MEDIA_MODEL_DENIED',
|
||||
message: `media model "${target.model}" is not allowed for this run`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -1,22 +1,154 @@
|
|||
import type { Express } from 'express';
|
||||
import type { MediaExecutionPolicy } from '@open-design/contracts';
|
||||
import { defaultMediaExecutionPolicy, mediaPolicyDenial } from './media-policy.js';
|
||||
import type { RouteDeps } from './server-context.js';
|
||||
import { proxyDispatcherRequestInit } from './connectionTest.js';
|
||||
import type { ToolTokenGrant } from './tool-tokens.js';
|
||||
|
||||
export interface RegisterMediaRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'ids' | 'media' | 'appConfig' | 'orbit' | 'nativeDialogs' | 'projectStore' | 'projectFiles' | 'conversations' | 'research'> {}
|
||||
const LONG_MEDIA_PROXY_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface RegisterMediaRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'ids' | 'auth' | 'media' | 'appConfig' | 'orbit' | 'nativeDialogs' | 'projectStore' | 'projectFiles' | 'conversations' | 'research'> {}
|
||||
|
||||
export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps) {
|
||||
const { db } = ctx;
|
||||
const { db, design } = ctx;
|
||||
const { sendApiError, requireLocalDaemonRequest, isLocalSameOrigin, resolvedPortRef } = ctx.http;
|
||||
const { PROJECT_ROOT, PROJECTS_DIR, RUNTIME_DATA_DIR } = ctx.paths;
|
||||
const { authorizeToolRequest } = ctx.auth;
|
||||
const { randomUUID } = ctx.ids;
|
||||
const { MEDIA_PROVIDERS, IMAGE_MODELS, VIDEO_MODELS, AUDIO_MODELS_BY_KIND, MEDIA_ASPECTS, VIDEO_LENGTHS_SEC, AUDIO_DURATIONS_SEC, readMaskedConfig, writeConfig, generateMedia, createMediaTask, persistMediaTask, appendTaskProgress, notifyTaskWaiters, getLiveMediaTask, mediaTaskSnapshot, listMediaTasksByProject, listElevenLabsVoiceOptions } = ctx.media;
|
||||
const { readAppConfig, writeAppConfig } = ctx.appConfig;
|
||||
const { orbitService } = ctx.orbit;
|
||||
const { openNativeFolderDialog } = ctx.nativeDialogs;
|
||||
const { getProject } = ctx.projectStore;
|
||||
const { writeProjectFile } = ctx.projectFiles;
|
||||
const { insertConversation, upsertMessage } = ctx.conversations;
|
||||
const { searchResearch, ResearchError } = ctx.research;
|
||||
const getResolvedPort = () => resolvedPortRef.current;
|
||||
|
||||
const mediaPolicyForGrant = (grant: ToolTokenGrant | null): MediaExecutionPolicy => {
|
||||
if (!grant?.runId) return defaultMediaExecutionPolicy();
|
||||
const run = design.runs.get(grant.runId);
|
||||
return run?.mediaExecution ?? defaultMediaExecutionPolicy();
|
||||
};
|
||||
|
||||
const handleGenerate = async (
|
||||
req: any,
|
||||
res: any,
|
||||
options: { projectId: string; grant: ToolTokenGrant | null },
|
||||
) => {
|
||||
const projectId = options.projectId;
|
||||
const project = getProject(db, projectId);
|
||||
if (!project) return res.status(404).json({ error: 'project not found' });
|
||||
|
||||
const surface = req.body?.surface;
|
||||
if (surface !== 'image' && surface !== 'video' && surface !== 'audio') {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'surface must be image, video, or audio');
|
||||
}
|
||||
const model = typeof req.body?.model === 'string' ? req.body.model : '';
|
||||
if (!model) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'model is required');
|
||||
}
|
||||
|
||||
const policy = mediaPolicyForGrant(options.grant);
|
||||
const denial = mediaPolicyDenial(policy, { surface, model });
|
||||
if (denial) {
|
||||
return sendApiError(res, 403, denial.code, denial.message);
|
||||
}
|
||||
|
||||
let task: ReturnType<typeof createMediaTask> | null = null;
|
||||
try {
|
||||
const taskId = randomUUID();
|
||||
task = createMediaTask(taskId, projectId, {
|
||||
surface: req.body?.surface,
|
||||
model: req.body?.model,
|
||||
});
|
||||
console.error(
|
||||
`[task ${taskId.slice(0, 8)}] queued model=${req.body?.model} ` +
|
||||
`surface=${req.body?.surface} ` +
|
||||
`image=${req.body?.image ? 'yes' : 'no'} ` +
|
||||
`compositionDir=${req.body?.compositionDir ? 'yes' : 'no'}`,
|
||||
);
|
||||
|
||||
const proxyDispatcher = proxyDispatcherRequestInit(process.env, {
|
||||
headersTimeout: LONG_MEDIA_PROXY_TIMEOUT_MS,
|
||||
bodyTimeout: LONG_MEDIA_PROXY_TIMEOUT_MS,
|
||||
});
|
||||
task.status = 'running';
|
||||
persistMediaTask(task);
|
||||
generateMedia({
|
||||
projectRoot: PROJECT_ROOT,
|
||||
projectsRoot: PROJECTS_DIR,
|
||||
projectId,
|
||||
surface: req.body?.surface,
|
||||
model: req.body?.model,
|
||||
prompt: req.body?.prompt,
|
||||
output: req.body?.output,
|
||||
aspect: req.body?.aspect,
|
||||
length:
|
||||
typeof req.body?.length === 'number' ? req.body.length : undefined,
|
||||
duration:
|
||||
typeof req.body?.duration === 'number'
|
||||
? req.body.duration
|
||||
: undefined,
|
||||
voice: req.body?.voice,
|
||||
audioKind: req.body?.audioKind,
|
||||
language: typeof req.body?.language === 'string' ? req.body.language : undefined,
|
||||
loop: typeof req.body?.loop === 'boolean' ? req.body.loop : undefined,
|
||||
promptInfluence: typeof req.body?.promptInfluence === 'number'
|
||||
? req.body.promptInfluence
|
||||
: undefined,
|
||||
compositionDir: req.body?.compositionDir,
|
||||
image: req.body?.image,
|
||||
onProgress: (line: any) => appendTaskProgress(task, line),
|
||||
requestInit: proxyDispatcher.requestInit,
|
||||
})
|
||||
.then((meta: any) => {
|
||||
task.status = 'done';
|
||||
task.file = meta;
|
||||
task.endedAt = Date.now();
|
||||
persistMediaTask(task);
|
||||
notifyTaskWaiters(task);
|
||||
console.error(
|
||||
`[task ${taskId.slice(0, 8)}] done size=${meta?.size} mime=${meta?.mime} ` +
|
||||
`elapsed=${Math.round((task.endedAt - task.startedAt) / 1000)}s`,
|
||||
);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
task.status = 'failed';
|
||||
task.error = {
|
||||
message: String(err && err.message ? err.message : err),
|
||||
status: typeof err?.status === 'number' ? err.status : 400,
|
||||
code: err?.code,
|
||||
};
|
||||
task.endedAt = Date.now();
|
||||
persistMediaTask(task);
|
||||
notifyTaskWaiters(task);
|
||||
console.error(
|
||||
`[task ${taskId.slice(0, 8)}] failed status=${task.error.status} ` +
|
||||
`message=${(task.error.message || '').slice(0, 240)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => proxyDispatcher.close());
|
||||
|
||||
return res.status(202).json({
|
||||
taskId,
|
||||
status: task.status,
|
||||
startedAt: task.startedAt,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (task) {
|
||||
task.status = 'failed';
|
||||
task.error = {
|
||||
message: String(err && err.message ? err.message : err),
|
||||
status: typeof err?.status === 'number' ? err.status : 400,
|
||||
code: err?.code,
|
||||
};
|
||||
task.endedAt = Date.now();
|
||||
persistMediaTask(task);
|
||||
notifyTaskWaiters(task);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
app.get('/api/media/models', (_req, res) => {
|
||||
res.json({
|
||||
providers: MEDIA_PROVIDERS,
|
||||
|
|
@ -59,8 +191,16 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
|
|||
try {
|
||||
const rawLimit = Number(req.query.limit);
|
||||
const limit = Number.isFinite(rawLimit) ? rawLimit : undefined;
|
||||
const voices = await listElevenLabsVoiceOptions(PROJECT_ROOT, { limit });
|
||||
res.json({ voices });
|
||||
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
|
||||
try {
|
||||
const voices = await listElevenLabsVoiceOptions(PROJECT_ROOT, {
|
||||
limit,
|
||||
requestInit: proxyDispatcher.requestInit,
|
||||
});
|
||||
res.json({ voices });
|
||||
} finally {
|
||||
await proxyDispatcher.close();
|
||||
}
|
||||
} catch (err: any) {
|
||||
const message = String(err && err.message ? err.message : err);
|
||||
const status = message.includes('no ElevenLabs API key') ? 400 : 502;
|
||||
|
|
@ -148,82 +288,21 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
|
|||
}
|
||||
|
||||
try {
|
||||
const projectId = req.params.id;
|
||||
const project = getProject(db, projectId);
|
||||
if (!project) return res.status(404).json({ error: 'project not found' });
|
||||
await handleGenerate(req, res, { projectId: req.params.id, grant: null });
|
||||
} catch (err: any) {
|
||||
const status = typeof err?.status === 'number' ? err.status : 400;
|
||||
const code = err?.code;
|
||||
const body: any = { error: String(err && err.message ? err.message : err) };
|
||||
if (code) body.code = code;
|
||||
res.status(status).json(body);
|
||||
}
|
||||
});
|
||||
|
||||
const taskId = randomUUID();
|
||||
const task = createMediaTask(taskId, projectId, {
|
||||
surface: req.body?.surface,
|
||||
model: req.body?.model,
|
||||
});
|
||||
console.error(
|
||||
`[task ${taskId.slice(0, 8)}] queued model=${req.body?.model} ` +
|
||||
`surface=${req.body?.surface} ` +
|
||||
`image=${req.body?.image ? 'yes' : 'no'} ` +
|
||||
`compositionDir=${req.body?.compositionDir ? 'yes' : 'no'}`,
|
||||
);
|
||||
|
||||
task.status = 'running';
|
||||
persistMediaTask(task);
|
||||
generateMedia({
|
||||
projectRoot: PROJECT_ROOT,
|
||||
projectsRoot: PROJECTS_DIR,
|
||||
projectId,
|
||||
surface: req.body?.surface,
|
||||
model: req.body?.model,
|
||||
prompt: req.body?.prompt,
|
||||
output: req.body?.output,
|
||||
aspect: req.body?.aspect,
|
||||
length:
|
||||
typeof req.body?.length === 'number' ? req.body.length : undefined,
|
||||
duration:
|
||||
typeof req.body?.duration === 'number'
|
||||
? req.body.duration
|
||||
: undefined,
|
||||
voice: req.body?.voice,
|
||||
audioKind: req.body?.audioKind,
|
||||
language: typeof req.body?.language === 'string' ? req.body.language : undefined,
|
||||
loop: typeof req.body?.loop === 'boolean' ? req.body.loop : undefined,
|
||||
promptInfluence: typeof req.body?.promptInfluence === 'number'
|
||||
? req.body.promptInfluence
|
||||
: undefined,
|
||||
compositionDir: req.body?.compositionDir,
|
||||
image: req.body?.image,
|
||||
onProgress: (line: any) => appendTaskProgress(task, line),
|
||||
})
|
||||
.then((meta: any) => {
|
||||
task.status = 'done';
|
||||
task.file = meta;
|
||||
task.endedAt = Date.now();
|
||||
persistMediaTask(task);
|
||||
notifyTaskWaiters(task);
|
||||
console.error(
|
||||
`[task ${taskId.slice(0, 8)}] done size=${meta?.size} mime=${meta?.mime} ` +
|
||||
`elapsed=${Math.round((task.endedAt - task.startedAt) / 1000)}s`,
|
||||
);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
task.status = 'failed';
|
||||
task.error = {
|
||||
message: String(err && err.message ? err.message : err),
|
||||
status: typeof err?.status === 'number' ? err.status : 400,
|
||||
code: err?.code,
|
||||
};
|
||||
task.endedAt = Date.now();
|
||||
persistMediaTask(task);
|
||||
notifyTaskWaiters(task);
|
||||
console.error(
|
||||
`[task ${taskId.slice(0, 8)}] failed status=${task.error.status} ` +
|
||||
`message=${(task.error.message || '').slice(0, 240)}`,
|
||||
);
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
taskId,
|
||||
status: task.status,
|
||||
startedAt: task.startedAt,
|
||||
});
|
||||
app.post('/api/tools/media/generate', async (req, res) => {
|
||||
const grant = authorizeToolRequest(req, res, 'media:generate');
|
||||
if (!grant) return;
|
||||
try {
|
||||
await handleGenerate(req, res, { projectId: grant.projectId, grant });
|
||||
} catch (err: any) {
|
||||
const status = typeof err?.status === 'number' ? err.status : 400;
|
||||
const code = err?.code;
|
||||
|
|
@ -242,18 +321,24 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps)
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await searchResearch({
|
||||
projectRoot: PROJECT_ROOT,
|
||||
query: req.body?.query,
|
||||
maxSources:
|
||||
typeof req.body?.maxSources === 'number'
|
||||
? req.body.maxSources
|
||||
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
|
||||
try {
|
||||
const result = await searchResearch({
|
||||
projectRoot: PROJECT_ROOT,
|
||||
query: req.body?.query,
|
||||
maxSources:
|
||||
typeof req.body?.maxSources === 'number'
|
||||
? req.body.maxSources
|
||||
: undefined,
|
||||
providers: Array.isArray(req.body?.providers)
|
||||
? req.body.providers
|
||||
: undefined,
|
||||
providers: Array.isArray(req.body?.providers)
|
||||
? req.body.providers
|
||||
: undefined,
|
||||
});
|
||||
res.json(result);
|
||||
requestInit: proxyDispatcher.requestInit,
|
||||
});
|
||||
res.json(result);
|
||||
} finally {
|
||||
await proxyDispatcher.close();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err instanceof ResearchError) {
|
||||
return res.status(err.status).json({
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ const execFile = promisify(execFileCb);
|
|||
type ProviderConfig = { apiKey?: string; baseUrl?: string; model?: string };
|
||||
type ProgressFn = (message: string) => void;
|
||||
type ImageRef = { path: string; abs: string; mime: string; size: number; dataUrl: string };
|
||||
type MediaRequestInit = Pick<RequestInit, 'dispatcher'>;
|
||||
type MediaContext = {
|
||||
surface: MediaSurface;
|
||||
/**
|
||||
|
|
@ -108,6 +109,7 @@ type MediaContext = {
|
|||
promptInfluence: number | undefined;
|
||||
compositionDir: string | null;
|
||||
imageRef: ImageRef | null;
|
||||
requestInit: MediaRequestInit;
|
||||
};
|
||||
type RenderResult = { bytes: Buffer; providerNote: string; suggestedExt?: string };
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
|
@ -285,7 +287,7 @@ export async function generateMedia(args: {
|
|||
projectRoot: string; projectsRoot: string; projectId: string; surface: MediaSurface; model: string;
|
||||
prompt?: string; output?: string; aspect?: string; length?: number; duration?: number; voice?: string;
|
||||
audioKind?: AudioKind; language?: string; loop?: boolean; promptInfluence?: number;
|
||||
compositionDir?: string; image?: string; onProgress?: ProgressFn;
|
||||
compositionDir?: string; image?: string; onProgress?: ProgressFn; requestInit?: MediaRequestInit;
|
||||
}) {
|
||||
const {
|
||||
projectRoot,
|
||||
|
|
@ -305,6 +307,7 @@ export async function generateMedia(args: {
|
|||
promptInfluence,
|
||||
compositionDir,
|
||||
image,
|
||||
requestInit,
|
||||
} = args;
|
||||
|
||||
if (!projectRoot) throw new Error('projectRoot required');
|
||||
|
|
@ -414,6 +417,7 @@ export async function generateMedia(args: {
|
|||
// Resolved reference image for i2v / image-edit flows. `null` when
|
||||
// the agent didn't pass --image. See resolveProjectImage below.
|
||||
imageRef,
|
||||
requestInit: requestInit || {},
|
||||
};
|
||||
|
||||
const credentials = await resolveProviderConfig(projectRoot, def.provider);
|
||||
|
|
@ -693,6 +697,16 @@ const openAIImageDispatcher = new UndiciAgent({
|
|||
bodyTimeout: OPENAI_IMAGE_BODY_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
function withMediaRequestInit(
|
||||
ctx: Pick<MediaContext, 'requestInit'>,
|
||||
init: RequestInit = {},
|
||||
): RequestInit {
|
||||
return {
|
||||
...ctx.requestInit,
|
||||
...init,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig): Promise<RenderResult> {
|
||||
if (!credentials.apiKey) {
|
||||
throw new Error('no OpenAI credential — configure an API key in Settings, set OPENAI_API_KEY, or refresh Codex/Hermes OAuth');
|
||||
|
|
@ -737,12 +751,14 @@ async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig)
|
|||
headers['api-key'] = credentials.apiKey;
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
const resp = await fetch(url, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
dispatcher: openAIImageDispatcher as unknown as NonNullable<RequestInit['dispatcher']>,
|
||||
});
|
||||
dispatcher: ctx.requestInit.dispatcher
|
||||
?? openAIImageDispatcher as unknown as NonNullable<RequestInit['dispatcher']>,
|
||||
signal: AbortSignal.timeout(Math.max(OPENAI_IMAGE_HEADERS_TIMEOUT_MS, OPENAI_IMAGE_BODY_TIMEOUT_MS)),
|
||||
}));
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
const tag = azure ? 'azure-openai' : 'openai';
|
||||
|
|
@ -760,7 +776,7 @@ async function renderOpenAIImage(ctx: MediaContext, credentials: ProviderConfig)
|
|||
if (entry.b64_json) {
|
||||
bytes = Buffer.from(entry.b64_json, 'base64');
|
||||
} else if (entry.url) {
|
||||
const imgResp = await fetch(entry.url);
|
||||
const imgResp = await fetch(entry.url, withMediaRequestInit(ctx));
|
||||
if (!imgResp.ok) throw new Error(`openai image fetch ${imgResp.status}`);
|
||||
const arr = await imgResp.arrayBuffer();
|
||||
bytes = Buffer.from(arr);
|
||||
|
|
@ -794,16 +810,16 @@ async function renderImageRouterImage(ctx: MediaContext, credentials: ProviderCo
|
|||
output_format: 'png',
|
||||
};
|
||||
|
||||
const resp = await fetch(url, {
|
||||
const resp = await fetch(url, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const data = await parseOpenAICompatibleJson(resp, 'imagerouter image');
|
||||
const bytes = await bytesFromOpenAICompatibleData(data, 'imagerouter image');
|
||||
const bytes = await bytesFromOpenAICompatibleData(data, 'imagerouter image', ctx.requestInit);
|
||||
return {
|
||||
bytes,
|
||||
providerNote: `imagerouter/${wireModel} · ${imageRouterSizeFor(ctx.aspect, 'image')} · ${bytes.length} bytes`,
|
||||
|
|
@ -829,16 +845,16 @@ async function renderImageRouterVideo(ctx: MediaContext, credentials: ProviderCo
|
|||
response_format: 'b64_json',
|
||||
};
|
||||
|
||||
const resp = await fetch(url, {
|
||||
const resp = await fetch(url, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const data = await parseOpenAICompatibleJson(resp, 'imagerouter video');
|
||||
const bytes = await bytesFromOpenAICompatibleData(data, 'imagerouter video');
|
||||
const bytes = await bytesFromOpenAICompatibleData(data, 'imagerouter video', ctx.requestInit);
|
||||
return {
|
||||
bytes,
|
||||
providerNote: `imagerouter/${wireModel} · ${imageRouterSizeFor(ctx.aspect, 'video')} · ${seconds === 'auto' ? 'auto' : `${seconds}s`} · ${bytes.length} bytes`,
|
||||
|
|
@ -876,13 +892,13 @@ async function renderCustomOpenAIImage(ctx: MediaContext, credentials: ProviderC
|
|||
size: openaiSizeFor('gpt-image-1', ctx.aspect),
|
||||
};
|
||||
|
||||
const resp = await fetch(buildOpenAIImageUrl(baseUrl, false), {
|
||||
const resp = await fetch(buildOpenAIImageUrl(baseUrl, false), withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const data = await parseOpenAICompatibleJson(resp, 'custom image');
|
||||
const bytes = await bytesFromOpenAICompatibleData(data, 'custom image');
|
||||
const bytes = await bytesFromOpenAICompatibleData(data, 'custom image', ctx.requestInit);
|
||||
return {
|
||||
bytes,
|
||||
providerNote: `custom-image/${wireModel} · ${body.size} · ${bytes.length} bytes`,
|
||||
|
|
@ -912,7 +928,7 @@ async function parseOpenAICompatibleJson(resp: Response, providerTag: string): P
|
|||
}
|
||||
}
|
||||
|
||||
async function bytesFromOpenAICompatibleData(data: any, providerTag: string): Promise<Buffer> {
|
||||
async function bytesFromOpenAICompatibleData(data: any, providerTag: string, requestInit: MediaRequestInit = {}): Promise<Buffer> {
|
||||
const entry = data && Array.isArray(data.data) ? data.data[0] : null;
|
||||
if (!entry) throw new Error(`${providerTag} response had no data[0]`);
|
||||
if (typeof entry.b64_json === 'string' && entry.b64_json) {
|
||||
|
|
@ -922,7 +938,7 @@ async function bytesFromOpenAICompatibleData(data: any, providerTag: string): Pr
|
|||
return Buffer.from(raw, 'base64');
|
||||
}
|
||||
if (typeof entry.url === 'string' && entry.url) {
|
||||
const mediaResp = await fetch(entry.url);
|
||||
const mediaResp = await fetch(entry.url, requestInit);
|
||||
if (!mediaResp.ok) {
|
||||
throw new Error(`${providerTag} media fetch ${mediaResp.status}`);
|
||||
}
|
||||
|
|
@ -1109,11 +1125,11 @@ async function renderOpenAISpeech(ctx: MediaContext, credentials: ProviderConfig
|
|||
headers['api-key'] = credentials.apiKey;
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
const resp = await fetch(url, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
const tag = azure ? 'azure-openai' : 'openai';
|
||||
|
|
@ -1187,14 +1203,14 @@ async function renderVolcengineVideo(ctx: MediaContext, credentials: ProviderCon
|
|||
content,
|
||||
};
|
||||
|
||||
const taskResp = await fetch(`${baseUrl}/contents/generations/tasks`, {
|
||||
const taskResp = await fetch(`${baseUrl}/contents/generations/tasks`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(taskBody),
|
||||
});
|
||||
}));
|
||||
const taskText = await taskResp.text();
|
||||
if (!taskResp.ok) {
|
||||
throw new Error(`volcengine task create ${taskResp.status}: ${truncate(taskText, 240)}`);
|
||||
|
|
@ -1231,9 +1247,9 @@ async function renderVolcengineVideo(ctx: MediaContext, credentials: ProviderCon
|
|||
}
|
||||
while (Date.now() - startedAt < maxMs) {
|
||||
await sleep(4000);
|
||||
const pollResp = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
|
||||
const pollResp = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(taskId)}`, withMediaRequestInit(ctx, {
|
||||
headers: { 'authorization': `Bearer ${credentials.apiKey}` },
|
||||
});
|
||||
}));
|
||||
const pollText = await pollResp.text();
|
||||
if (!pollResp.ok) {
|
||||
throw new Error(`volcengine poll ${pollResp.status}: ${truncate(pollText, 240)}`);
|
||||
|
|
@ -1266,7 +1282,7 @@ async function renderVolcengineVideo(ctx: MediaContext, credentials: ProviderCon
|
|||
throw new Error(`volcengine task did not finish in time (last status: ${lastStatus || 'unknown'})`);
|
||||
}
|
||||
|
||||
const dlResp = await fetch(videoUrl);
|
||||
const dlResp = await fetch(videoUrl, withMediaRequestInit(ctx));
|
||||
if (!dlResp.ok) throw new Error(`volcengine video fetch ${dlResp.status}`);
|
||||
const arr = await dlResp.arrayBuffer();
|
||||
const bytes = Buffer.from(arr);
|
||||
|
|
@ -1305,14 +1321,14 @@ async function renderVolcengineImage(ctx: MediaContext, credentials: ProviderCon
|
|||
// wire name. lefarcen + codex P2 on PR #1309.
|
||||
size: openaiSizeFor(ctx.model, ctx.aspect),
|
||||
};
|
||||
const resp = await fetch(`${baseUrl}/images/generations`, {
|
||||
const resp = await fetch(`${baseUrl}/images/generations`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
throw new Error(`volcengine image ${resp.status}: ${truncate(text, 240)}`);
|
||||
|
|
@ -1329,7 +1345,7 @@ async function renderVolcengineImage(ctx: MediaContext, credentials: ProviderCon
|
|||
if (entry.b64_json) {
|
||||
bytes = Buffer.from(entry.b64_json, 'base64');
|
||||
} else if (entry.url) {
|
||||
const imgResp = await fetch(entry.url);
|
||||
const imgResp = await fetch(entry.url, withMediaRequestInit(ctx));
|
||||
if (!imgResp.ok) throw new Error(`volcengine image fetch ${imgResp.status}`);
|
||||
bytes = Buffer.from(await imgResp.arrayBuffer());
|
||||
} else {
|
||||
|
|
@ -1376,14 +1392,14 @@ async function renderGrokImage(ctx: MediaContext, credentials: ProviderConfig):
|
|||
aspect_ratio: aspectRatio,
|
||||
response_format: 'b64_json',
|
||||
};
|
||||
const resp = await fetch(`${baseUrl}/images/generations`, {
|
||||
const resp = await fetch(`${baseUrl}/images/generations`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
throw new Error(`grok image ${resp.status}: ${truncate(text, 240)}`);
|
||||
|
|
@ -1400,7 +1416,7 @@ async function renderGrokImage(ctx: MediaContext, credentials: ProviderConfig):
|
|||
if (entry.b64_json) {
|
||||
bytes = Buffer.from(entry.b64_json, 'base64');
|
||||
} else if (entry.url) {
|
||||
const imgResp = await fetch(entry.url);
|
||||
const imgResp = await fetch(entry.url, withMediaRequestInit(ctx));
|
||||
if (!imgResp.ok) throw new Error(`grok image fetch ${imgResp.status}`);
|
||||
bytes = Buffer.from(await imgResp.arrayBuffer());
|
||||
} else {
|
||||
|
|
@ -1442,11 +1458,11 @@ async function renderNanoBananaImage(ctx: MediaContext, credentials: ProviderCon
|
|||
},
|
||||
};
|
||||
|
||||
const resp = await fetch(`${baseUrl}/v1beta/models/${encodeURIComponent(wireModel)}:generateContent`, {
|
||||
const resp = await fetch(`${baseUrl}/v1beta/models/${encodeURIComponent(wireModel)}:generateContent`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: nanoBananaHeaders(baseUrl, credentials.apiKey),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
throw new Error(`nano-banana image ${resp.status}: ${truncate(text, 240)}`);
|
||||
|
|
@ -1583,14 +1599,14 @@ async function renderLeonardoImage(ctx: MediaContext, credentials: ProviderConfi
|
|||
...(requiresContrast ? { contrast: 3.5 } : {}),
|
||||
};
|
||||
|
||||
const submitResp = await fetch(`${baseUrl}/generations`, {
|
||||
const submitResp = await fetch(`${baseUrl}/generations`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
|
||||
const submitText = await submitResp.text();
|
||||
if (!submitResp.ok) {
|
||||
|
|
@ -1618,11 +1634,11 @@ async function renderLeonardoImage(ctx: MediaContext, credentials: ProviderConfi
|
|||
while (Date.now() - startedAt < maxPollMs) {
|
||||
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||
|
||||
const pollResp = await fetch(`${baseUrl}/generations/${generationId}`, {
|
||||
const pollResp = await fetch(`${baseUrl}/generations/${generationId}`, withMediaRequestInit(ctx, {
|
||||
headers: {
|
||||
'authorization': `Bearer ${credentials.apiKey}`,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
if (!pollResp.ok) {
|
||||
throw new Error(`leonardo.ai poll ${pollResp.status}`);
|
||||
|
|
@ -1647,7 +1663,7 @@ async function renderLeonardoImage(ctx: MediaContext, credentials: ProviderConfi
|
|||
}
|
||||
|
||||
// Fetch the generated image
|
||||
const imgResp = await fetch(imageUrl);
|
||||
const imgResp = await fetch(imageUrl, withMediaRequestInit(ctx));
|
||||
if (!imgResp.ok) {
|
||||
throw new Error(`leonardo.ai image fetch ${imgResp.status}`);
|
||||
}
|
||||
|
|
@ -1691,14 +1707,14 @@ async function renderGrokVideo(ctx: MediaContext, credentials: ProviderConfig, o
|
|||
body.image = ctx.imageRef.dataUrl;
|
||||
}
|
||||
|
||||
const submitResp = await fetch(`${baseUrl}/videos/generations`, {
|
||||
const submitResp = await fetch(`${baseUrl}/videos/generations`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'authorization': `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const submitText = await submitResp.text();
|
||||
if (!submitResp.ok) {
|
||||
throw new Error(`grok video submit ${submitResp.status}: ${truncate(submitText, 240)}`);
|
||||
|
|
@ -1731,9 +1747,9 @@ async function renderGrokVideo(ctx: MediaContext, credentials: ProviderConfig, o
|
|||
}
|
||||
while (Date.now() - startedAt < maxMs) {
|
||||
await sleep(4000);
|
||||
const pollResp = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {
|
||||
const pollResp = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, withMediaRequestInit(ctx, {
|
||||
headers: { 'authorization': `Bearer ${credentials.apiKey}` },
|
||||
});
|
||||
}));
|
||||
const pollText = await pollResp.text();
|
||||
if (!pollResp.ok) {
|
||||
throw new Error(`grok poll ${pollResp.status}: ${truncate(pollText, 240)}`);
|
||||
|
|
@ -1785,7 +1801,7 @@ async function renderGrokVideo(ctx: MediaContext, credentials: ProviderConfig, o
|
|||
);
|
||||
}
|
||||
|
||||
const dlResp = await fetch(videoUrl);
|
||||
const dlResp = await fetch(videoUrl, withMediaRequestInit(ctx));
|
||||
if (!dlResp.ok) throw new Error(`grok video fetch ${dlResp.status}`);
|
||||
const arr = await dlResp.arrayBuffer();
|
||||
const bytes = Buffer.from(arr);
|
||||
|
|
@ -1854,14 +1870,14 @@ async function renderXAITTS(ctx: MediaContext, credentials: ProviderConfig): Pro
|
|||
language,
|
||||
};
|
||||
|
||||
const resp = await fetch(`${baseUrl}/tts`, {
|
||||
const resp = await fetch(`${baseUrl}/tts`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text().catch(() => '');
|
||||
throw new Error(`xai tts ${resp.status}: ${truncate(errText, 240)}`);
|
||||
|
|
@ -1957,14 +1973,14 @@ async function renderElevenLabsTTS(ctx: MediaContext, credentials: ProviderConfi
|
|||
|
||||
const resp = await fetch(
|
||||
`${baseUrl}/v1/text-to-speech/${encodeURIComponent(voiceId)}?output_format=mp3_44100_128`,
|
||||
{
|
||||
withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xi-api-key': credentials.apiKey,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
|
|
@ -2008,14 +2024,14 @@ async function renderElevenLabsSfx(ctx: MediaContext, credentials: ProviderConfi
|
|||
|
||||
const resp = await fetch(
|
||||
`${baseUrl}/v1/sound-generation?output_format=mp3_44100_128`,
|
||||
{
|
||||
withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xi-api-key': credentials.apiKey,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
|
|
@ -2099,14 +2115,14 @@ async function renderMinimaxTTS(ctx: MediaContext, credentials: ProviderConfig):
|
|||
},
|
||||
};
|
||||
|
||||
const resp = await fetch(`${baseUrl}/t2a_v2`, {
|
||||
const resp = await fetch(`${baseUrl}/t2a_v2`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const respText = await resp.text();
|
||||
if (!resp.ok) {
|
||||
throw new Error(`minimax tts ${resp.status}: ${truncate(respText, 240)}`);
|
||||
|
|
@ -2204,14 +2220,14 @@ async function renderSenseAudioTTS(ctx: MediaContext, credentials: ProviderConfi
|
|||
},
|
||||
};
|
||||
|
||||
const resp = await fetch(`${baseUrl}/v1/t2a_v2`, {
|
||||
const resp = await fetch(`${baseUrl}/v1/t2a_v2`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const respText = await resp.text();
|
||||
if (!resp.ok) {
|
||||
throw new Error(`senseaudio tts ${resp.status}: ${truncate(respText, 240)}`);
|
||||
|
|
@ -2315,14 +2331,14 @@ async function renderSenseAudioImage(ctx: MediaContext, credentials: ProviderCon
|
|||
body.reference = reference;
|
||||
}
|
||||
|
||||
const resp = await fetch(`${baseUrl}/v1/image/sync`, {
|
||||
const resp = await fetch(`${baseUrl}/v1/image/sync`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
const respText = await resp.text();
|
||||
if (!resp.ok) {
|
||||
throw new Error(`senseaudio image ${resp.status}: ${truncate(respText, 240)}`);
|
||||
|
|
@ -2358,7 +2374,7 @@ async function renderSenseAudioImage(ctx: MediaContext, credentials: ProviderCon
|
|||
if (!urlCheck.ok) {
|
||||
throw new Error(`senseaudio image ${urlCheck.error}`);
|
||||
}
|
||||
const imgResp = await fetch(url, { redirect: 'error' });
|
||||
const imgResp = await fetch(url, withMediaRequestInit(ctx, { redirect: 'error' }));
|
||||
if (!imgResp.ok) {
|
||||
throw new Error(`senseaudio image fetch ${imgResp.status}`);
|
||||
}
|
||||
|
|
@ -2424,14 +2440,14 @@ async function renderFishAudioTTS(ctx: MediaContext, credentials: ProviderConfig
|
|||
body.reference_id = ctx.voice.trim();
|
||||
}
|
||||
|
||||
const resp = await fetch(`${baseUrl}/v1/tts`, {
|
||||
const resp = await fetch(`${baseUrl}/v1/tts`, withMediaRequestInit(ctx, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${credentials.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}));
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(`fishaudio tts ${resp.status}: ${truncate(errText, 240)}`);
|
||||
|
|
|
|||
|
|
@ -100,5 +100,6 @@ export * from './scaffold.js';
|
|||
export * from './gc.js';
|
||||
export * from './resolve-snapshot.js';
|
||||
export * from './snapshots.js';
|
||||
export * from './skill-candidates.js';
|
||||
export * from './trust.js';
|
||||
export * from './until.js';
|
||||
|
|
|
|||
|
|
@ -142,6 +142,30 @@ export function migratePlugins(db: SqliteDb): void {
|
|||
CREATE INDEX IF NOT EXISTS idx_genui_proj_surface ON genui_surfaces(project_id, surface_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_genui_conv_surface ON genui_surfaces(conversation_id, surface_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_genui_run ON genui_surfaces(run_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_plugin_candidates (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
run_id TEXT,
|
||||
conversation_id TEXT,
|
||||
assistant_message_id TEXT,
|
||||
fingerprint TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
confidence REAL NOT NULL,
|
||||
source_refs_json TEXT NOT NULL,
|
||||
provenance_json TEXT NOT NULL,
|
||||
draft_path TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
dismissed_at INTEGER,
|
||||
UNIQUE(project_id, fingerprint),
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_plugin_candidates_project
|
||||
ON skill_plugin_candidates(project_id, status, created_at DESC);
|
||||
`);
|
||||
|
||||
const marketplaceCols = db.prepare(`PRAGMA table_info(plugin_marketplaces)`).all() as DbRow[];
|
||||
|
|
|
|||
391
apps/daemon/src/plugins/skill-candidates.ts
Normal file
391
apps/daemon/src/plugins/skill-candidates.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import type Database from 'better-sqlite3';
|
||||
import type {
|
||||
SkillPluginCandidate,
|
||||
SkillPluginCandidateSourceRef,
|
||||
} from '@open-design/contracts';
|
||||
import { OPEN_DESIGN_PLUGIN_SPEC_VERSION } from '@open-design/contracts';
|
||||
import { validatePluginFolder, flattenValidationDiagnostics } from './validate.js';
|
||||
|
||||
type SqliteDb = Database.Database;
|
||||
type DbRow = Record<string, unknown>;
|
||||
|
||||
const MAX_SOURCE_BYTES = 96_000;
|
||||
const SAFE_SLUG = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
|
||||
export interface DetectSkillPluginCandidateInput {
|
||||
projectId: string;
|
||||
runId?: string | null;
|
||||
conversationId?: string | null;
|
||||
assistantMessageId?: string | null;
|
||||
message?: unknown;
|
||||
attachments?: unknown;
|
||||
projectRoot: string;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export interface SkillPluginDraftGenerationResult {
|
||||
ok: boolean;
|
||||
candidate: SkillPluginCandidate;
|
||||
draftPath: string;
|
||||
folder: string;
|
||||
validation: {
|
||||
ok: boolean;
|
||||
diagnostics: Array<{ severity: 'error' | 'warning' | 'info'; code: string; message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectSkillPluginCandidate(input: DetectSkillPluginCandidateInput): Promise<Omit<SkillPluginCandidate, 'id' | 'createdAt' | 'updatedAt' | 'status'> & { fingerprint: string } | null> {
|
||||
const refs: SkillPluginCandidateSourceRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addRef = (ref: SkillPluginCandidateSourceRef) => {
|
||||
const key = `${ref.kind}:${ref.value}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
refs.push(ref);
|
||||
};
|
||||
|
||||
const message = typeof input.message === 'string' ? input.message : '';
|
||||
for (const rel of referencedMarkdownPaths(message)) {
|
||||
const ref = await readCandidateFile(input.projectRoot, rel);
|
||||
if (ref) addRef(ref);
|
||||
}
|
||||
if (Array.isArray(input.attachments)) {
|
||||
for (const raw of input.attachments) {
|
||||
if (typeof raw !== 'string') continue;
|
||||
const ref = await readCandidateFile(input.projectRoot, raw);
|
||||
if (ref) addRef(ref);
|
||||
}
|
||||
}
|
||||
for (const url of referencedPluginUrls(message)) {
|
||||
addRef({
|
||||
kind: 'url',
|
||||
value: url,
|
||||
label: url,
|
||||
reason: 'Referenced repository or plugin-like URL.',
|
||||
});
|
||||
}
|
||||
|
||||
const eligible = refs.filter((ref) => isEligibleSourceRef(ref));
|
||||
if (eligible.length === 0) return null;
|
||||
|
||||
const primary = eligible[0]!;
|
||||
const title = deriveCandidateTitle(primary);
|
||||
const description = deriveCandidateDescription(primary);
|
||||
const fingerprint = sha256(eligible.map((ref) => `${ref.kind}:${ref.value}:${ref.content ? sha256(ref.content) : ''}`).join('\n'));
|
||||
return {
|
||||
projectId: input.projectId,
|
||||
runId: input.runId ?? null,
|
||||
conversationId: input.conversationId ?? null,
|
||||
assistantMessageId: input.assistantMessageId ?? null,
|
||||
title,
|
||||
description,
|
||||
confidence: primary.value.endsWith('SKILL.md') || primary.value.includes('/SKILL.md') ? 0.95 : 0.78,
|
||||
sourceRefs: eligible,
|
||||
provenance: {
|
||||
summary: `Detected from ${eligible.map((ref) => ref.label || ref.value).join(', ')} after a successful run.`,
|
||||
detectedAt: input.now ?? Date.now(),
|
||||
},
|
||||
draftPath: null,
|
||||
fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertSkillPluginCandidate(
|
||||
db: SqliteDb,
|
||||
candidate: Omit<SkillPluginCandidate, 'id' | 'createdAt' | 'updatedAt' | 'status'> & { fingerprint: string },
|
||||
now = Date.now(),
|
||||
): SkillPluginCandidate | null {
|
||||
const existing = db.prepare(
|
||||
`SELECT * FROM skill_plugin_candidates WHERE project_id = ? AND fingerprint = ?`,
|
||||
).get(candidate.projectId, candidate.fingerprint) as DbRow | undefined;
|
||||
if (existing) return rowToCandidate(existing);
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO skill_plugin_candidates
|
||||
(id, project_id, run_id, conversation_id, assistant_message_id, fingerprint, status,
|
||||
title, description, confidence, source_refs_json, provenance_json, draft_path,
|
||||
created_at, updated_at, dismissed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
|
||||
).run(
|
||||
id,
|
||||
candidate.projectId,
|
||||
candidate.runId ?? null,
|
||||
candidate.conversationId ?? null,
|
||||
candidate.assistantMessageId ?? null,
|
||||
candidate.fingerprint,
|
||||
candidate.title,
|
||||
candidate.description,
|
||||
candidate.confidence,
|
||||
JSON.stringify(candidate.sourceRefs),
|
||||
JSON.stringify(candidate.provenance),
|
||||
candidate.draftPath ?? null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
return getSkillPluginCandidate(db, id);
|
||||
}
|
||||
|
||||
export function listSkillPluginCandidates(db: SqliteDb, projectId: string, includeDismissed = false): SkillPluginCandidate[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM skill_plugin_candidates
|
||||
WHERE project_id = ? ${includeDismissed ? '' : `AND status != 'dismissed'`}
|
||||
ORDER BY created_at DESC`,
|
||||
).all(projectId) as DbRow[];
|
||||
return rows.map(rowToCandidate);
|
||||
}
|
||||
|
||||
export function getSkillPluginCandidate(db: SqliteDb, id: string): SkillPluginCandidate | null {
|
||||
const row = db.prepare(`SELECT * FROM skill_plugin_candidates WHERE id = ?`).get(id) as DbRow | undefined;
|
||||
return row ? rowToCandidate(row) : null;
|
||||
}
|
||||
|
||||
export function dismissSkillPluginCandidate(db: SqliteDb, projectId: string, id: string, now = Date.now()): SkillPluginCandidate | null {
|
||||
const result = db.prepare(
|
||||
`UPDATE skill_plugin_candidates
|
||||
SET status = 'dismissed', dismissed_at = ?, updated_at = ?
|
||||
WHERE project_id = ? AND id = ?`,
|
||||
).run(now, now, projectId, id);
|
||||
if (result.changes === 0) return null;
|
||||
return getSkillPluginCandidate(db, id);
|
||||
}
|
||||
|
||||
export async function generateSkillPluginDraft(
|
||||
db: SqliteDb,
|
||||
projectRoot: string,
|
||||
projectId: string,
|
||||
id: string,
|
||||
now = Date.now(),
|
||||
): Promise<SkillPluginDraftGenerationResult | null> {
|
||||
const candidate = getSkillPluginCandidate(db, id);
|
||||
if (!candidate || candidate.projectId !== projectId || candidate.status === 'dismissed') return null;
|
||||
const slug = uniqueSlug(slugify(candidate.title || 'skill-plugin'));
|
||||
const draftPath = `plugin-source/${slug}`;
|
||||
const folder = path.join(projectRoot, ...draftPath.split('/'));
|
||||
await fs.mkdir(path.join(folder, 'references'), { recursive: true });
|
||||
await fs.writeFile(path.join(folder, 'SKILL.md'), synthesizeSkill(candidate), 'utf8');
|
||||
await fs.writeFile(path.join(folder, 'open-design.json'), JSON.stringify(buildManifest(slug, candidate), null, 2) + '\n', 'utf8');
|
||||
await fs.writeFile(path.join(folder, 'references', 'provenance.json'), JSON.stringify({
|
||||
candidateId: candidate.id,
|
||||
projectId: candidate.projectId,
|
||||
runId: candidate.runId,
|
||||
conversationId: candidate.conversationId,
|
||||
provenance: candidate.provenance,
|
||||
sourceRefs: candidate.sourceRefs.map((ref) => ({ ...ref, content: undefined })),
|
||||
}, null, 2) + '\n', 'utf8');
|
||||
let copied = 0;
|
||||
for (const ref of candidate.sourceRefs) {
|
||||
if (ref.kind !== 'file' || typeof ref.content !== 'string') continue;
|
||||
if (Buffer.byteLength(ref.content, 'utf8') > MAX_SOURCE_BYTES) continue;
|
||||
copied += 1;
|
||||
await fs.writeFile(path.join(folder, 'references', `source-${copied}-${path.basename(ref.value) || 'source.md'}`), ref.content, 'utf8');
|
||||
}
|
||||
const validationResult = await validatePluginFolder({ folder });
|
||||
const diagnostics = flattenValidationDiagnostics(validationResult).map((d) => ({
|
||||
severity: d.severity,
|
||||
code: d.code,
|
||||
message: d.message,
|
||||
}));
|
||||
db.prepare(
|
||||
`UPDATE skill_plugin_candidates
|
||||
SET draft_path = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(draftPath, now, id);
|
||||
const updated = getSkillPluginCandidate(db, id) ?? candidate;
|
||||
return {
|
||||
ok: validationResult.ok,
|
||||
candidate: updated,
|
||||
draftPath,
|
||||
folder,
|
||||
validation: { ok: validationResult.ok, diagnostics },
|
||||
};
|
||||
}
|
||||
|
||||
function rowToCandidate(row: DbRow): SkillPluginCandidate {
|
||||
return {
|
||||
id: String(row.id),
|
||||
projectId: String(row.project_id),
|
||||
runId: nullableString(row.run_id),
|
||||
conversationId: nullableString(row.conversation_id),
|
||||
assistantMessageId: nullableString(row.assistant_message_id),
|
||||
title: String(row.title ?? ''),
|
||||
description: String(row.description ?? ''),
|
||||
confidence: Number(row.confidence ?? 0),
|
||||
status: row.status === 'dismissed' ? 'dismissed' : 'active',
|
||||
sourceRefs: parseJsonArray(row.source_refs_json),
|
||||
provenance: parseJsonObject(row.provenance_json, { summary: '', detectedAt: Number(row.created_at ?? Date.now()) }) as SkillPluginCandidate['provenance'],
|
||||
draftPath: nullableString(row.draft_path),
|
||||
createdAt: Number(row.created_at ?? 0),
|
||||
updatedAt: Number(row.updated_at ?? 0),
|
||||
dismissedAt: nullableNumber(row.dismissed_at),
|
||||
};
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function parseJsonArray(value: unknown): SkillPluginCandidateSourceRef[] {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : [];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown, fallback: Record<string, unknown>): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : null;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function referencedMarkdownPaths(message: string): string[] {
|
||||
const out = new Set<string>();
|
||||
const re = /(?:^|[\s('"`])(@?[\w./ -]*(?:SKILL\.md|[A-Za-z0-9._-]+\.md))(?:$|[\s)'"`,])/gu;
|
||||
for (const match of message.matchAll(re)) {
|
||||
const value = String(match[1] ?? '').replace(/^@/, '').trim();
|
||||
if (value && !/^https?:\/\//iu.test(value)) out.add(value);
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
function referencedPluginUrls(message: string): string[] {
|
||||
const out = new Set<string>();
|
||||
const re = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\/[^\s)'"`<>]*)?/gu;
|
||||
for (const match of message.matchAll(re)) {
|
||||
const url = match[0];
|
||||
if (isExplicitSkillPluginUrl(url)) out.add(url);
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
async function readCandidateFile(projectRoot: string, rel: string): Promise<SkillPluginCandidateSourceRef | null> {
|
||||
const normalized = rel.split(/[\\/]+/u).filter(Boolean);
|
||||
if (normalized.length === 0 || normalized.some((seg) => seg === '..')) return null;
|
||||
const abs = path.join(projectRoot, ...normalized);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(abs);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!stat.isFile()) return null;
|
||||
const base = path.basename(abs);
|
||||
if (base !== 'SKILL.md' && !base.endsWith('.md')) return null;
|
||||
if (stat.size > MAX_SOURCE_BYTES) {
|
||||
return { kind: 'file', value: normalized.join('/'), label: normalized.join('/'), size: stat.size, reason: 'Source file is too large to copy safely.' };
|
||||
}
|
||||
const content = await fs.readFile(abs, 'utf8');
|
||||
return { kind: 'file', value: normalized.join('/'), label: normalized.join('/'), content, size: stat.size, copied: true };
|
||||
}
|
||||
|
||||
function isEligibleSourceRef(ref: SkillPluginCandidateSourceRef): boolean {
|
||||
if (ref.kind === 'url') return isExplicitSkillPluginUrl(ref.value);
|
||||
if (path.basename(ref.value) === 'SKILL.md') return true;
|
||||
const content = ref.content ?? '';
|
||||
if (!content) return false;
|
||||
const hasSkillSignal = /(^|\n)#{1,3}\s*(When to use|Usage|Workflow|Inputs|Outputs|Instructions|Capabilities)\b/iu.test(content)
|
||||
|| /\b(skill|agent skill|reusable workflow|use this skill)\b/iu.test(content);
|
||||
const hasSubstance = content.trim().length >= 160;
|
||||
return hasSkillSignal && hasSubstance;
|
||||
}
|
||||
|
||||
function isExplicitSkillPluginUrl(value: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.hostname !== 'github.com') return false;
|
||||
return /\/(?:SKILL\.md|open-design\.json)$/u.test(decodeURIComponent(url.pathname));
|
||||
}
|
||||
|
||||
function deriveCandidateTitle(ref: SkillPluginCandidateSourceRef): string {
|
||||
const heading = ref.content?.match(/^#\s+(.+)$/mu)?.[1]?.trim();
|
||||
if (heading) return heading.slice(0, 80);
|
||||
const base = path.basename(ref.value).replace(/\.md$/iu, '');
|
||||
return titleize(base === 'SKILL' ? path.basename(path.dirname(ref.value)) || 'Skill plugin' : base);
|
||||
}
|
||||
|
||||
function deriveCandidateDescription(ref: SkillPluginCandidateSourceRef): string {
|
||||
const paragraph = ref.content
|
||||
?.split(/\n{2,}/u)
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part && !part.startsWith('#'));
|
||||
if (paragraph) return paragraph.replace(/\s+/gu, ' ').slice(0, 220);
|
||||
return ref.kind === 'url'
|
||||
? 'This repo looks like it could work as a plugin.'
|
||||
: 'Reusable skill material detected from a project file.';
|
||||
}
|
||||
|
||||
function synthesizeSkill(candidate: SkillPluginCandidate): string {
|
||||
const source = candidate.sourceRefs.find((ref) => ref.content)?.content?.trim();
|
||||
if (source) return `${source}\n\n## Provenance\n\nFormalized by Open Design from candidate ${candidate.id}.\n`;
|
||||
return [
|
||||
`# ${candidate.title}`,
|
||||
'',
|
||||
candidate.description,
|
||||
'',
|
||||
'## When to use',
|
||||
'',
|
||||
'Use this skill when the workflow described by the source material should be repeated inside Open Design.',
|
||||
'',
|
||||
'## Workflow',
|
||||
'',
|
||||
'- Review the provenance in `references/provenance.json`.',
|
||||
'- Follow the linked source material conservatively.',
|
||||
'- Ask for clarification when the source material is incomplete.',
|
||||
'',
|
||||
'## Provenance',
|
||||
'',
|
||||
`Formalized by Open Design from candidate ${candidate.id}.`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildManifest(slug: string, candidate: SkillPluginCandidate) {
|
||||
return {
|
||||
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
||||
specVersion: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
|
||||
name: slug,
|
||||
title: candidate.title,
|
||||
version: '0.1.0',
|
||||
description: candidate.description,
|
||||
od: {
|
||||
kind: 'skill',
|
||||
taskKind: 'new-generation',
|
||||
context: {
|
||||
skills: [{ path: './SKILL.md' }],
|
||||
},
|
||||
capabilities: ['prompt:inject'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
const slug = value.toLowerCase().replace(/[^a-z0-9._-]+/gu, '-').replace(/(^[-._]+|[-._]+$)/gu, '');
|
||||
return SAFE_SLUG.test(slug) ? slug : 'skill-plugin';
|
||||
}
|
||||
|
||||
function uniqueSlug(slug: string): string {
|
||||
return `${slug}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function titleize(value: string): string {
|
||||
return value.replace(/[-_.]+/gu, ' ').replace(/\b\w/gu, (c) => c.toUpperCase()).trim() || 'Skill plugin';
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
40
apps/daemon/src/project-ignored-dirs.ts
Normal file
40
apps/daemon/src/project-ignored-dirs.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Directory names that should not be listed or watched for folder-backed
|
||||
// projects. These are generated, installed, or cache trees that add file
|
||||
// descriptor pressure without adding useful design context.
|
||||
export const IGNORED_PROJECT_DIR_NAMES = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'.od',
|
||||
'debug',
|
||||
'dist',
|
||||
'build',
|
||||
'.build',
|
||||
'deriveddata',
|
||||
'target',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'.output',
|
||||
'out',
|
||||
'coverage',
|
||||
'.gradle',
|
||||
'.swiftpm',
|
||||
'.tmp',
|
||||
'.venv',
|
||||
'venv',
|
||||
'__pycache__',
|
||||
'.mypy_cache',
|
||||
'.pytest_cache',
|
||||
'.tox',
|
||||
'.ruff_cache',
|
||||
].map((name) => name.toLowerCase()));
|
||||
|
||||
export function isIgnoredProjectDirName(name: unknown): boolean {
|
||||
const normalized = String(name).toLowerCase();
|
||||
return (
|
||||
IGNORED_PROJECT_DIR_NAMES.has(normalized) ||
|
||||
normalized.startsWith('deriveddata-')
|
||||
);
|
||||
}
|
||||
|
|
@ -872,7 +872,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
// Preflight for the raw file route. Current artifact fetches are simple GETs
|
||||
// (no preflight needed), but an explicit handler future-proofs the route if
|
||||
// artifacts ever add custom request headers.
|
||||
app.options('/api/projects/:id/raw/*', (req, res) => {
|
||||
app.options(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, (req, res) => {
|
||||
if (req.headers.origin === 'null') {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET');
|
||||
|
|
@ -881,10 +881,12 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id/raw/*', async (req, res) => {
|
||||
app.get(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => {
|
||||
try {
|
||||
const relPath = (req.params as any)[0];
|
||||
const project = getProject(db, req.params.id);
|
||||
const params = req.params as unknown as { 0?: string; 1?: string };
|
||||
const projectId = String(params[0] ?? '');
|
||||
const relPath = String(params[1] ?? '');
|
||||
const project = getProject(db, projectId);
|
||||
// PreviewModal loads artifact HTML via srcdoc, giving the iframe Origin: "null".
|
||||
// data: URIs, file://, and some sandboxed iframes also send null — all are
|
||||
// local-only callers, so this is safe. Real cross-origin sites send a real
|
||||
|
|
@ -895,7 +897,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
|
||||
const meta = await resolveProjectFilePath(
|
||||
PROJECTS_DIR,
|
||||
req.params.id,
|
||||
projectId,
|
||||
relPath,
|
||||
project?.metadata,
|
||||
);
|
||||
|
|
@ -944,7 +946,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
return;
|
||||
}
|
||||
|
||||
const file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath, project?.metadata);
|
||||
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
|
||||
res.type(file.mime).send(file.buffer);
|
||||
} catch (err: any) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
|
|
@ -957,10 +959,13 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
}
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id/raw/*', async (req, res) => {
|
||||
app.delete(/^\/api\/projects\/([^/]+)\/raw\/(.+)$/u, async (req, res) => {
|
||||
try {
|
||||
const project = getProject(db, req.params.id);
|
||||
await deleteProjectFile(PROJECTS_DIR, req.params.id, (req.params as any)[0], project?.metadata);
|
||||
const params = req.params as unknown as { 0?: string; 1?: string };
|
||||
const projectId = String(params[0] ?? '');
|
||||
const rawSplat = String(params[1] ?? '');
|
||||
const project = getProject(db, projectId);
|
||||
await deleteProjectFile(PROJECTS_DIR, projectId, rawSplat, project?.metadata);
|
||||
/** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
|
||||
const body = { ok: true };
|
||||
res.json(body);
|
||||
|
|
@ -1002,13 +1007,16 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id/files/*', async (req, res) => {
|
||||
app.get(/^\/api\/projects\/([^/]+)\/files\/(.+)$/u, async (req, res) => {
|
||||
try {
|
||||
const project = getProject(db, req.params.id);
|
||||
const params = req.params as unknown as { 0?: string; 1?: string };
|
||||
const projectId = String(params[0] ?? '');
|
||||
const fileSplat = String(params[1] ?? '');
|
||||
const project = getProject(db, projectId);
|
||||
const file = await readProjectFile(
|
||||
PROJECTS_DIR,
|
||||
req.params.id,
|
||||
(req.params as any)[0],
|
||||
projectId,
|
||||
fileSplat,
|
||||
project?.metadata,
|
||||
);
|
||||
res.type(file.mime).send(file.buffer);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import path from 'node:path';
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
|
||||
import { isIgnoredProjectDirName } from './project-ignored-dirs.js';
|
||||
import { projectDir, resolveProjectDir } from './projects.js';
|
||||
|
||||
/**
|
||||
|
|
@ -16,24 +17,7 @@ import { projectDir, resolveProjectDir } from './projects.js';
|
|||
// against the path *relative to the watch root* so that ancestor directories
|
||||
// (e.g. the daemon's own `.od/` runtime dir, which contains every project) do
|
||||
// not accidentally match and silence every event in the tree.
|
||||
const IGNORE_NAMES = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
'.od',
|
||||
'debug',
|
||||
'.DS_Store',
|
||||
// Python virtual environments and caches — can contain tens of thousands of
|
||||
// files, exhausting the process fd table and breaking child-process spawning.
|
||||
// These names are safe to match at any path depth: a directory named `.venv`
|
||||
// or `__pycache__` is never legitimate authored source in a project tree.
|
||||
'.venv',
|
||||
'venv',
|
||||
'__pycache__',
|
||||
'.mypy_cache',
|
||||
'.pytest_cache',
|
||||
'.tox',
|
||||
'.ruff_cache',
|
||||
]);
|
||||
const WATCHER_ONLY_IGNORE_NAMES = new Set(['.ds_store']);
|
||||
export type ProjectWatchKind = 'add' | 'change' | 'unlink';
|
||||
export interface ProjectWatchEvent { type: 'file-changed'; path: string; kind: ProjectWatchKind }
|
||||
export type ProjectWatchCallback = (evt: ProjectWatchEvent) => void;
|
||||
|
|
@ -56,7 +40,10 @@ export function makeIgnored(rootDir: string): (absPath: string) => boolean {
|
|||
return (absPath: string): boolean => {
|
||||
const rel = path.relative(rootDir, absPath);
|
||||
if (!rel || rel === '' || rel.startsWith('..')) return false; // never ignore root itself
|
||||
return rel.split(/[\\/]/).some((seg) => IGNORE_NAMES.has(seg));
|
||||
return rel.split(/[\\/]/).some((seg) => {
|
||||
const normalized = seg.toLowerCase();
|
||||
return WATCHER_ONLY_IGNORE_NAMES.has(normalized) || isIgnoredProjectDirName(normalized);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
assertArtifactPublicationAllowed,
|
||||
isPublicationGuardedArtifactKind,
|
||||
} from './artifact-publication-guard.js';
|
||||
import { isIgnoredProjectDirName } from './project-ignored-dirs.js';
|
||||
|
||||
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
|
||||
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
|
||||
|
|
@ -66,7 +67,7 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
|
|||
const out = [];
|
||||
// Skip build/install dirs for linked folders so node_modules doesn't stall
|
||||
// the walk on large repos.
|
||||
const skipDirs = metadata?.baseDir ? SKIP_DIRS : undefined;
|
||||
const skipDirs = metadata?.baseDir ? isIgnoredProjectDirName : undefined;
|
||||
await collectFiles(dir, '', out, skipDirs, dir);
|
||||
// Newest first — matches the visual order users expect after generating.
|
||||
out.sort((a, b) => b.mtime - a.mtime);
|
||||
|
|
@ -77,16 +78,6 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
|
|||
return out;
|
||||
}
|
||||
|
||||
// Build/install dirs that should be hidden from the file panel when a
|
||||
// project is rooted at metadata.baseDir (the user's own folder). Without
|
||||
// this, the listing would be dominated by node_modules, lockfiles, and
|
||||
// build output that have no design value.
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.turbo',
|
||||
'.cache', '.output', 'out', 'coverage', '__pycache__', '.venv',
|
||||
'vendor', 'target', '.od', '.tmp',
|
||||
]);
|
||||
|
||||
// Best-effort entry-file detector — looks for index.html at the root,
|
||||
// then any *.html file. Returns null if nothing obvious is found, in
|
||||
// which case the project simply opens to the file panel with no
|
||||
|
|
@ -104,7 +95,7 @@ export async function detectEntryFile(dir: string): Promise<string | null> {
|
|||
return null;
|
||||
}
|
||||
|
||||
async function collectFiles(dir, relDir, out, skipDirs?: Set<string>, projectRoot = dir) {
|
||||
async function collectFiles(dir, relDir, out, shouldSkipDir?: (name: string) => boolean, projectRoot = dir) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
|
|
@ -117,8 +108,8 @@ async function collectFiles(dir, relDir, out, skipDirs?: Set<string>, projectRoo
|
|||
const rel = relDir ? `${relDir}/${e.name}` : e.name;
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (skipDirs && skipDirs.has(e.name)) continue;
|
||||
await collectFiles(full, rel, out, skipDirs, projectRoot);
|
||||
if (shouldSkipDir?.(e.name)) continue;
|
||||
await collectFiles(full, rel, out, shouldSkipDir, projectRoot);
|
||||
continue;
|
||||
}
|
||||
if (!e.isFile()) continue;
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ Form authoring rules:
|
|||
- If you keep the \`brand\` question, its \`id\` must stay \`"brand"\`. Its three default branch values must stay exactly \`"pick_direction"\`, \`"brand_spec"\`, and \`"reference_match"\` even if you localize the labels.
|
||||
- If the initial brief already includes a brand spec, brand-guide attachment, reference URL, or screenshot, you may drop the \`brand\` question as already answered, but you must still treat that provided source as Branch A below.
|
||||
- Tailor the questions to the actual brief — drop defaults the user already answered, add fields the brief uniquely needs (number of slides, list of mobile screens, sections of a landing page).
|
||||
- Emit exactly ONE \`<question-form>\` in this turn. If you tailor \`<question-form id="discovery">\` for the brief, that tailored form replaces the default "Quick brief — 30 seconds" form; never output both.
|
||||
- **Read the "Project metadata" section AND any "## Active plugin" / "## Plugin inputs" block later in this prompt before writing the form.** "Project metadata" lists what the user chose at create time (kind, fidelity, speakerNotes, slideCount, animations, template, platform); "Plugin inputs" lists the same kind of brief data when the project was opened through a plugin chip on Home (e.g. \`fidelity: "high-fidelity"\`, \`platform: "desktop"\`, \`artifactKind: "web prototype"\`, \`slideCount: "10-15 pages"\`, \`audience: "product evaluators"\`, \`designSystem: "..."\`). **Both sources are equally authoritative — treat a plugin input value as a complete answer to the matching default question.** Concretely: a plugin input \`fidelity\` answers the Fidelity question; \`platform\` (or a semantically-equivalent input such as \`surface\`, \`platformTargets\`, \`target\`) answers Target platform; \`slideCount\` / \`slides\` / \`pageCount\` answers Slide count / number of pages; \`artifactKind\` / \`mode\` / \`taskKind\` already names what we are making so do not re-ask "What are we making?"; \`audience\` answers "Who is this for?"; \`designSystem\` / \`brand\` answers Brand context. Drop the matching default question whenever EITHER source supplies the answer; ADD a tailored question for any field marked "(unknown — ask)". For example, on a deck with \`speakerNotes: (unknown — ask…)\`, include a yes/no on speaker notes; on a template project where animations is unknown, include a motion radio; on a cross-platform project, ask which screens need native variants instead of re-asking platform. Don't re-ask the kind itself if metadata.kind is set or the active plugin's \`od.kind\` / \`taskKind\` already names it — the user already told you.
|
||||
- Keep it under ~7 questions. Second batch in a follow-up form if needed.
|
||||
- Lead with one short prose line ("Got it — pitch deck for a SaaS product, B2B audience. Tell me the rest:") then the form. Do **not** write a long pre-amble.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
IMAGE_MODELS,
|
||||
VIDEO_MODELS,
|
||||
} from '../media-models.js';
|
||||
import type { MediaExecutionPolicy, MediaSurface } from '@open-design/contracts';
|
||||
|
||||
function fmtList(ids: string[]): string {
|
||||
return ids.map((id) => `\`${id}\``).join(', ');
|
||||
|
|
@ -34,6 +35,67 @@ const AUDIO_MUSIC_IDS = fmtList(AUDIO_MODELS_BY_KIND.music.map((m) => m.id));
|
|||
const AUDIO_SPEECH_IDS = fmtList(AUDIO_MODELS_BY_KIND.speech.map((m) => m.id));
|
||||
const AUDIO_SFX_IDS = fmtList(AUDIO_MODELS_BY_KIND.sfx.map((m) => m.id));
|
||||
|
||||
export function renderMediaGenerationContract(
|
||||
mediaExecution?: MediaExecutionPolicy | undefined,
|
||||
): string {
|
||||
const mode = mediaExecution?.mode ?? 'enabled';
|
||||
if (mode === 'enabled') {
|
||||
return renderEnabledMediaGenerationContract(mediaExecution);
|
||||
}
|
||||
const scope = renderMediaPolicyScope(mediaExecution);
|
||||
if (mode === 'disabled') {
|
||||
return `
|
||||
---
|
||||
|
||||
## Media generation policy (load-bearing — overrides softer wording above)
|
||||
|
||||
Open Design-owned media execution is **disabled for this run**. Do not call
|
||||
\`"$OD_NODE_BIN" "$OD_BIN" media generate\`, Codex built-in imagegen, OD media
|
||||
provider APIs, local renderers, or ad-hoc scripts that create media bytes on
|
||||
OD's behalf.
|
||||
|
||||
External MCP media tools, when explicitly configured for this run, are outside
|
||||
this OD-owned media policy. If no such external tool is available and the user
|
||||
asks for media, describe the intended creative brief, prompt, surface, model
|
||||
preference, references, and output filename in chat, then stop. Do not claim a
|
||||
file was generated and do not emit an \`<artifact>\` block for media.
|
||||
${scope}`;
|
||||
}
|
||||
return renderEnabledMediaGenerationContract(mediaExecution);
|
||||
}
|
||||
|
||||
function renderEnabledMediaGenerationContract(
|
||||
mediaExecution?: MediaExecutionPolicy | undefined,
|
||||
): string {
|
||||
const scope = renderMediaPolicyScope(mediaExecution);
|
||||
if (!scope) return MEDIA_GENERATION_CONTRACT;
|
||||
return MEDIA_GENERATION_CONTRACT.replace(
|
||||
'\n### Allowed model IDs (per surface)',
|
||||
`
|
||||
### Active media policy scope
|
||||
|
||||
The dispatcher will reject surfaces or models outside this run's active
|
||||
allowlist. Treat this allowlist as narrower than the full catalogue below;
|
||||
select only from it.
|
||||
${scope}
|
||||
|
||||
### Allowed model IDs (per surface)`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderMediaPolicyScope(
|
||||
mediaExecution?: MediaExecutionPolicy | undefined,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
if (Array.isArray(mediaExecution?.allowedSurfaces) && mediaExecution.allowedSurfaces.length > 0) {
|
||||
lines.push(`Allowed surfaces for this run: ${fmtList(mediaExecution.allowedSurfaces as MediaSurface[])}.`);
|
||||
}
|
||||
if (Array.isArray(mediaExecution?.allowedModels) && mediaExecution.allowedModels.length > 0) {
|
||||
lines.push(`Allowed models for this run: ${fmtList(mediaExecution.allowedModels)}.`);
|
||||
}
|
||||
return lines.length > 0 ? `\n\n${lines.join('\n')}` : '';
|
||||
}
|
||||
|
||||
export const MEDIA_GENERATION_CONTRACT = `
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -32,10 +32,11 @@
|
|||
import { OFFICIAL_DESIGNER_PROMPT } from './official-system.js';
|
||||
import { DISCOVERY_AND_PHILOSOPHY } from './discovery.js';
|
||||
import { DECK_FRAMEWORK_DIRECTIVE } from './deck-framework.js';
|
||||
import { MEDIA_GENERATION_CONTRACT } from './media-contract.js';
|
||||
import { renderMediaGenerationContract } from './media-contract.js';
|
||||
import { IMAGE_MODELS } from '../media-models.js';
|
||||
import { renderPanelPrompt } from './panel.js';
|
||||
import { defaultCritiqueConfig, type CritiqueConfig } from '@open-design/contracts/critique';
|
||||
import type { MediaExecutionPolicy, MediaSurface } from '@open-design/contracts';
|
||||
|
||||
const ELEVENLABS_VOICE_PROMPT_OPTION_LIMIT = 100;
|
||||
const ELEVENLABS_VOICE_OPTIONS_PROMPT_PREFIX = 'ElevenLabs voice list could not be loaded';
|
||||
|
|
@ -371,6 +372,9 @@ export interface ComposeInput {
|
|||
// UI locale selected by the client. User-visible generated form copy
|
||||
// must follow this locale even when the user's initial prompt is brief.
|
||||
locale?: string | undefined;
|
||||
// Run-scoped media policy. Defaults to enabled when omitted so existing
|
||||
// local OD behavior keeps the same media prompt contract.
|
||||
mediaExecution?: MediaExecutionPolicy | undefined;
|
||||
}
|
||||
|
||||
export function composeSystemPrompt({
|
||||
|
|
@ -405,6 +409,7 @@ export function composeSystemPrompt({
|
|||
locale,
|
||||
userInstructions,
|
||||
projectInstructions,
|
||||
mediaExecution,
|
||||
}: ComposeInput): string {
|
||||
// Discovery + philosophy goes FIRST so its hard rules ("emit a form on
|
||||
// turn 1", "branch on brand on turn 2", "TodoWrite on turn 3", run
|
||||
|
|
@ -557,7 +562,13 @@ export function composeSystemPrompt({
|
|||
}
|
||||
}
|
||||
|
||||
const metaBlock = renderMetadataBlock(metadata, template, audioVoiceOptions, audioVoiceOptionsError);
|
||||
const metaBlock = renderMetadataBlock(
|
||||
metadata,
|
||||
template,
|
||||
audioVoiceOptions,
|
||||
audioVoiceOptionsError,
|
||||
mediaExecution,
|
||||
);
|
||||
if (metaBlock) parts.push(metaBlock);
|
||||
|
||||
// Decks have a load-bearing framework (nav, counter, scroll JS, print
|
||||
|
|
@ -602,10 +613,10 @@ export function composeSystemPrompt({
|
|||
|| resolvedExclusiveSurface === 'video'
|
||||
|| resolvedExclusiveSurface === 'audio';
|
||||
if (isMediaSurface) {
|
||||
parts.push(MEDIA_GENERATION_CONTRACT);
|
||||
parts.push(renderMediaGenerationContract(mediaExecution));
|
||||
}
|
||||
|
||||
if (includeCodexImagegenOverride) {
|
||||
if (includeCodexImagegenOverride && shouldAllowCodexImagegenOverride(metadata, mediaExecution)) {
|
||||
const codexImagegenOverride = renderCodexImagegenOverride(
|
||||
agentId,
|
||||
metadata,
|
||||
|
|
@ -757,6 +768,31 @@ export function shouldRenderCodexImagegenOverride(
|
|||
);
|
||||
}
|
||||
|
||||
function shouldAllowCodexImagegenOverride(
|
||||
metadata: ProjectMetadata | undefined,
|
||||
mediaExecution: MediaExecutionPolicy | undefined,
|
||||
): boolean {
|
||||
const mode = mediaExecution?.mode ?? 'enabled';
|
||||
if (mode !== 'enabled') return false;
|
||||
if (
|
||||
Array.isArray(mediaExecution?.allowedSurfaces) &&
|
||||
mediaExecution.allowedSurfaces.length > 0 &&
|
||||
!mediaExecution.allowedSurfaces.includes('image')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const model = resolveCodexImagegenModelId(metadata);
|
||||
if (
|
||||
model &&
|
||||
Array.isArray(mediaExecution?.allowedModels) &&
|
||||
mediaExecution.allowedModels.length > 0 &&
|
||||
!mediaExecution.allowedModels.includes(model)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function renderCodexImagegenOverride(
|
||||
agentId: string | null | undefined,
|
||||
metadata: ProjectMetadata | undefined,
|
||||
|
|
@ -813,6 +849,7 @@ function renderMetadataBlock(
|
|||
template: ProjectTemplate | undefined,
|
||||
audioVoiceOptions: AudioVoiceOption[] | undefined,
|
||||
audioVoiceOptionsError: string | undefined,
|
||||
mediaExecution: MediaExecutionPolicy | undefined,
|
||||
): string {
|
||||
if (!metadata) return '';
|
||||
const lines: string[] = [];
|
||||
|
|
@ -859,6 +896,9 @@ function renderMetadataBlock(
|
|||
lines.push(
|
||||
'- **interaction-fidelity rule**: when the requested screen includes user input, generation, copying, validation, login, checkout, filtering, or any action verb, build real interactive controls for that screen. Do not substitute static text rows, prefilled-only mockups, screenshot-like device frames, or decorative state cards for editable inputs and working actions.',
|
||||
);
|
||||
lines.push(
|
||||
'- **artifact-output rule**: when you generate an HTML artifact, keep conversational prose concise and product-facing. Do not dump the full raw HTML source back into chat; the artifact/file is the source of truth and the assistant message should only summarize the result.',
|
||||
);
|
||||
}
|
||||
if (metadata.includeLandingPage) {
|
||||
lines.push(
|
||||
|
|
@ -918,9 +958,11 @@ function renderMetadataBlock(
|
|||
lines.push(`- **referenceTemplate**: ${metadata.promptTemplate.title}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'This is an **image** project. Plan the prompt carefully, then dispatch via the **media generation contract** using `"$OD_NODE_BIN" "$OD_BIN" media generate --surface image --model <imageModel>`. Do NOT emit `<artifact>` HTML for media surfaces.',
|
||||
);
|
||||
lines.push(renderMediaMetadataAction(
|
||||
'image',
|
||||
'`"$OD_NODE_BIN" "$OD_BIN" media generate --surface image --model <imageModel>`',
|
||||
mediaExecution,
|
||||
));
|
||||
}
|
||||
if (metadata.kind === 'video') {
|
||||
lines.push(
|
||||
|
|
@ -940,9 +982,11 @@ function renderMetadataBlock(
|
|||
lines.push(`- **referenceTemplate**: ${metadata.promptTemplate.title}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'This is a **video** project. Plan the shotlist and motion, then dispatch via the **media generation contract** using `"$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model <videoModel> --length <seconds> --aspect <ratio>`. Do NOT emit `<artifact>` HTML.',
|
||||
);
|
||||
lines.push(renderMediaMetadataAction(
|
||||
'video',
|
||||
'`"$OD_NODE_BIN" "$OD_BIN" media generate --surface video --model <videoModel> --length <seconds> --aspect <ratio>`',
|
||||
mediaExecution,
|
||||
));
|
||||
if (metadata.videoModel === 'hyperframes-html') {
|
||||
lines.push(
|
||||
'Special case: `hyperframes-html` is a local HTML-to-MP4 renderer, not a photoreal text-to-video model. Treat it like a motion design renderer, ask at most one clarifying question, then dispatch immediately.',
|
||||
|
|
@ -992,9 +1036,11 @@ function renderMetadataBlock(
|
|||
);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'This is an **audio** project. Lock the content intent first, then dispatch via the **media generation contract** using `"$OD_NODE_BIN" "$OD_BIN" media generate --surface audio --audio-kind <kind> --model <audioModel> --duration <seconds>` and add `--voice <voice-id>` for speech when you have a provider-specific voice id. Do NOT emit `<artifact>` HTML.',
|
||||
);
|
||||
lines.push(renderMediaMetadataAction(
|
||||
'audio',
|
||||
'`"$OD_NODE_BIN" "$OD_BIN" media generate --surface audio --audio-kind <kind> --model <audioModel> --duration <seconds>` and add `--voice <voice-id>` for speech when you have a provider-specific voice id',
|
||||
mediaExecution,
|
||||
));
|
||||
}
|
||||
|
||||
if (metadata.inspirationDesignSystemIds && metadata.inspirationDesignSystemIds.length > 0) {
|
||||
|
|
@ -1137,6 +1183,19 @@ function renderMetadataBlock(
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderMediaMetadataAction(
|
||||
surface: MediaSurface,
|
||||
command: string,
|
||||
mediaExecution: MediaExecutionPolicy | undefined,
|
||||
): string {
|
||||
const article = surface === 'audio' ? 'an' : 'a';
|
||||
const mode = mediaExecution?.mode ?? 'enabled';
|
||||
if (mode === 'disabled') {
|
||||
return `This is ${article} **${surface}** project, but Open Design-owned media execution is disabled for this run. Plan the creative brief only unless an external MCP media tool is explicitly configured. Do NOT call OD media generation tools and do NOT emit \`<artifact>\` HTML for media surfaces.`;
|
||||
}
|
||||
return `This is ${article} **${surface}** project. Plan the creative brief carefully, then dispatch via the **media generation contract** using ${command}. Do NOT emit \`<artifact>\` HTML for media surfaces.`;
|
||||
}
|
||||
|
||||
function shouldRenderElevenLabsVoiceOptions(
|
||||
metadata: ProjectMetadata,
|
||||
audioVoiceOptions: AudioVoiceOption[] | undefined,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import { isLoopbackApiHost } from '@open-design/contracts/api/connectionTest';
|
|||
import { redactSecrets, validateBaseUrlResolved } from './connectionTest.js';
|
||||
import { googleProviderModelsUrl, normalizeGoogleModelId } from './google-models.js';
|
||||
|
||||
type ProviderModelsInput = ProviderModelsRequest & { signal?: AbortSignal };
|
||||
type ProviderModelsInput = ProviderModelsRequest & {
|
||||
signal?: AbortSignal;
|
||||
requestInit?: Pick<RequestInit, 'dispatcher'>;
|
||||
};
|
||||
|
||||
const PROVIDER_MODELS_TIMEOUT_MS = 12_000;
|
||||
|
||||
|
|
@ -236,6 +239,7 @@ export async function listProviderModels(
|
|||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: providerModelsHeaders(input.protocol, input.apiKey),
|
||||
...input.requestInit,
|
||||
signal: controller.signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface SearchResearchInput {
|
|||
projectRoot: string;
|
||||
maxSources?: number;
|
||||
providers?: string[];
|
||||
requestInit?: Pick<RequestInit, 'dispatcher'>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +71,7 @@ export async function searchResearch(
|
|||
maxResults: maxSources,
|
||||
includeAnswer: true,
|
||||
...(cfg.baseUrl ? { baseUrl: cfg.baseUrl } : {}),
|
||||
...(input.requestInit ? { requestInit: input.requestInit } : {}),
|
||||
...(input.signal ? { signal: input.signal } : {}),
|
||||
});
|
||||
answer = out.answer;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface TavilySearchInput {
|
|||
searchDepth?: 'basic' | 'advanced';
|
||||
maxResults?: number;
|
||||
includeAnswer?: boolean;
|
||||
requestInit?: Pick<RequestInit, 'dispatcher'>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ export async function tavilySearch(
|
|||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${base}/search`, {
|
||||
...input.requestInit,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// @ts-nocheck
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
|
||||
|
||||
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ export function createChatRunService({
|
|||
: null,
|
||||
pluginId:
|
||||
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
|
||||
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
|
||||
status: 'queued',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
@ -102,6 +104,7 @@ export function createChatRunService({
|
|||
signal: run.signal,
|
||||
error: run.error ?? null,
|
||||
errorCode: run.errorCode ?? null,
|
||||
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
|
||||
});
|
||||
|
||||
const finish = (run, status, code: number | null = null, signal: string | null = null) => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@ import type { RuntimeEnv } from './types.js';
|
|||
export type AgentAuthProbeResult = {
|
||||
status: 'ok' | 'missing' | 'unknown';
|
||||
message?: string;
|
||||
// Output captured from the probe child process (e.g.
|
||||
// `cursor-agent status`). Exposed so callers like the connection
|
||||
// test layer can fold the probe's own stderr/exit context into their
|
||||
// structured diagnostics — the probe runs before the smoke spawn,
|
||||
// so without this the diagnostics block would otherwise drop the
|
||||
// probe output entirely.
|
||||
stdoutTail?: string;
|
||||
stderrTail?: string;
|
||||
exitCode?: number | null;
|
||||
signal?: string | null;
|
||||
};
|
||||
|
||||
const CURSOR_AUTH_GUIDANCE =
|
||||
|
|
@ -12,6 +22,9 @@ const CURSOR_AUTH_GUIDANCE =
|
|||
const DEEPSEEK_AUTH_GUIDANCE =
|
||||
'DeepSeek TUI is installed but is not authenticated. Add or verify your API key in `~/.deepseek/config.toml` as `api_key = "..."`, or expose DEEPSEEK_API_KEY to the Open Design daemon process, then retry. If Open Design is launched outside an interactive shell, shell rc files such as ~/.zshrc may not be loaded.';
|
||||
|
||||
const REASONIX_AUTH_GUIDANCE =
|
||||
'DeepSeek Reasonix is installed but is not authenticated. Add your API key in `~/.reasonix/config.json` under `apiKey`, or expose DEEPSEEK_API_KEY to the Open Design daemon process, then retry. If Open Design is launched outside an interactive shell, shell rc files such as ~/.zshrc may not be loaded.';
|
||||
|
||||
export function cursorAuthGuidance(): string {
|
||||
return CURSOR_AUTH_GUIDANCE;
|
||||
}
|
||||
|
|
@ -20,6 +33,10 @@ export function deepseekAuthGuidance(): string {
|
|||
return DEEPSEEK_AUTH_GUIDANCE;
|
||||
}
|
||||
|
||||
export function reasonixAuthGuidance(): string {
|
||||
return REASONIX_AUTH_GUIDANCE;
|
||||
}
|
||||
|
||||
export function isCursorAuthFailureText(text: string): boolean {
|
||||
const value = String(text || '');
|
||||
if (!value.trim()) return false;
|
||||
|
|
@ -45,6 +62,18 @@ export function isDeepSeekAuthFailureText(text: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
export function isReasonixAuthFailureText(text: string): boolean {
|
||||
const value = String(text || '');
|
||||
if (!value.trim()) return false;
|
||||
return (
|
||||
/~\/\.reasonix\/config\.json/i.test(value) &&
|
||||
/api[_ -]?key|missing|not set|required|unauthorized|invalid/i.test(value)
|
||||
) || (
|
||||
/DEEPSEEK_API_KEY/i.test(value) &&
|
||||
/auth|missing|not set|required|unauthorized|invalid/i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyAgentAuthFailure(
|
||||
agentId: string,
|
||||
text: string,
|
||||
|
|
@ -63,9 +92,103 @@ export function classifyAgentAuthFailure(
|
|||
message: deepseekAuthGuidance(),
|
||||
};
|
||||
}
|
||||
if (agentId === 'reasonix') {
|
||||
if (!isReasonixAuthFailureText(text)) return null;
|
||||
return {
|
||||
status: 'missing',
|
||||
message: reasonixAuthGuidance(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Model-service failure classes that map a CLI agent's raw error text to a
|
||||
// structured API error code. `classifyAgentAuthFailure` only covers the two
|
||||
// agents (cursor-agent, deepseek) that ship a tailored sign-in hint; every
|
||||
// other CLI agent (Claude Code, codex, …) used to collapse auth / quota /
|
||||
// upstream failures into the generic `AGENT_EXECUTION_FAILED`. This agent-
|
||||
// agnostic, text-based classifier recovers the specific class so the chat
|
||||
// shows an accurate reason — and so the hosted-AMR nudge can key off it.
|
||||
export type AgentServiceFailureCode =
|
||||
| 'AGENT_AUTH_REQUIRED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'UPSTREAM_UNAVAILABLE';
|
||||
|
||||
// A bare HTTP status number (`500`, `429`, …) is too noisy to trust on its own
|
||||
// — agent stderr is full of unrelated numbers (`line 500`, `read 502 bytes`,
|
||||
// `took 503ms`, `exit code 401`, `process exited with code 429`). Only treat a
|
||||
// status number as a signal when it carries explicit HTTP-status context
|
||||
// (`HTTP 500`, `status 429`, `status code 401`, `error code 502`,
|
||||
// `server error 503`, or a punctuation-bound `code: 401`). Crucially `code`
|
||||
// alone is NOT enough — that would still match process-exit lines like `exit
|
||||
// code 401`; it only counts when qualified (status/error/response code) or
|
||||
// immediately followed by `:`/`=`/`#`. Phrasing per review on #3083.
|
||||
const STATUS_CTX =
|
||||
'(?:' +
|
||||
'\\bhttp(?:[ /]?\\d(?:\\.\\d)?)?\\b' + // HTTP, HTTP/1.1
|
||||
'|\\b(?:status|error|response)(?:[ _-]?code)?\\b' + // status / status code / error code / response code
|
||||
'|\\bcode(?=\\s*[:=#])' + // code: 401 / code=429 (NOT "exit code 401")
|
||||
'|\\b(?:server|http)[ _-]?error\\b' + // server error / http error
|
||||
')[\\s:=#-]*';
|
||||
|
||||
// Authentication / authorization: a missing, invalid, or expired credential.
|
||||
const AGENT_AUTH_FAILURE_RE = new RegExp(
|
||||
`(\\b(unauthor(?:ized|ised)|authenticat(?:e|ed|ion)|invalid[ _-]?(?:api[ _-]?)?key|incorrect api key|x-api-key|not (?:authenticated|logged[ _-]?in)|please (?:sign|log)[ _-]?in|oauth token (?:has )?expired|session expired|credentials? (?:are )?(?:missing|invalid|required))\\b|\\/login\\b|${STATUS_CTX}401\\b)`,
|
||||
'i',
|
||||
);
|
||||
|
||||
// Quota / rate limit / billing balance — the wall the hosted gateway avoids.
|
||||
const AGENT_RATE_FAILURE_RE = new RegExp(
|
||||
`(\\b(rate[ _-]?limit|too many requests|quota|insufficient[ _-]?(?:quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|usage limit|billing (?:hard )?limit)\\b|${STATUS_CTX}429\\b)`,
|
||||
'i',
|
||||
);
|
||||
|
||||
// Upstream model/provider problems: overloaded, 5xx, temporarily unavailable.
|
||||
const AGENT_UPSTREAM_FAILURE_RE = new RegExp(
|
||||
`(\\b(overloaded(?:_error)?|service (?:is )?(?:temporarily )?unavailable|bad gateway|gateway timeout|internal server error|upstream (?:error|unavailable)|provider (?:error|unavailable)|temporarily unavailable|model is currently overloaded|5xx)\\b|${STATUS_CTX}5\\d\\d\\b|\\b5\\d\\d\\s+(?:bad gateway|service unavailable|internal server error|gateway timeout))`,
|
||||
'i',
|
||||
);
|
||||
|
||||
// Returns the model-service failure class implied by an agent's combined
|
||||
// stdout/stderr/error text, or null when the text looks like an ordinary
|
||||
// process failure. Auth is checked before rate/upstream so a `401` is never
|
||||
// misread as a `5xx`. Pure text match — no agent-specific assumptions — so it
|
||||
// applies uniformly to any CLI agent.
|
||||
export function classifyAgentServiceFailure(
|
||||
text: string,
|
||||
): AgentServiceFailureCode | null {
|
||||
const value = String(text || '');
|
||||
if (!value.trim()) return null;
|
||||
if (AGENT_AUTH_FAILURE_RE.test(value)) return 'AGENT_AUTH_REQUIRED';
|
||||
if (AGENT_RATE_FAILURE_RE.test(value)) return 'RATE_LIMITED';
|
||||
if (AGENT_UPSTREAM_FAILURE_RE.test(value)) return 'UPSTREAM_UNAVAILABLE';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tail length matches the smoke-test sink so the diagnostics block
|
||||
// stays compact when it folds probe output back into its overrides.
|
||||
const PROBE_TAIL_BYTES = 400;
|
||||
|
||||
function tailString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.length > PROBE_TAIL_BYTES ? trimmed.slice(-PROBE_TAIL_BYTES) : trimmed;
|
||||
}
|
||||
|
||||
function withProbeTails(
|
||||
base: AgentAuthProbeResult,
|
||||
stdoutText: string,
|
||||
stderrText: string,
|
||||
): AgentAuthProbeResult {
|
||||
const result: AgentAuthProbeResult = { ...base };
|
||||
const stdoutTail = tailString(stdoutText);
|
||||
const stderrTail = tailString(stderrText);
|
||||
if (stdoutTail) result.stdoutTail = stdoutTail;
|
||||
if (stderrTail) result.stderrTail = stderrTail;
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function probeAgentAuthStatus(
|
||||
agentId: string,
|
||||
resolvedBin: string,
|
||||
|
|
@ -78,27 +201,54 @@ export async function probeAgentAuthStatus(
|
|||
timeout: 5000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const output = `${stdout ?? ''}\n${stderr ?? ''}`;
|
||||
const stdoutText = typeof stdout === 'string' ? stdout : '';
|
||||
const stderrText = typeof stderr === 'string' ? stderr : '';
|
||||
const output = `${stdoutText}\n${stderrText}`;
|
||||
if (isCursorAuthFailureText(output)) {
|
||||
return { status: 'missing', message: cursorAuthGuidance() };
|
||||
return withProbeTails(
|
||||
{ status: 'missing', message: cursorAuthGuidance(), exitCode: 0, signal: null },
|
||||
stdoutText,
|
||||
stderrText,
|
||||
);
|
||||
}
|
||||
return { status: 'ok' };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & {
|
||||
stdout?: unknown;
|
||||
stderr?: unknown;
|
||||
code?: string | number;
|
||||
signal?: string;
|
||||
};
|
||||
const output = [
|
||||
err.message,
|
||||
typeof err.stdout === 'string' ? err.stdout : '',
|
||||
typeof err.stderr === 'string' ? err.stderr : '',
|
||||
].join('\n');
|
||||
const stdoutText = typeof err.stdout === 'string' ? err.stdout : '';
|
||||
const stderrText = typeof err.stderr === 'string' ? err.stderr : '';
|
||||
const output = [err.message, stdoutText, stderrText].join('\n');
|
||||
// util.promisify(execFile) attaches `code` and `signal` to the
|
||||
// rejection error. `code` may be a number (real non-zero exit) or
|
||||
// a Node ErrnoException string ("ENOENT"); only the numeric form
|
||||
// is meaningful as an exit code.
|
||||
const numericExit = typeof err.code === 'number' ? err.code : null;
|
||||
const childSignal = typeof err.signal === 'string' ? err.signal : null;
|
||||
if (isCursorAuthFailureText(output)) {
|
||||
return { status: 'missing', message: cursorAuthGuidance() };
|
||||
return withProbeTails(
|
||||
{
|
||||
status: 'missing',
|
||||
message: cursorAuthGuidance(),
|
||||
exitCode: numericExit,
|
||||
signal: childSignal,
|
||||
},
|
||||
stdoutText,
|
||||
stderrText,
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 'unknown',
|
||||
message: 'Cursor Agent authentication status could not be verified with `cursor-agent status`.',
|
||||
};
|
||||
return withProbeTails(
|
||||
{
|
||||
status: 'unknown',
|
||||
message: 'Cursor Agent authentication status could not be verified with `cursor-agent status`.',
|
||||
exitCode: numericExit,
|
||||
signal: childSignal,
|
||||
},
|
||||
stdoutText,
|
||||
stderrText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
apps/daemon/src/runtimes/defs/aider.ts
Normal file
63
apps/daemon/src/runtimes/defs/aider.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { DEFAULT_MODEL_OPTION } from './shared.js';
|
||||
import type { RuntimeAgentDef } from '../types.js';
|
||||
|
||||
export const aiderAgentDef = {
|
||||
id: 'aider',
|
||||
name: 'Aider',
|
||||
bin: 'aider',
|
||||
versionArgs: ['--version'],
|
||||
// Aider proxies to whatever LLM the user configures via `--model` and
|
||||
// routes through LiteLLM, so any concrete fallback list is necessarily
|
||||
// partial. These are the commonly recommended starting points from
|
||||
// aider.chat/docs; users can paste anything else through the custom-
|
||||
// model input. The id strings follow LiteLLM provider/model spelling
|
||||
// so Aider parses them without an extra `--provider` flag.
|
||||
fallbackModels: [
|
||||
DEFAULT_MODEL_OPTION,
|
||||
{ id: 'sonnet', label: 'sonnet' },
|
||||
{ id: 'gpt-4o', label: 'gpt-4o' },
|
||||
{ id: 'deepseek/deepseek-chat', label: 'deepseek/deepseek-chat' },
|
||||
{ id: 'gemini/gemini-2.0-flash', label: 'gemini/gemini-2.0-flash' },
|
||||
],
|
||||
// Aider's one-shot mode requires the prompt as `--message <text>` on
|
||||
// argv; neither `--message` nor `--message-file` accept `-` as a stdin
|
||||
// sentinel (it is treated as a literal filename), so we cannot pipe
|
||||
// the prompt in the way qwen/gemini do. Mirror the DeepSeek TUI
|
||||
// pattern: ship the prompt as argv with a conservative byte budget so
|
||||
// the /api/chat spawn path emits an actionable error before hitting
|
||||
// Windows' ~32 KB CreateProcess limit or Linux MAX_ARG_STRLEN.
|
||||
//
|
||||
// The suppression flags are all there to keep aider runnable without
|
||||
// a TTY:
|
||||
// --yes-always — skip per-action confirmation
|
||||
// --no-pretty — strip ANSI so stdout parses as plain text
|
||||
// --no-stream — left as default (streaming on)
|
||||
// --no-git / --no-auto-commits — the daemon spawns aider inside
|
||||
// an OD project workspace that is
|
||||
// not the user's git repo, so the
|
||||
// commit machinery has nothing
|
||||
// useful to do here
|
||||
// --no-suggest-shell-commands — avoids a follow-up interactive prompt
|
||||
// --no-show-model-warnings — suppresses model-compat banners
|
||||
// that would otherwise prefix every
|
||||
// run with noise
|
||||
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
|
||||
const args = [
|
||||
'--yes-always',
|
||||
'--no-pretty',
|
||||
'--no-git',
|
||||
'--no-auto-commits',
|
||||
'--no-suggest-shell-commands',
|
||||
'--no-show-model-warnings',
|
||||
];
|
||||
if (options.model && options.model !== 'default') {
|
||||
args.push('--model', options.model);
|
||||
}
|
||||
args.push('--message', prompt);
|
||||
return args;
|
||||
},
|
||||
maxPromptArgBytes: 30_000,
|
||||
streamFormat: 'plain',
|
||||
installUrl: 'https://aider.chat/docs/install.html',
|
||||
docsUrl: 'https://aider.chat',
|
||||
} satisfies RuntimeAgentDef;
|
||||
153
apps/daemon/src/runtimes/defs/amr.ts
Normal file
153
apps/daemon/src/runtimes/defs/amr.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { execAgentFile } from './shared.js';
|
||||
import type { RuntimeAgentDef, RuntimeModelOption } from '../types.js';
|
||||
|
||||
const PREFERRED_AMR_CHAT_MODEL_ORDER = [
|
||||
'deepseek-v4-flash',
|
||||
'deepseek-v3.2',
|
||||
'glm-5.1',
|
||||
'gemini-2.5-flash',
|
||||
] as const;
|
||||
|
||||
const PREFERRED_AMR_CHAT_MODEL_RANK: ReadonlyMap<string, number> = new Map(
|
||||
PREFERRED_AMR_CHAT_MODEL_ORDER.map((id, index) => [id, index]),
|
||||
);
|
||||
|
||||
// AMR is the vela CLI's ACP stdio mode. `vela agent run --runtime opencode`
|
||||
// starts a private OpenCode server and forwards stream-json over ACP JSON-RPC.
|
||||
// Required env (set on the daemon process or via Settings → CLI env):
|
||||
// VELA_RUNTIME_KEY — OpenRouter (or compatible) API key
|
||||
// VELA_LINK_URL — OpenAI-compatible endpoint, e.g. https://openrouter.ai/api/v1
|
||||
// VELA_OPENCODE_BIN — optional; absolute path to opencode when not on PATH
|
||||
// See docs/new-agent-runtime-acp.md and the vela
|
||||
// `specs/current/runtime/manual-agent-run-openrouter.md`.
|
||||
//
|
||||
// Model wiring notes:
|
||||
//
|
||||
// 1. vela rejects `session/prompt` until `session/set_model` has been
|
||||
// called, so AMR cannot accept the synthetic `default` model id —
|
||||
// attachAcpSession skips set_model whenever model === 'default'.
|
||||
//
|
||||
// 2. Vela 0.0.1 exposes the current link-supported catalog through
|
||||
// `vela models`, but that command prints public ids such as
|
||||
// `public_model_deepseek_v3_2`. The ACP `session/set_model` call accepts
|
||||
// the link-facing slug (`deepseek-v3.2` / `glm-5.1`), so Open Design
|
||||
// normalizes those public ids at the daemon boundary until Vela exposes
|
||||
// canonical ACP ids directly.
|
||||
export function normalizeVelaModelId(rawId: string): string | null {
|
||||
const trimmed = rawId.trim();
|
||||
if (!trimmed) return null;
|
||||
const withoutProvider = trimmed.startsWith('vela/')
|
||||
? trimmed.slice('vela/'.length)
|
||||
: trimmed;
|
||||
const withoutPrefix = withoutProvider.startsWith('public_model_')
|
||||
? withoutProvider.slice('public_model_'.length)
|
||||
: withoutProvider;
|
||||
if (!withoutPrefix) return null;
|
||||
if (/^deepseek_v3_2$/i.test(withoutPrefix)) return 'deepseek-v3.2';
|
||||
if (/^kimi_k2_6$/i.test(withoutPrefix)) return 'kimi-k2.6';
|
||||
if (/^glm_5_1$/i.test(withoutPrefix)) return 'glm-5.1';
|
||||
if (/^glm_5$/i.test(withoutPrefix)) return 'glm-5';
|
||||
const versioned = normalizeKnownVelaVersionId(withoutPrefix);
|
||||
if (versioned) return versioned;
|
||||
return withoutPrefix.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
function normalizeKnownVelaVersionId(rawId: string): string | null {
|
||||
const claude = /^claude[_-](haiku|opus|sonnet)[_-](\d+)[_-](\d+)(.*)$/i.exec(rawId);
|
||||
if (claude) {
|
||||
const [, family, major, minor, suffix = ''] = claude;
|
||||
if (!family || !major || !minor) return null;
|
||||
return `claude-${family.toLowerCase()}-${major}.${minor}${suffix.replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
const gpt = /^gpt_(\d+)_(\d+)(.*)$/i.exec(rawId);
|
||||
if (gpt) {
|
||||
const [, major, minor, suffix = ''] = gpt;
|
||||
if (!major || !minor) return null;
|
||||
return `gpt-${major}.${minor}${suffix.replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
const gemini = /^gemini_(\d+)_(\d+)(.*)$/i.exec(rawId);
|
||||
if (gemini) {
|
||||
const [, major, minor, suffix = ''] = gemini;
|
||||
if (!major || !minor) return null;
|
||||
return `gemini-${major}.${minor}${suffix.replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
const minimax = /^minimax_m(\d+)_(\d+)(.*)$/i.exec(rawId);
|
||||
if (minimax) {
|
||||
const [, major, minor, suffix = ''] = minimax;
|
||||
if (!major || !minor) return null;
|
||||
return `minimax-m${major}.${minor}${suffix.replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isVelaChatModelId(modelId: string): boolean {
|
||||
// Temporary chat-surface guard: Vela already lists media-generation models,
|
||||
// but Open Design's AMR runtime currently drives only chat completions.
|
||||
// Remove this filter when AMR grows first-class image/video execution.
|
||||
const id = modelId.toLowerCase();
|
||||
if (id.startsWith('gpt-image-')) return false;
|
||||
if (id.startsWith('seedance-')) return false;
|
||||
if (id.startsWith('doubao-seedance-')) return false;
|
||||
if (id.startsWith('veo-')) return false;
|
||||
if (id.startsWith('imagen-')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseVelaModels(stdout: string): RuntimeModelOption[] {
|
||||
const seen = new Set<string>();
|
||||
const models: RuntimeModelOption[] = [];
|
||||
for (const line of String(stdout || '').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const [rawId] = trimmed.split(/\s+/);
|
||||
if (!rawId) continue;
|
||||
const id = normalizeVelaModelId(rawId);
|
||||
if (!id || seen.has(id) || !isVelaChatModelId(id)) continue;
|
||||
seen.add(id);
|
||||
models.push({ id, label: id });
|
||||
}
|
||||
return orderAmrChatModels(models);
|
||||
}
|
||||
|
||||
function orderAmrChatModels(
|
||||
models: RuntimeModelOption[],
|
||||
): RuntimeModelOption[] {
|
||||
return models
|
||||
.map((model, index) => ({ model, index }))
|
||||
.sort((a, b) => {
|
||||
const aRank =
|
||||
PREFERRED_AMR_CHAT_MODEL_RANK.get(a.model.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const bRank =
|
||||
PREFERRED_AMR_CHAT_MODEL_RANK.get(b.model.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return aRank - bRank || a.index - b.index;
|
||||
})
|
||||
.map(({ model }) => model);
|
||||
}
|
||||
|
||||
export const amrAgentDef = {
|
||||
id: 'amr',
|
||||
name: 'AMR',
|
||||
bin: 'vela',
|
||||
versionArgs: ['--version'],
|
||||
fetchModels: async (resolvedBin, env) => {
|
||||
const { stdout } = await execAgentFile(resolvedBin, ['models'], {
|
||||
env,
|
||||
timeout: 10_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return parseVelaModels(String(stdout));
|
||||
},
|
||||
// Fail closed when Vela's live catalog is unavailable. Stale static
|
||||
// fallbacks let users select models that link/opencode no longer accepts.
|
||||
fallbackModels: [] as RuntimeModelOption[],
|
||||
buildArgs: () => ['agent', 'run', '--runtime', 'opencode'],
|
||||
streamFormat: 'acp-json-rpc',
|
||||
// Daemon-process env override for emergency operator pinning. Normal UI
|
||||
// selection comes from the live `vela models` catalog and is preflighted
|
||||
// before spawn.
|
||||
defaultModelEnvVar: 'VELA_DEFAULT_MODEL',
|
||||
} satisfies RuntimeAgentDef;
|
||||
|
|
@ -44,6 +44,16 @@ export function parseCodexDebugModels(stdout: string): RuntimeModelOption[] | nu
|
|||
return out.length > 1 ? out : null;
|
||||
}
|
||||
|
||||
export function codexNeedsDangerFullAccessSandbox(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
if (platform === 'win32') return true;
|
||||
// WSL reports `linux` but Codex still hits the Windows read-only
|
||||
// workspace-write sandbox path when launched from there (#2834).
|
||||
return Boolean(env.WSL_DISTRO_NAME?.trim());
|
||||
}
|
||||
|
||||
export const codexAgentDef = {
|
||||
id: 'codex',
|
||||
name: 'Codex CLI',
|
||||
|
|
@ -98,8 +108,8 @@ export const codexAgentDef = {
|
|||
// back to a coarse policy that rejects any shell. macOS (Seatbelt)
|
||||
// and Linux (Landlock+seccomp) keep workspace-write because their
|
||||
// sandbox enforcement permits shell while restricting writes.
|
||||
const isWindows = process.platform === 'win32';
|
||||
const args = isWindows
|
||||
const needsDangerFullAccess = codexNeedsDangerFullAccessSandbox();
|
||||
const args = needsDangerFullAccess
|
||||
? ['exec', '--json', '--skip-git-repo-check', '--sandbox', 'danger-full-access']
|
||||
: [
|
||||
'exec',
|
||||
|
|
@ -110,6 +120,9 @@ export const codexAgentDef = {
|
|||
'-c',
|
||||
'sandbox_workspace_write.network_access=true',
|
||||
];
|
||||
// Newer Codex builds honor permissions config over legacy sandbox
|
||||
// flags; without this, Windows/WSL launches can stay read-only (#2834).
|
||||
args.push('-c', 'default_permissions=":workspace"');
|
||||
if (process.env.OD_CODEX_DISABLE_PLUGINS === '1') {
|
||||
args.push('--disable', 'plugins');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@ export const deepseekAgentDef = {
|
|||
id: 'deepseek',
|
||||
name: 'DeepSeek TUI',
|
||||
// The `deepseek` dispatcher owns the `exec` / `--auto` subcommands and
|
||||
// delegates to a sibling `deepseek-tui` runtime binary at exec time.
|
||||
// Upstream documents both binaries as required (npm and cargo paths
|
||||
// install them together), so a host with only `deepseek-tui` on PATH
|
||||
// isn't a supported install — and `deepseek-tui` itself doesn't accept
|
||||
// the argv shape `buildArgs` produces (`exec --auto <prompt>`). We only
|
||||
// probe the dispatcher; advertising availability via a `deepseek-tui`
|
||||
// fallback would surface the agent as runnable but make `/api/chat`
|
||||
// exit immediately on the first prompt.
|
||||
// delegates to a sibling TUI runtime binary at exec time. Upstream also
|
||||
// ships the same dispatcher as `codewhale` after the CodeWhale rename
|
||||
// (issue #2983). The companion `deepseek-tui` / `codewhale-tui` runtime
|
||||
// is not probed here — it does not accept the argv shape `buildArgs`
|
||||
// produces (`exec --auto <prompt>`).
|
||||
bin: 'deepseek',
|
||||
fallbackBins: ['codewhale'],
|
||||
versionArgs: ['--version'],
|
||||
// No `models` subcommand that prints a clean id-per-line list; the
|
||||
// canonical model ids for DeepSeek V4 are documented in the README,
|
||||
|
|
|
|||
70
apps/daemon/src/runtimes/defs/reasonix.ts
Normal file
70
apps/daemon/src/runtimes/defs/reasonix.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { detectAcpModels, DEFAULT_MODEL_OPTION } from './shared.js';
|
||||
import type { RuntimeAgentDef } from '../types.js';
|
||||
|
||||
// Design instructions injected into Reasonix's ACP system prompt via
|
||||
// REASONIX_ACP_SYSTEM_APPEND. This ensures the model follows Open Design's
|
||||
// design workflow (artifact output, design system, skill instructions)
|
||||
// instead of treating every request as a pure coding task.
|
||||
const DESIGN_INSTRUCTIONS = `# Open Design integration — MUST follow
|
||||
|
||||
You are running inside Open Design, a design tool. The user message contains
|
||||
design context (system prompt, skill instructions, design system tokens).
|
||||
Follow these rules:
|
||||
|
||||
1. **Output format**: Wrap your HTML output in <artifact> tags:
|
||||
<artifact>
|
||||
<!DOCTYPE html>
|
||||
<html>...</html>
|
||||
</artifact>
|
||||
|
||||
2. **Design system**: The user message includes a design system with colors,
|
||||
typography, spacing, and component patterns. Apply them consistently.
|
||||
|
||||
3. **Skill workflow**: The user message includes a skill (SKILL.md) with
|
||||
specific workflow instructions. Follow the skill's steps in order.
|
||||
|
||||
4. **No code fences**: Do NOT wrap HTML in \`\`\`html code fences.
|
||||
Output raw HTML inside <artifact> tags only.
|
||||
|
||||
5. **Single file**: Output a complete, self-contained HTML file with all
|
||||
CSS and JS inline. No external dependencies.
|
||||
|
||||
6. **Language**: Match the language of the user's prompt.`;
|
||||
|
||||
export const reasonixAgentDef = {
|
||||
id: 'reasonix',
|
||||
name: 'DeepSeek Reasonix',
|
||||
bin: 'reasonix',
|
||||
fallbackBins: ['dsnix'],
|
||||
versionArgs: ['--version'],
|
||||
fetchModels: async (resolvedBin, env) =>
|
||||
detectAcpModels({
|
||||
bin: resolvedBin,
|
||||
args: ['acp'],
|
||||
env,
|
||||
timeoutMs: 15_000,
|
||||
defaultModelOption: DEFAULT_MODEL_OPTION,
|
||||
}),
|
||||
// Reasonix ships an ACP (Agent Client Protocol) mode via `reasonix acp`
|
||||
// that speaks NDJSON JSON-RPC over stdio — the same wire format Hermes,
|
||||
// Kimi, Kilo, Kiro, and Vibe use. This avoids the Windows CreateProcess
|
||||
// ~32 KB command-line limit entirely: the prompt travels as a JSON-RPC
|
||||
// message body through stdin, not as a positional argv entry.
|
||||
buildArgs: () => ['acp'],
|
||||
streamFormat: 'acp-json-rpc',
|
||||
mcpDiscovery: 'mature-acp',
|
||||
externalMcpInjection: 'acp-merge',
|
||||
// Inject design instructions into Reasonix's system prompt via env var.
|
||||
// Reasonix's ACP code reads REASONIX_ACP_SYSTEM_APPEND and appends it
|
||||
// to the code system prompt, so the model sees both coding + design rules.
|
||||
env: {
|
||||
REASONIX_ACP_SYSTEM_APPEND: DESIGN_INSTRUCTIONS,
|
||||
},
|
||||
fallbackModels: [
|
||||
DEFAULT_MODEL_OPTION,
|
||||
{ id: 'deepseek-v4-pro', label: 'deepseek-v4-pro' },
|
||||
{ id: 'deepseek-v4-flash', label: 'deepseek-v4-flash' },
|
||||
],
|
||||
installUrl: 'https://github.com/esengine/DeepSeek-Reasonix',
|
||||
docsUrl: 'https://esengine.github.io/DeepSeek-Reasonix/',
|
||||
} satisfies RuntimeAgentDef;
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
import path from 'node:path';
|
||||
|
||||
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
|
||||
import { expandConfiguredEnv } from './paths.js';
|
||||
import { resolveAmrOpenCodeExecutable } from './executables.js';
|
||||
import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
|
||||
|
||||
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
|
||||
|
||||
|
|
@ -32,11 +37,29 @@ export function spawnEnvForAgent(
|
|||
agentId: string,
|
||||
baseEnv: RuntimeEnvMap,
|
||||
configuredEnv: unknown = {},
|
||||
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...baseEnv,
|
||||
...expandConfiguredEnv(configuredEnv),
|
||||
};
|
||||
const env = mergeProxyAwareEnv(
|
||||
process.platform,
|
||||
systemProxyEnv,
|
||||
baseEnv,
|
||||
expandConfiguredEnv(configuredEnv),
|
||||
);
|
||||
if (agentId === 'amr') {
|
||||
Object.assign(env, amrVelaProfileEnv(env));
|
||||
if (!env.OPENCODE_TEST_HOME?.trim() && env.OD_DATA_DIR?.trim()) {
|
||||
env.OPENCODE_TEST_HOME = path.join(
|
||||
env.OD_DATA_DIR.trim(),
|
||||
'amr',
|
||||
'opencode-home',
|
||||
);
|
||||
}
|
||||
if (!env.VELA_OPENCODE_BIN?.trim()) {
|
||||
const opencodeBin = resolveAmrOpenCodeExecutable(env);
|
||||
if (opencodeBin) env.VELA_OPENCODE_BIN = opencodeBin;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
if (agentId === 'claude') {
|
||||
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
|
||||
return env;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { expandHomePath } from './paths.js';
|
|||
import type { RuntimeAgentDef } from './types.js';
|
||||
|
||||
const AGENT_BIN_ENV_KEYS = new Map<string, string>([
|
||||
['amr', 'VELA_BIN'],
|
||||
['aider', 'AIDER_BIN'],
|
||||
['claude', 'CLAUDE_BIN'],
|
||||
['codex', 'CODEX_BIN'],
|
||||
['copilot', 'COPILOT_BIN'],
|
||||
|
|
@ -22,6 +24,7 @@ const AGENT_BIN_ENV_KEYS = new Map<string, string>([
|
|||
['pi', 'PI_BIN'],
|
||||
['qoder', 'QODER_BIN'],
|
||||
['qwen', 'QWEN_BIN'],
|
||||
['reasonix', 'REASONIX_BIN'],
|
||||
['trae-cli', 'TRAE_CLI_BIN'],
|
||||
['vibe', 'VIBE_BIN'],
|
||||
]);
|
||||
|
|
@ -100,18 +103,7 @@ function looksExecutableOnWindows(filePath: string): boolean {
|
|||
return executableExts.includes(ext);
|
||||
}
|
||||
|
||||
// Resolve the first available binary for an agent definition. Tries
|
||||
// `def.bin` first, then walks `def.fallbackBins` in order. Used for
|
||||
// agents whose forks ship under a different binary name but speak the
|
||||
// exact same CLI (Claude Code → OpenClaude, issue #235). Returns null
|
||||
// when no candidate is on PATH.
|
||||
function configuredExecutableOverride(
|
||||
def: RuntimeAgentDef,
|
||||
configuredEnv: Record<string, string> = {},
|
||||
): string | null {
|
||||
const envKey = AGENT_BIN_ENV_KEYS.get(def?.id);
|
||||
if (!envKey) return null;
|
||||
const raw = configuredEnv?.[envKey];
|
||||
function executableFilePath(raw: string | undefined): string | null {
|
||||
if (typeof raw !== 'string' || raw.trim().length === 0) return null;
|
||||
const expanded = expandHomePath(raw.trim());
|
||||
if (!path.isAbsolute(expanded)) return null;
|
||||
|
|
@ -128,6 +120,104 @@ function configuredExecutableOverride(
|
|||
}
|
||||
}
|
||||
|
||||
// Resolve the first available binary for an agent definition. Tries
|
||||
// `def.bin` first, then walks `def.fallbackBins` in order. Used for
|
||||
// agents whose forks ship under a different binary name but speak the
|
||||
// exact same CLI (Claude Code → OpenClaude, issue #235). Returns null
|
||||
// when no candidate is on PATH.
|
||||
function configuredExecutableOverride(
|
||||
def: RuntimeAgentDef,
|
||||
configuredEnv: Record<string, string> = {},
|
||||
): string | null {
|
||||
const envKey = AGENT_BIN_ENV_KEYS.get(def?.id);
|
||||
if (!envKey) return null;
|
||||
return executableFilePath(configuredEnv?.[envKey]);
|
||||
}
|
||||
|
||||
export function resolveAmrOpenCodeExecutable(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string | null {
|
||||
const configured = executableFilePath(env.VELA_OPENCODE_BIN);
|
||||
if (configured) return configured;
|
||||
// In packaged builds prefer the bundled companion under
|
||||
// `OD_RESOURCE_ROOT/bin/libexec/opencode/opencode` so a stale global
|
||||
// `opencode` on the user's PATH can't override the known-good build that
|
||||
// shipped with this app. PATH is only consulted as a last resort.
|
||||
const resourceRoot = (
|
||||
env.OD_RESOURCE_ROOT ?? process.env.OD_RESOURCE_ROOT
|
||||
)?.trim();
|
||||
if (resourceRoot) {
|
||||
const bundledDir = packagedVelaOpenCodeCompanionTree(resourceRoot);
|
||||
if (bundledDir) {
|
||||
const bundled = executableFilePath(
|
||||
path.join(
|
||||
bundledDir,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode',
|
||||
),
|
||||
);
|
||||
if (bundled) return bundled;
|
||||
}
|
||||
}
|
||||
return resolveOnPath('opencode-cli') ?? resolveOnPath('opencode');
|
||||
}
|
||||
|
||||
// `tools/pack/tests/resources.test.ts` ships the AMR OpenCode companion as a
|
||||
// `<resourceRoot>/bin/libexec/opencode/opencode` *executable file*, not just
|
||||
// the directory. Treating any directory there as a valid companion produces a
|
||||
// false-positive availability path: `detectAgents()` would surface AMR as
|
||||
// available even though the first real run can't launch (`vela` would spawn
|
||||
// a missing/non-executable inner binary). Verify the inner executable too.
|
||||
function packagedVelaOpenCodeCompanionTree(resourceRoot: string): string | null {
|
||||
const candidate = path.join(resourceRoot, 'bin', 'libexec', 'opencode');
|
||||
const exe = path.join(
|
||||
candidate,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode',
|
||||
);
|
||||
try {
|
||||
if (!statSync(candidate).isDirectory()) return null;
|
||||
if (!statSync(exe).isFile()) return null;
|
||||
if (process.platform === 'win32') {
|
||||
if (!looksExecutableOnWindows(exe)) return null;
|
||||
} else {
|
||||
accessSync(exe, constants.X_OK);
|
||||
}
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function packagedBuiltInExecutable(
|
||||
def: RuntimeAgentDef,
|
||||
configuredEnv: Record<string, string> = {},
|
||||
): string | null {
|
||||
if (def.id !== 'amr') return null;
|
||||
const resourceRoot = process.env.OD_RESOURCE_ROOT?.trim();
|
||||
if (!resourceRoot) return null;
|
||||
if (
|
||||
!resolveAmrOpenCodeExecutable({ ...process.env, ...configuredEnv }) &&
|
||||
!packagedVelaOpenCodeCompanionTree(resourceRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const candidate = path.join(
|
||||
resourceRoot,
|
||||
'bin',
|
||||
process.platform === 'win32' ? 'vela.exe' : 'vela',
|
||||
);
|
||||
try {
|
||||
if (!statSync(candidate).isFile()) return null;
|
||||
if (process.platform === 'win32') {
|
||||
if (!looksExecutableOnWindows(candidate)) return null;
|
||||
} else {
|
||||
accessSync(candidate, constants.X_OK);
|
||||
}
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAgentExecutable(
|
||||
def: RuntimeAgentDef,
|
||||
configuredEnv: Record<string, string> = {},
|
||||
|
|
@ -163,9 +253,10 @@ export function inspectAgentExecutableResolution(
|
|||
break;
|
||||
}
|
||||
}
|
||||
const builtInPath = packagedBuiltInExecutable(def, configuredEnv);
|
||||
return {
|
||||
configuredOverridePath,
|
||||
pathResolvedPath,
|
||||
selectedPath: configuredOverridePath || pathResolvedPath,
|
||||
selectedPath: configuredOverridePath || builtInPath || pathResolvedPath,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ const AGENT_INSTALL_LINKS: Record<
|
|||
string,
|
||||
{ installUrl?: string; docsUrl?: string }
|
||||
> = {
|
||||
amr: {
|
||||
installUrl: 'https://github.com/nexu-io/vela',
|
||||
docsUrl: 'https://github.com/nexu-io/open-design/blob/main/docs/new-agent-runtime-acp.md',
|
||||
},
|
||||
claude: {
|
||||
installUrl: 'https://docs.anthropic.com/en/docs/claude-code/setup',
|
||||
docsUrl: 'https://docs.anthropic.com/en/docs/claude-code',
|
||||
|
|
@ -68,8 +72,8 @@ const AGENT_INSTALL_LINKS: Record<
|
|||
docsUrl: 'https://github.com/mistralai/vibe-acp',
|
||||
},
|
||||
deepseek: {
|
||||
installUrl: 'https://github.com/deepseek-ai/DeepSeek-TUI',
|
||||
docsUrl: 'https://github.com/deepseek-ai/DeepSeek-TUI/blob/main/README.md',
|
||||
installUrl: 'https://github.com/Hmbown/CodeWhale',
|
||||
docsUrl: 'https://github.com/Hmbown/CodeWhale/blob/main/README.md',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -11,15 +11,18 @@ export const DEFAULT_MODEL_OPTION: RuntimeModelOption = {
|
|||
// trust any value present in the static fallback. A model that's neither
|
||||
// gets rejected so a stale or hostile value can't smuggle arbitrary flags.
|
||||
const liveModelCache = new Map<string, Set<string>>();
|
||||
const liveModelOrder = new Map<string, string[]>();
|
||||
|
||||
export function rememberLiveModels(agentId: string, models: RuntimeModelOption[]) {
|
||||
if (!Array.isArray(models)) return;
|
||||
const ids = models
|
||||
.map((m) => m && m.id)
|
||||
.filter((id) => typeof id === 'string');
|
||||
liveModelCache.set(
|
||||
agentId,
|
||||
new Set(
|
||||
models.map((m) => m && m.id).filter((id) => typeof id === 'string'),
|
||||
),
|
||||
new Set(ids),
|
||||
);
|
||||
liveModelOrder.set(agentId, ids);
|
||||
}
|
||||
|
||||
export function isKnownModel(def: RuntimeAgentDef, modelId: string | null | undefined) {
|
||||
|
|
@ -32,6 +35,37 @@ export function isKnownModel(def: RuntimeAgentDef, modelId: string | null | unde
|
|||
return false;
|
||||
}
|
||||
|
||||
// Some adapters reject the synthetic `'default'` model id (e.g. AMR / vela,
|
||||
// which requires an explicit `session/set_model` before `session/prompt`).
|
||||
// Those defs declare it by omitting DEFAULT_MODEL_OPTION from
|
||||
// `fallbackModels` entirely. When the chat run produces a null or 'default'
|
||||
// model for one of those adapters, prefer the first model from the live list
|
||||
// last surfaced to the UI, then fall back to the def's first concrete fallback
|
||||
// id so the spawn layer always has a real model to forward.
|
||||
// Defs that DO list 'default' (the common case) are left untouched.
|
||||
export function resolveModelForAgent(
|
||||
def: RuntimeAgentDef,
|
||||
resolved: string | null,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string | null {
|
||||
if (resolved && resolved !== 'default') return resolved;
|
||||
// Daemon-process env override (e.g. VELA_DEFAULT_MODEL for AMR). Lets an
|
||||
// operator pin a different fallback id without a code change when the
|
||||
// hardcoded default goes away upstream.
|
||||
if (def.defaultModelEnvVar) {
|
||||
const raw = env[def.defaultModelEnvVar];
|
||||
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
||||
}
|
||||
const fallbacks = Array.isArray(def.fallbackModels) ? def.fallbackModels : [];
|
||||
if (fallbacks.some((m) => m.id === 'default')) return resolved;
|
||||
const liveModels = liveModelOrder.get(def.id) ?? [];
|
||||
const firstLive = liveModels[0];
|
||||
if (firstLive) return firstLive;
|
||||
if (fallbacks.length === 0) return resolved;
|
||||
const firstFallback = fallbacks[0];
|
||||
return firstFallback ? firstFallback.id : resolved;
|
||||
}
|
||||
|
||||
// Permit user-typed model ids that didn't appear in either the live
|
||||
// listing or the static fallback (e.g. the user is on a brand-new model
|
||||
// the CLI's `models` command hasn't surfaced yet). The CLI gets the value
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { amrAgentDef } from './defs/amr.js';
|
||||
import { claudeAgentDef } from './defs/claude.js';
|
||||
import { codexAgentDef } from './defs/codex.js';
|
||||
import { devinAgentDef } from './defs/devin.js';
|
||||
|
|
@ -16,10 +17,13 @@ import { kiroAgentDef } from './defs/kiro.js';
|
|||
import { kiloAgentDef } from './defs/kilo.js';
|
||||
import { vibeAgentDef } from './defs/vibe.js';
|
||||
import { deepseekAgentDef } from './defs/deepseek.js';
|
||||
import { aiderAgentDef } from './defs/aider.js';
|
||||
import { reasonixAgentDef } from './defs/reasonix.js';
|
||||
import { readLocalAgentProfileDefs as readLocalAgentProfileDefsFromFile } from './local-profiles.js';
|
||||
import type { RuntimeAgentDef } from './types.js';
|
||||
|
||||
const BASE_AGENT_DEFS: RuntimeAgentDef[] = [
|
||||
amrAgentDef,
|
||||
claudeAgentDef,
|
||||
codexAgentDef,
|
||||
devinAgentDef,
|
||||
|
|
@ -38,6 +42,8 @@ const BASE_AGENT_DEFS: RuntimeAgentDef[] = [
|
|||
kiloAgentDef,
|
||||
vibeAgentDef,
|
||||
deepseekAgentDef,
|
||||
aiderAgentDef,
|
||||
reasonixAgentDef,
|
||||
];
|
||||
|
||||
export function readLocalAgentProfileDefs(
|
||||
|
|
|
|||
|
|
@ -101,6 +101,15 @@ export type RuntimeAgentDef = {
|
|||
| 'opencode-env-content';
|
||||
installUrl?: string;
|
||||
docsUrl?: string;
|
||||
// Optional name of a daemon-process environment variable that overrides
|
||||
// the default model id when the chat run reaches the spawn layer with
|
||||
// null or the synthetic 'default'. Used by adapters whose CLI rejects
|
||||
// 'default' (e.g. AMR / vela) so an operator can swap the hardcoded
|
||||
// fallback without a code change — set the env var on the daemon
|
||||
// process when launching `tools-dev` / `od` daemon. The value must be
|
||||
// present in the daemon's `process.env`; Settings-UI per-agent env
|
||||
// values only reach the spawned child and are NOT consulted here.
|
||||
defaultModelEnvVar?: string;
|
||||
};
|
||||
|
||||
export type DetectedAgent = Omit<
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,5 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
|
|
@ -7,6 +9,7 @@ import {
|
|||
type DaemonStatusSnapshot,
|
||||
type DesktopExportPdfInput,
|
||||
type DesktopExportPdfResult,
|
||||
type MintImportTokenResult,
|
||||
type SidecarStamp,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import {
|
||||
|
|
@ -18,7 +21,13 @@ import {
|
|||
} from "@open-design/sidecar";
|
||||
|
||||
import { startDaemonRuntime, type StartedDaemonRuntime } from "../daemon-startup.js";
|
||||
import { isDesktopAuthGateActive, setDesktopAuthSecret } from "../desktop-auth.js";
|
||||
import {
|
||||
getDesktopAuthSecret,
|
||||
isDesktopAuthGateActive,
|
||||
isDesktopAuthRegistered,
|
||||
setDesktopAuthSecret,
|
||||
signDesktopImportToken,
|
||||
} from "../desktop-auth.js";
|
||||
|
||||
/**
|
||||
* PR #974 round 6 (mrcfps): pure wrapper that overlays the live
|
||||
|
|
@ -36,6 +45,7 @@ export function withCurrentDesktopAuthGate(snapshot: DaemonStatusSnapshot): Daem
|
|||
const DAEMON_PORT_ENV = SIDECAR_ENV.DAEMON_PORT;
|
||||
const WEB_PORT_ENV = SIDECAR_ENV.WEB_PORT;
|
||||
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
|
||||
const DESKTOP_IMPORT_TOKEN_TTL_MS = 60_000;
|
||||
|
||||
export type DaemonSidecarHandle = {
|
||||
status(): Promise<DaemonStatusSnapshot>;
|
||||
|
|
@ -78,6 +88,33 @@ function attachParentMonitor(stop: () => Promise<void>): void {
|
|||
timer.unref();
|
||||
}
|
||||
|
||||
export function mintImportTokenForCli(baseDir: string): MintImportTokenResult {
|
||||
if (!isDesktopAuthGateActive()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "DESKTOP_AUTH_INACTIVE",
|
||||
message: "desktop import auth gate is inactive",
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
const secret = getDesktopAuthSecret();
|
||||
if (secret == null || !isDesktopAuthRegistered()) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "DESKTOP_AUTH_PENDING",
|
||||
message: "desktop auth required but secret not yet registered",
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
const nonce = randomBytes(16).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + DESKTOP_IMPORT_TOKEN_TTL_MS).toISOString();
|
||||
return {
|
||||
ok: true,
|
||||
expiresAt,
|
||||
token: signDesktopImportToken(secret, baseDir, { nonce, exp: expiresAt }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function startDaemonSidecar(runtime: SidecarRuntimeContext<SidecarStamp>): Promise<DaemonSidecarHandle> {
|
||||
const serverHandle: StartedDaemonRuntime = await startDaemonRuntime({
|
||||
desktopPdfExporter: async (input: DesktopExportPdfInput): Promise<DesktopExportPdfResult> => {
|
||||
|
|
@ -152,6 +189,8 @@ export async function startDaemonSidecar(runtime: SidecarRuntimeContext<SidecarS
|
|||
// renderer→arbitrary-baseDir→shell.openPath bypass.
|
||||
setDesktopAuthSecret(Buffer.from(request.input.secret, "base64"));
|
||||
return { accepted: true };
|
||||
case SIDECAR_MESSAGES.MINT_IMPORT_TOKEN:
|
||||
return mintImportTokenForCli(request.input.baseDir);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -284,7 +284,8 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
res.setHeader('Access-Control-Allow-Origin', 'null');
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.sendFile(sheet.absPath);
|
||||
const buf = await fs.promises.readFile(sheet.absPath);
|
||||
res.send(buf);
|
||||
} catch (err: any) {
|
||||
res.status(500).type('text/plain').send(String(err));
|
||||
}
|
||||
|
|
@ -502,7 +503,7 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
// The example response above rewrites `./assets/<file>` into a request
|
||||
// against this route; we still keep the on-disk paths human-friendly so
|
||||
// contributors can preview `example.html` straight from disk.
|
||||
app.get('/api/skills/:id/assets/*', async (req, res) => {
|
||||
app.get('/api/skills/:id/assets/*splat', async (req, res) => {
|
||||
try {
|
||||
// Same rationale as /example above — assets need to resolve whether
|
||||
// the owning skill folder lives under skills/ or design-templates/.
|
||||
|
|
@ -511,7 +512,8 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
if (!skill) {
|
||||
return res.status(404).type('text/plain').send('skill not found');
|
||||
}
|
||||
const relPath = String((req.params as any)[0] || '');
|
||||
const splatParam = (req.params as { splat?: string | string[] }).splat;
|
||||
const relPath = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam || '');
|
||||
const assetsRoot = path.resolve(skill.dir, 'assets');
|
||||
const target = path.resolve(assetsRoot, relPath);
|
||||
if (target !== assetsRoot && !target.startsWith(assetsRoot + path.sep)) {
|
||||
|
|
@ -526,7 +528,7 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
if (req.headers.origin === 'null') {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
res.type(mimeFor(target)).sendFile(target);
|
||||
await res.type(mimeFor(target)).sendFile(target);
|
||||
} catch (err: any) {
|
||||
res.status(500).type('text/plain').send(String(err));
|
||||
}
|
||||
|
|
@ -580,7 +582,7 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
}
|
||||
const systems = await listAllDesignSystems();
|
||||
const designSystemId = path.basename(fs.realpathSync.native(result.dir));
|
||||
const designSystem = systems.find((system) => system.id === designSystemId);
|
||||
const designSystem = findUserDesignSystemInCatalog(systems, designSystemId);
|
||||
if (!designSystem) {
|
||||
return res.status(500).json({ error: `installed design system was not found in catalog: ${result.dir}` });
|
||||
}
|
||||
|
|
@ -636,10 +638,10 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
...(typeof body.name === 'string' ? { name: body.name } : {}),
|
||||
...(importMode ? { importMode } : {}),
|
||||
...(craftApplies ? { craftApplies } : {}),
|
||||
reservedIds: before.map((system) => system.id),
|
||||
reservedIds: designSystemDirIdsFromCatalog(before),
|
||||
});
|
||||
const systems = await listAllDesignSystems();
|
||||
const designSystem = systems.find((system) => system.id === result.id);
|
||||
const designSystem = findUserDesignSystemInCatalog(systems, result.id);
|
||||
if (!designSystem) {
|
||||
return sendApiError(
|
||||
res,
|
||||
|
|
@ -679,11 +681,11 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
...(typeof body.branch === 'string' ? { branch: body.branch } : {}),
|
||||
...(importMode ? { importMode } : {}),
|
||||
...(craftApplies ? { craftApplies } : {}),
|
||||
reservedIds: before.map((system) => system.id),
|
||||
reservedIds: designSystemDirIdsFromCatalog(before),
|
||||
},
|
||||
);
|
||||
const systems = await listAllDesignSystems();
|
||||
const designSystem = systems.find((system) => system.id === result.id);
|
||||
const designSystem = findUserDesignSystemInCatalog(systems, result.id);
|
||||
if (!designSystem) {
|
||||
return sendApiError(
|
||||
res,
|
||||
|
|
@ -722,6 +724,24 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
|
|||
|
||||
}
|
||||
|
||||
function userDesignSystemCatalogId(dirId: string): string {
|
||||
return `user:${dirId}`;
|
||||
}
|
||||
|
||||
function findUserDesignSystemInCatalog<T extends { id: string }>(
|
||||
systems: T[],
|
||||
dirId: string,
|
||||
): T | undefined {
|
||||
const catalogId = userDesignSystemCatalogId(dirId);
|
||||
return systems.find((system) => system.id === catalogId || system.id === dirId);
|
||||
}
|
||||
|
||||
function designSystemDirIdsFromCatalog(systems: Array<{ id: string }>): string[] {
|
||||
return systems.map((system) =>
|
||||
system.id.startsWith('user:') ? system.id.slice('user:'.length) : system.id,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDesignSystemImportMode(value: unknown): 'normalized' | 'hybrid' | 'verbatim' | undefined {
|
||||
return value === 'normalized' || value === 'hybrid' || value === 'verbatim' ? value : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const CHAT_TOOL_ENDPOINTS = [
|
|||
'/api/tools/connectors/list',
|
||||
'/api/tools/connectors/execute',
|
||||
'/api/tools/design-systems/read',
|
||||
'/api/tools/media/generate',
|
||||
] as const;
|
||||
|
||||
export const CHAT_TOOL_OPERATIONS = [
|
||||
|
|
@ -20,6 +21,7 @@ export const CHAT_TOOL_OPERATIONS = [
|
|||
'connectors:list',
|
||||
'connectors:execute',
|
||||
'design-systems:read',
|
||||
'media:generate',
|
||||
] as const;
|
||||
|
||||
export type ToolEndpoint = (typeof CHAT_TOOL_ENDPOINTS)[number] | (string & {});
|
||||
|
|
|
|||
21
apps/daemon/src/user-facing-agent-label.ts
Normal file
21
apps/daemon/src/user-facing-agent-label.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { basename } from 'node:path';
|
||||
|
||||
/**
|
||||
* Labels surfaced in chat status / diagnostics. Never expose resolved
|
||||
* executable paths — packaged installs leak app bundle roots and custom
|
||||
* home directories (issue #2874).
|
||||
*/
|
||||
export function userFacingAgentLabel(
|
||||
agentId: string | null | undefined,
|
||||
resolvedBin: string | null | undefined,
|
||||
): string {
|
||||
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
if (normalizedAgentId) return normalizedAgentId;
|
||||
|
||||
if (typeof resolvedBin === 'string' && resolvedBin.trim()) {
|
||||
const base = basename(resolvedBin.trim().replace(/\\/g, '/')).replace(/\.(exe|cmd|bat)$/i, '');
|
||||
if (base) return base;
|
||||
}
|
||||
|
||||
return 'agent';
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
|
||||
import type { Express } from 'express';
|
||||
|
||||
import { proxyDispatcherRequestInit } from './connectionTest.js';
|
||||
import { mediaConfigDir, resolveProviderConfig } from './media-config.js';
|
||||
import { PendingAuthCache } from './mcp-oauth.js';
|
||||
import { beginXAIAuth, completeXAIAuth } from './xai-oauth.js';
|
||||
|
|
@ -44,6 +45,12 @@ import type { RouteDeps } from './server-context.js';
|
|||
|
||||
export interface RegisterXaiRoutesDeps extends RouteDeps<'http' | 'paths'> {}
|
||||
|
||||
function fetchWithRequestInit(
|
||||
requestInit: Pick<RequestInit, 'dispatcher'>,
|
||||
): typeof fetch {
|
||||
return (input, init) => fetch(input, { ...init, ...requestInit });
|
||||
}
|
||||
|
||||
export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
|
||||
const { isLocalSameOrigin, resolvedPortRef } = ctx.http;
|
||||
const { PROJECT_ROOT } = ctx.paths;
|
||||
|
|
@ -76,11 +83,13 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
|
|||
console.warn(`[xai-oauth] callback failed: ${outcome.error}`);
|
||||
return;
|
||||
}
|
||||
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
|
||||
try {
|
||||
const tokenResp = await completeXAIAuth({
|
||||
pending: pendingAuth,
|
||||
state: outcome.state,
|
||||
code: outcome.code,
|
||||
fetchImpl: fetchWithRequestInit(proxyDispatcher.requestInit),
|
||||
});
|
||||
const stored: StoredXAIToken = {
|
||||
accessToken: tokenResp.access_token,
|
||||
|
|
@ -97,6 +106,8 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
|
|||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[xai-oauth] token exchange failed:', msg);
|
||||
} finally {
|
||||
await proxyDispatcher.close();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -149,11 +160,13 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
|
|||
.status(400)
|
||||
.json({ error: 'state and code are required' });
|
||||
}
|
||||
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
|
||||
try {
|
||||
const tokenResp = await completeXAIAuth({
|
||||
pending: pendingAuth,
|
||||
state,
|
||||
code,
|
||||
fetchImpl: fetchWithRequestInit(proxyDispatcher.requestInit),
|
||||
});
|
||||
const stored: StoredXAIToken = {
|
||||
accessToken: tokenResp.access_token,
|
||||
|
|
@ -178,6 +191,8 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
|
|||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[xai-oauth] manual complete failed:', msg);
|
||||
res.status(400).json({ error: msg });
|
||||
} finally {
|
||||
await proxyDispatcher.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -296,6 +311,8 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
|
|||
};
|
||||
|
||||
let resp: Response;
|
||||
let text: string;
|
||||
const proxyDispatcher = proxyDispatcherRequestInit(process.env);
|
||||
try {
|
||||
resp = await fetch(`${baseUrl}/responses`, {
|
||||
method: 'POST',
|
||||
|
|
@ -304,13 +321,16 @@ export function registerXaiRoutes(app: Express, ctx: RegisterXaiRoutesDeps) {
|
|||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
...proxyDispatcher.requestInit,
|
||||
});
|
||||
text = await resp.text();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return res.status(502).json({ error: `xAI request failed: ${msg}` });
|
||||
} finally {
|
||||
await proxyDispatcher.close();
|
||||
}
|
||||
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) {
|
||||
return res
|
||||
.status(502)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue