Compare commits

...

14 Commits

Author SHA1 Message Date
hongjr03 530fcdd2b7 feat: migrate legacy projects through binding search 2026-07-11 23:33:23 +08:00
hongjr03 53998d2651 fix: enable proxy use in new silos 2026-07-11 15:07:28 +08:00
hongjr03 5b55cf18a8 Revert "fix: accept SDK provider capability headers"
This reverts commit e7ad5580ec.
2026-07-11 15:06:48 +08:00
hongjr03 b0d691d53f Revert "fix: pass provider capability as API key"
This reverts commit f065f9f978.
2026-07-11 15:06:48 +08:00
hongjr03 1f48c5b707 Revert "fix: provide capability for both SDK auth modes"
This reverts commit ebf870249f.
2026-07-11 15:06:48 +08:00
hongjr03 12a2f3117f Revert "fix: preserve run provider capability"
This reverts commit 63c86322de.
2026-07-11 15:06:48 +08:00
hongjr03 2ee84d9543 Revert "fix: carry provider capability in dedicated header"
This reverts commit 3087132083.
2026-07-11 15:06:47 +08:00
hongjr03 3087132083 fix: carry provider capability in dedicated header 2026-07-11 15:02:51 +08:00
hongjr03 63c86322de fix: preserve run provider capability 2026-07-11 15:00:34 +08:00
hongjr03 ebf870249f fix: provide capability for both SDK auth modes 2026-07-11 14:59:00 +08:00
hongjr03 f065f9f978 fix: pass provider capability as API key 2026-07-11 14:57:22 +08:00
hongjr03 e7ad5580ec fix: accept SDK provider capability headers 2026-07-11 14:55:29 +08:00
hongjr03 19d942e812 feat: automate managed silo provisioning 2026-07-11 14:53:03 +08:00
hongjr03 e5e923dd34 feat: add repeatable alpha silo setup 2026-07-11 14:30:50 +08:00
17 changed files with 1746 additions and 31 deletions
+133
View File
@@ -0,0 +1,133 @@
# New Alpha Silo runbook
Use this runbook for a new Organization. A Silo is not merely another row in
the existing database: it has an independent PostgreSQL role/database, Linux
service identity, systemd unit, secret directory/keyring, workspace, skill
store, loopback port, domain, Feishu app and provider credential.
The repeatable entry point is:
```sh
bash hub/deploy/new_silo.sh
```
After collecting the Organization inputs, the wizard shows the assigned
resources and asks once before applying them directly over SSH. The generated
bundle is only a root-secret-safe retry and audit checkpoint; the operator does
not execute it manually during the normal path.
It gathers values and writes a private deployment bundle below
`~/.cph-silo-plans/<instance-id>/`. The directory and all generated files are
mode `0700`/`0600`. Never commit, paste into chat, or copy that directory into
an immutable release. Run the wizard once per Organization; do not edit a copy
from another Organization.
## Inputs to collect
The wizard derives the instance/Organization id from the slug, uses the current
release and managed Alpha defaults, connects to the managed host (currently
`39.107.254.4`), derives
`https://<organization-slug>.educraft.paradigm-edu.net` from the wildcard DNS,
and allocates an unused loopback port plus short workspace path by inspecting
existing Silo environments, listening sockets and workspace paths over
read-only SSH. The Organization administrator supplies:
- Organization display name and slug;
- Feishu App ID and App Secret (the wizard resolves the bot Open ID);
- the first OWNER's Open ID and display name;
- an Organization-exclusive OpenRouter token.
Everything else is platform-managed or derived: instance/Organization id,
server, SSH settings, release, resource ceilings, database coordinates and
generated password, domain, port, workspace, provider/base URL, model/role,
curated skills, concurrency, request/file limits and the managed Mihomo proxy
environment. `NODE_USE_ENV_PROXY=1` is required on Node.js 24 so Hub's built-in
`fetch` actually uses that proxy; merely setting `HTTP_PROXY`/`HTTPS_PROXY` is
not sufficient.
The Feishu app is scoped to this Silo. OAuth users authenticated by that app are
automatically admitted to this Organization; OWNER remains the initial
privileged membership used for controlled administration and bootstrap. An
empty initial team list does not block the Alpha.
To target a replacement platform-managed host, the platform operator may set
`CPH_ALPHA_HOST`, `CPH_ALPHA_DEPLOY_USER`, `CPH_ALPHA_SSH_PORT` and
`CPH_ALPHA_BASE_DOMAIN` before running the wizard. These are fleet controls,
not Organization setup questions.
Obtain a person's Open ID from the Feishu user-get documentation page by
clicking the `user_id` value picker and selecting the person. Configure the
redirect URL shown by the generated `OPERATE.md`; it is required for first-time
OAuth admission.
## Host prerequisites
Before the first Silo on a host, install Node.js 24+, npm, rsync, PostgreSQL
server/client, `pg_isready`, systemd, bubblewrap, socat, `runuser`, `setpriv`,
`pg_dump`, tar, sha256sum, Nginx, Certbot, Typst and a compatible `cph` binary.
Configure outbound proxying independently at host/service level and verify both
GitHub and the selected model provider through it. The wizard does not install
or select proxy nodes.
Use a deployment account with only the required passwordless sudo operations.
The application itself always runs as the installer-created non-root
`cph-<instance-id>` user. PostgreSQL must have a separate login role and logical
database per Silo even when all Silo databases share one PostgreSQL server.
## Execute a generated bundle
Open the bundle's `OPERATE.md` and perform its numbered gates in order:
1. DNS, Feishu redirect URL, permissions and event subscription.
2. Dedicated PostgreSQL role and database.
3. Immutable release publication. Exit 78 is expected only when the first
installer call seeds this instance's keyring and environment template.
4. Root-owned secret installation and off-host keyring recovery copy.
5. Prisma migration, stopped service installation and idempotent bootstrap.
6. Explicit runtime role/skill installation.
7. Nginx/TLS, service start, internal/external health and Feishu acceptance.
8. First off-host backup.
Every command must fail fast. Do not add `|| true` around install, migration,
bootstrap, Nginx validation, health, or backup checks. If a check fails, retain
the unit logs and the exact failed stage before changing configuration.
## Default runtime role and skills
Roles and skills are dynamic Silo state, not release contents. The release
contains only the management CLI. Stage approved skill directories on the host
and install them with `agent_config.sh`; PostgreSQL records role definitions and
skill selections while the versioned skill content lives in the Silo state
directory and is included in backups.
For the current Alpha, upsert the default role with the agreed education
assistant system prompt, model selection, tools policy and approved skill list.
Keep the prompt in a root-controlled staging file, pass it via
`--system-prompt-file`, then verify with `agent_config.sh list`. Do not sync an
operator's entire personal skill directory: each enabled skill must be reviewed
and named explicitly. Typst being installed on the host and the Typst skill
being enabled are separate gates.
## Acceptance gate
A Silo is ready only when all of the following pass:
- its systemd service is active and both loopback and TLS health endpoints pass;
- startup preflight sees exactly the configured Organization plus active Feishu
and provider connections;
- OWNER completes OAuth and can interact with the bot;
- a non-OWNER completes OAuth and can interact with the same app/Organization;
- two Feishu groups bind distinct projects/sessions;
- a restart preserves persisted session cursor behavior;
- the configured provider/model succeeds through the host proxy;
- every enabled skill is listed, and a Typst task succeeds if Typst is enabled;
- the first business backup and separate recovery backup are stored off-host.
## Rollback boundary
For a failed code release, point only this instance back to the previous
immutable release and rerun its installer with the same instance parameters.
Do not roll back a database after migrations unless that release's documented
database compatibility permits it. Preserve the environment, keyring, database,
workspace and skill store. For destructive recovery, stop traffic and use the
separate restore procedure; never substitute another Silo's state.
+11
View File
@@ -1,5 +1,16 @@
# Alpha Silo service installation
For a brand-new Organization, start with the repeatable wizard and end-to-end
operator runbook:
```sh
bash hub/deploy/new_silo.sh
```
See [NEW_SILO_RUNBOOK.md](NEW_SILO_RUNBOOK.md). The remainder of this document
describes the individual installer and maintenance primitives used by the
generated bundle.
The supervised alpha runs one Organization per named Silo. The supported host
has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`,
bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`.
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Apply a bundle produced by new_silo.sh to the managed Alpha host.
set -euo pipefail
BUNDLE="${1:?usage: apply_new_silo.sh BUNDLE_DIR}"
ANSWERS="$BUNDLE/answers.env"
[ -f "$ANSWERS" ] || { echo "missing $ANSWERS" >&2; exit 1; }
value() { sed -n "s/^$1=//p" "$ANSWERS" | tail -n1; }
INSTANCE_ID="$(value INSTANCE_ID)"
ORG_ID="$(value ORGANIZATION_ID)"
HOST="$(value DEPLOY_HOST)"
SSH_USER="$(value DEPLOY_USER)"
SSH_PORT="$(value DEPLOY_SSH_PORT)"
SSH_KEY="$(value DEPLOY_SSH_KEY)"
BASE="$(value DEPLOY_BASE)"
RELEASE="$(value RELEASE_ID)"
HUB_PORT="$(value HUB_PORT)"
WORKSPACE="$(value WORKSPACE_ROOT)"
MEMORY="$(value MEMORY_MAX)"
CPU="$(value CPU_QUOTA)"
TASKS="$(value TASKS_MAX)"
DB_NAME="$(value DATABASE_NAME)"
DB_USER="$(value DATABASE_USER)"
DB_PASSWORD="$(value DATABASE_PASSWORD)"
PUBLIC_URL="$(value PUBLIC_BASE_URL)"
DOMAIN="${PUBLIC_URL#https://}"
HUB_DIR="$BASE/releases/$RELEASE/hub"
ENV_PATH="$BASE/.secrets/$INSTANCE_ID/platform.env"
KEYRING_PATH="$BASE/.secrets/$INSTANCE_ID/secret-keyring.json"
UNIT="cph-hub-$INSTANCE_ID.service"
SSH=(ssh -i "$SSH_KEY" -p "$SSH_PORT" -o BatchMode=yes "$SSH_USER@$HOST")
SCP=(scp -i "$SSH_KEY" -P "$SSH_PORT")
for file in platform.env bootstrap.json default-role-prompt.md; do
[ -f "$BUNDLE/$file" ] || { echo "missing bundle file: $file" >&2; exit 1; }
done
echo "[1/8] Verify immutable release"
"${SSH[@]}" "test -f '$BASE/releases/$RELEASE/.complete'"
echo "[2/8] Create dedicated database"
if ! "${SSH[@]}" "sudo -u postgres psql -Atqc \"select 1 from pg_database where datname='$DB_NAME'\"" | grep -qx 1; then
printf "CREATE ROLE %s LOGIN PASSWORD '%s';\nCREATE DATABASE %s OWNER %s;\n" \
"$DB_USER" "$DB_PASSWORD" "$DB_NAME" "$DB_USER" | "${SSH[@]}" sudo -u postgres psql -v ON_ERROR_STOP=1
fi
echo "[3/8] Seed instance keyring and service template"
set +e
"${SSH[@]}" "BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY' CPU_QUOTA='$CPU' TASKS_MAX='$TASKS' bash '$HUB_DIR/deploy/install_service.sh'"
status=$?
set -e
[ "$status" -eq 0 ] || [ "$status" -eq 78 ] || exit "$status"
echo "[4/8] Upload root-only configuration"
remote_stage="/root/.cph-bootstrap-$INSTANCE_ID"
"${SSH[@]}" "install -d -o root -g root -m 0700 '$remote_stage'"
"${SCP[@]}" "$BUNDLE/platform.env" "$BUNDLE/bootstrap.json" "$BUNDLE/default-role-prompt.md" "$SSH_USER@$HOST:$remote_stage/"
"${SSH[@]}" "install -o root -g root -m 0600 '$remote_stage/platform.env' '$ENV_PATH'; chmod 0600 '$remote_stage/bootstrap.json' '$remote_stage/default-role-prompt.md'"
echo "[5/8] Migrate, install, and bootstrap"
"${SSH[@]}" "set -euo pipefail; set -a; . '$ENV_PATH'; set +a; node '$HUB_DIR/node_modules/prisma/build/index.js' migrate deploy --schema '$HUB_DIR/prisma/schema.prisma'; BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY' CPU_QUOTA='$CPU' TASKS_MAX='$TASKS' bash '$HUB_DIR/deploy/install_service.sh'; node '$HUB_DIR/dist/deployment/bootstrap-silo-cli.js' --config-file '$remote_stage/bootstrap.json' --keyring-file '$KEYRING_PATH'"
echo "[6/8] Copy curated skills and configure default role"
"${SSH[@]}" "set -euo pipefail; stage='/var/lib/cph-hub/$INSTANCE_ID/state/operator-staging'; install -d -o cph-$INSTANCE_ID -g cph-$INSTANCE_ID -m 0700 \"\$stage/skills\"; install -o cph-$INSTANCE_ID -g cph-$INSTANCE_ID -m 0600 '$remote_stage/default-role-prompt.md' \"\$stage/default-role-prompt.md\"; for source in /var/lib/cph-hub/para-26071100/state/skills/versions/*; do name=\$(sed -n 's/^name: *//p' \"\$source/SKILL.md\" | head -n1); case \"\$name\" in outline|lesson-project|data-processing-spec|typst) cp -a \"\$source\" \"\$stage/skills/\$name\"; chown -R cph-$INSTANCE_ID:cph-$INSTANCE_ID \"\$stage/skills/\$name\";; esac; done; for name in outline lesson-project data-processing-spec typst; do INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' install-skill --organization '$ORG_ID' --source \"\$stage/skills/\$name\" --version 1; done; INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' upsert-role --organization '$ORG_ID' --role draft --label '智能助手' --system-prompt-file \"\$stage/default-role-prompt.md\" --tools-json null; INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' set-role-skills --organization '$ORG_ID' --role draft --skills outline,lesson-project,data-processing-spec,typst; rm -rf \"\$stage\""
echo "[7/8] Configure Nginx and TLS"
printf 'server { listen 80; listen [::]:80; server_name %s; client_max_body_size 2m; location / { proxy_pass http://127.0.0.1:%s; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } }\n' "$DOMAIN" "$HUB_PORT" | "${SSH[@]}" "install -o root -g root -m 0644 /dev/stdin '/etc/nginx/sites-available/$INSTANCE_ID'; ln -sfn '/etc/nginx/sites-available/$INSTANCE_ID' '/etc/nginx/sites-enabled/$INSTANCE_ID'; nginx -t; systemctl reload nginx; certbot --nginx --non-interactive --agree-tos --redirect --register-unsafely-without-email -d '$DOMAIN'"
echo "[8/8] Start and verify"
"${SSH[@]}" "systemctl enable --now '$UNIT'; systemctl is-active --quiet '$UNIT'; curl --fail --silent 'http://127.0.0.1:$HUB_PORT/api/healthz' >/dev/null; rm -f '$remote_stage/bootstrap.json'"
curl --fail --silent --show-error "$PUBLIC_URL/api/healthz" >/dev/null
echo "Deployed $INSTANCE_ID at $PUBLIC_URL"
@@ -0,0 +1,56 @@
#!/usr/bin/env node
import { readdir, readFile, realpath } from "node:fs/promises";
import { dirname, relative, resolve, sep } from "node:path";
const rootArgument = process.argv[2];
if (!rootArgument) throw new Error("usage: build_legacy_project_manifest.mjs <legacy-workspaces-root>");
const root = await realpath(rootArgument);
const projectFiles = await findProjectFiles(root);
const seenIds = new Set();
const manifest = [];
for (const projectFile of projectFiles) {
const metadata = JSON.parse(await readFile(projectFile, "utf8"));
if (typeof metadata.id !== "string" || metadata.id.trim() === "") {
throw new Error(`project metadata has no id: ${projectFile}`);
}
if (seenIds.has(metadata.id)) throw new Error(`duplicate project id: ${metadata.id}`);
seenIds.add(metadata.id);
if (typeof metadata.name !== "string" || metadata.name.trim() === "") {
throw new Error(`project metadata has no name: ${projectFile}`);
}
const projectRoot = dirname(projectFile);
const sourceRelativePath = relative(root, projectRoot).split(sep).join("/");
const physicalFolderPath = dirname(sourceRelativePath) === "."
? []
: dirname(sourceRelativePath).split("/");
if (metadata.folderPath !== undefined && (
!Array.isArray(metadata.folderPath)
|| metadata.folderPath.some((part) => typeof part !== "string")
|| JSON.stringify(metadata.folderPath) !== JSON.stringify(physicalFolderPath)
)) {
process.stderr.write(`[legacy-manifest] stale metadata folderPath; using physical path: ${projectFile}\n`);
}
manifest.push({
legacyId: metadata.id,
name: metadata.name,
folderPath: physicalFolderPath,
sourceRelativePath,
});
}
manifest.sort((left, right) => left.sourceRelativePath.localeCompare(right.sourceRelativePath, "zh-CN"));
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
async function findProjectFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const projectMetadata = entries.find((entry) => entry.isFile() && entry.name === "project.json");
if (projectMetadata !== undefined) return [resolve(directory, projectMetadata.name)];
const found = [];
for (const entry of entries) {
if (entry.name === ".trash") continue;
const path = resolve(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error(`legacy manifest rejects symbolic link: ${path}`);
if (entry.isDirectory()) found.push(...await findProjectFiles(path));
}
return found;
}
+412
View File
@@ -0,0 +1,412 @@
#!/usr/bin/env bash
#
# A wizard — walks a human through a manual procedure step by step.
# Generated by the /wizard skill.
#
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
# it. Author the per-step stages below the marker.
set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────
# Wizard library — delightful, consistent UX. Identical across every wizard.
# ──────────────────────────────────────────────────────────────────────────
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1)
else
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED=""
fi
# Author sets these two at the top of the stages section.
TOTAL_STAGES=0
TOTAL_MINUTES=0
_STAGE_INDEX=0
_MINUTES_ELAPSED=0
ENV_FILE="${ENV_FILE:-.env}"
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
WRITTEN_SECRET=() # secret NAMEs set this run
SKIPPED=() # things we couldn't do (e.g. gh missing)
# _clear — wipe the terminal so only the current step is on screen. No-op when
# output isn't a terminal, so piped logs stay readable.
_clear() {
[[ -t 1 ]] || return 0
if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi
}
# banner "Title" — opening frame: what this wizard does and how long it takes.
banner() {
_clear
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
printf '%s %s stages · about %s minutes%s\n\n' \
"$DIM" "$TOTAL_STAGES" "$TOTAL_MINUTES" "$RESET"
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
printf ' later — it remembers values already saved.%s\n' "$RESET"
pause "Ready to start?"
}
# stage "Name" <minutes> — clear the screen, then announce a stage and show
# progress + time remaining. Clearing keeps only the current step on screen.
stage() {
_clear
_STAGE_INDEX=$((_STAGE_INDEX + 1))
local remaining=$((TOTAL_MINUTES - _MINUTES_ELAPSED))
(( remaining < 0 )) && remaining=0
_MINUTES_ELAPSED=$((_MINUTES_ELAPSED + ${2:-0}))
printf '\n%s%s▸ Stage %s/%s · %s%s %s(~%s min left)%s\n' \
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET" "$DIM" "$remaining" "$RESET"
}
# say "..." — a plain instruction line.
say() { printf ' %s\n' "$1"; }
# step "..." — a numbered-feeling action the human takes in the browser.
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
# open_url URL — open in the human's browser, cross-platform incl. WSL.
open_url() {
local url="$1"
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
elif command -v open >/dev/null 2>&1; then open "$url"
else warn "couldn't open a browser — visit it manually: $url"; fi
} >/dev/null 2>&1 || warn "couldn't open a browser — visit it manually: $url"
}
# pause "msg" — wait for the human to confirm they've done the manual part.
pause() {
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
read -r _ || true
}
# confirm "question" — y/N gate; returns success on yes.
confirm() {
local reply=""
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
read -r reply || true
[[ "$reply" =~ ^[Yy] ]]
}
# _existing KEY — current value of KEY in ENV_FILE, if any.
_existing() {
[[ -f "$ENV_FILE" ]] || return 1
local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
printf '%s' "${line#*=}"
}
# ask KEY "Prompt" — read a value into $KEY. Offers the existing .env value as
# a default on re-runs (Enter keeps it). Visible input (non-secret).
ask() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -r input || true
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# ask_secret KEY "Prompt" — like ask, but input is hidden.
ask_secret() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -rs input || true
printf '\n'
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# write_env KEY VALUE — upsert KEY=VALUE into ENV_FILE (creates it; replaces
# any existing line). Idempotent.
write_env() {
local key="$1" value="$2" tmp
touch "$ENV_FILE"
tmp=$(mktemp)
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
printf '%s=%s\n' "$key" "$value" >> "$tmp"
mv "$tmp" "$ENV_FILE"
WRITTEN_ENV+=("$key")
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
}
# set_secret NAME VALUE — set a GitHub Actions repo secret via gh. Falls back
# to a warning (and records it) if gh is unavailable or unauthenticated.
set_secret() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
WRITTEN_SECRET+=("$name")
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
warn "skipped GitHub secret $name — gh not ready; set it later"
}
# set_var NAME VALUE — set a GitHub Actions repo variable (non-secret).
set_var() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub variable $name")
warn "skipped GitHub variable $name — gh not ready; set it later"
}
# finish — clear, then a closing summary of everything configured.
finish() {
_clear
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
if (( ${#SKIPPED[@]} )); then
printf '\n'; warn "still to do by hand:"
for s in "${SKIPPED[@]}"; do note " - $s"; done
fi
printf '\n'
}
# ──────────────────────────────────────────────────────────────────────────
# STAGES — author this section. One stage() per step the human takes.
# Replace the example below. Set the two totals to match the stages you write.
# ──────────────────────────────────────────────────────────────────────────
TOTAL_STAGES=7
TOTAL_MINUTES=35
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLAN_ROOT="${CPH_SILO_PLAN_ROOT:-$HOME/.cph-silo-plans}"
umask 077
require_value() {
local key="$1" value="$2"
[[ -n "$value" ]] || { warn "$key is required"; exit 1; }
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || {
warn "$key must be a single line"
exit 1
}
}
seed_default() {
local key="$1" value="$2"
_existing "$key" >/dev/null 2>&1 || write_env "$key" "$value"
}
capture() {
local key="$1" prompt="$2"
ask "$key" "$prompt"
require_value "$key" "${!key}"
write_env "$key" "${!key}"
}
capture_secret() {
local key="$1" prompt="$2"
ask_secret "$key" "$prompt"
require_value "$key" "${!key}"
write_env "$key" "${!key}"
}
allocate_host_slot() {
local allocation local_reserved="" answers_file reserved_port
for answers_file in "$PLAN_ROOT"/*/answers.env; do
[ -f "$answers_file" ] || continue
reserved_port="$(sed -n 's/^HUB_PORT=//p' "$answers_file" | tail -n1)"
[[ "$reserved_port" =~ ^[0-9]+$ ]] || continue
local_reserved="${local_reserved:+$local_reserved,}$reserved_port"
done
allocation="$(ssh \
-i "$DEPLOY_SSH_KEY" \
-p "$DEPLOY_SSH_PORT" \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
"$DEPLOY_USER@$DEPLOY_HOST" \
bash -s -- "$local_reserved" <<'REMOTE'
set -euo pipefail
local_reserved=",${1:-},"
for candidate in $(seq 8788 8999); do
reserved=false
if [[ "$local_reserved" == *",$candidate,"* ]]; then
continue
fi
for env_file in /srv/curriculum-project-hub/.secrets/*/platform.env; do
[ -f "$env_file" ] || continue
if [ "$(sed -n "s/^PORT=//p" "$env_file")" = "$candidate" ]; then
reserved=true
break
fi
done
workspace="/w/$candidate"
if [ "$reserved" = false ] && ! ss -H -ltn "sport = :$candidate" | grep -q . && [ ! -e "$workspace" ]; then
printf "%s %s\n" "$candidate" "$workspace"
exit 0
fi
done
echo "no free Alpha Silo slot in 8788..8999" >&2
exit 1
REMOTE
)"
read -r HUB_PORT WORKSPACE_ROOT <<<"$allocation"
require_value HUB_PORT "$HUB_PORT"
require_value WORKSPACE_ROOT "$WORKSPACE_ROOT"
write_env HUB_PORT "$HUB_PORT"
write_env WORKSPACE_ROOT "$WORKSPACE_ROOT"
}
banner "New Alpha Silo"
stage "Organization identity and private plan" 3
say "One run creates one Organization's private deployment bundle."
ask ORGANIZATION_SLUG "Organization slug (lowercase, max 24 chars; e.g. school-a):"
require_value ORGANIZATION_SLUG "$ORGANIZATION_SLUG"
[[ "$ORGANIZATION_SLUG" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]] || {
warn "invalid Organization slug"
exit 1
}
INSTANCE_ID="$ORGANIZATION_SLUG"
ORGANIZATION_ID="$ORGANIZATION_SLUG"
OUTPUT_DIR="$PLAN_ROOT/$INSTANCE_ID"
mkdir -p "$OUTPUT_DIR"
chmod 0700 "$OUTPUT_DIR"
ENV_FILE="$OUTPUT_DIR/answers.env"
touch "$ENV_FILE"
chmod 0600 "$ENV_FILE"
write_env INSTANCE_ID "$INSTANCE_ID"
write_env ORGANIZATION_ID "$ORGANIZATION_ID"
write_env ORGANIZATION_SLUG "$ORGANIZATION_SLUG"
capture ORGANIZATION_NAME "Organization display name:"
stage "Host, release, and isolation" 5
say "The Alpha host is platform-managed. Port and short workspace path are allocated from live host state."
DEPLOY_HOST="${CPH_ALPHA_HOST:-39.107.254.4}"
DEPLOY_USER="${CPH_ALPHA_DEPLOY_USER:-root}"
DEPLOY_SSH_PORT="${CPH_ALPHA_SSH_PORT:-22}"
write_env DEPLOY_HOST "$DEPLOY_HOST"
write_env DEPLOY_USER "$DEPLOY_USER"
write_env DEPLOY_SSH_PORT "$DEPLOY_SSH_PORT"
seed_default DEPLOY_BASE "/srv/curriculum-project-hub"
seed_default DEPLOY_SSH_KEY "$HOME/.ssh/id_ed25519"
write_env RELEASE_ID "v$(node -p 'require(process.argv[1]).version' "$REPO_ROOT/hub/package.json")"
seed_default MEMORY_MAX "16G"
seed_default CPU_QUOTA "400%"
seed_default TASKS_MAX "512"
seed_default CPH_BIN "/usr/local/bin/cph"
note "Managed Alpha host: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_SSH_PORT"
DEPLOY_SSH_KEY="$(_existing DEPLOY_SSH_KEY)"
MEMORY_MAX="$(_existing MEMORY_MAX)"
CPU_QUOTA="$(_existing CPU_QUOTA)"
TASKS_MAX="$(_existing TASKS_MAX)"
[ -f "$DEPLOY_SSH_KEY" ] || { warn "managed SSH key is missing: $DEPLOY_SSH_KEY"; exit 1; }
if [[ "$(_existing HUB_PORT || true)" =~ ^(878[8-9]|87[9][0-9]|8[89][0-9]{2})$ ]] && \
[ "$(_existing WORKSPACE_ROOT || true)" = "/w/$(_existing HUB_PORT)" ]; then
HUB_PORT="$(_existing HUB_PORT)"
WORKSPACE_ROOT="$(_existing WORKSPACE_ROOT)"
note "Keeping allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT"
else
say "Checking existing Silo environments, listening sockets, and workspace paths..."
allocate_host_slot
note "Allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT"
fi
note "Platform ceilings: MemoryMax=$MEMORY_MAX, CPUQuota=$CPU_QUOTA, TasksMax=$TASKS_MAX"
stage "Dedicated PostgreSQL database" 4
say "A PostgreSQL server may be shared, but this Silo gets a distinct login role and database."
seed_default DATABASE_HOST "127.0.0.1"
seed_default DATABASE_PORT "5432"
seed_default DATABASE_NAME "cph_${INSTANCE_ID//-/_}"
seed_default DATABASE_USER "cph_${INSTANCE_ID//-/_}"
DATABASE_NAME="$(_existing DATABASE_NAME)"
if ! _existing DATABASE_PASSWORD >/dev/null 2>&1; then
DATABASE_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')"
write_env DATABASE_PASSWORD "$DATABASE_PASSWORD"
fi
note "The generated OPERATE.md uses an interactive/protected SQL path; the password is never put in a command argument."
stage "Public URL and Feishu app" 9
open_url "https://open.feishu.cn/app"
say "Create or open the Organization's own app. Copy credentials from Credentials & Basic Info."
PUBLIC_BASE_URL="https://${ORGANIZATION_SLUG}.${CPH_ALPHA_BASE_DOMAIN:-educraft.paradigm-edu.net}"
write_env PUBLIC_BASE_URL "$PUBLIC_BASE_URL"
note "Platform-assigned public URL: $PUBLIC_BASE_URL"
capture FEISHU_APP_ID "Feishu App ID:"
capture_secret FEISHU_APP_SECRET "Feishu App Secret:"
say "Resolving the bot Open ID from Feishu..."
FEISHU_BOT_OPEN_ID="$(printf '%s\0%s\0' "$FEISHU_APP_ID" "$FEISHU_APP_SECRET" | node "$SCRIPT_DIR/resolve_feishu_bot.mjs")"
write_env FEISHU_BOT_OPEN_ID "$FEISHU_BOT_OPEN_ID"
note "Resolved bot identity: $FEISHU_BOT_OPEN_ID"
open_url "https://open.feishu.cn/document/server-docs/contact-v3/user/get"
step "In the user/get page, click the user_id value picker, select the first OWNER, and copy the returned open_id."
capture OWNER_OPEN_ID "OWNER Open ID (ou_...):"
capture OWNER_DISPLAY_NAME "OWNER display name:"
write_env OWNER_UNION_ID ""
say "The exact redirect URL and acceptance steps will be written to OPERATE.md."
stage "Provider and Alpha limits" 5
say "Use a provider credential exclusive to this Organization. Host proxy setup is a separate prerequisite."
seed_default PROVIDER_ID "openrouter"
seed_default PROVIDER_BASE_URL "https://openrouter.ai/api"
seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5"
seed_default DEFAULT_ROLE_ID "draft"
seed_default DEFAULT_ROLE_LABEL "智能助手"
seed_default MAX_TURNS "25"
seed_default MAX_CONCURRENT_RUNS "4"
seed_default MAX_RUN_SECONDS "900"
seed_default HTTP_BODY_LIMIT_BYTES "1048576"
seed_default MAX_FILES_PER_MESSAGE "8"
seed_default MAX_FILE_BYTES "26214400"
seed_default HTTP_REQUESTS_PER_MINUTE "120"
seed_default FEISHU_EVENTS_PER_MINUTE "120"
capture_secret PROVIDER_AUTH_TOKEN "Provider auth token:"
for key in PROVIDER_ID PROVIDER_BASE_URL DEFAULT_MODEL DEFAULT_ROLE_ID DEFAULT_ROLE_LABEL \
MAX_TURNS MAX_CONCURRENT_RUNS MAX_RUN_SECONDS HTTP_BODY_LIMIT_BYTES \
MAX_FILES_PER_MESSAGE MAX_FILE_BYTES HTTP_REQUESTS_PER_MINUTE FEISHU_EVENTS_PER_MINUTE; do
printf -v "$key" '%s' "$(_existing "$key")"
done
write_env APPROVED_SKILLS "outline,lesson-project,data-processing-spec,typst"
note "Platform runtime: OpenRouter, concurrency 4, default education role, curated skills."
if ! _existing HUB_SESSION_SECRET >/dev/null 2>&1; then
command -v openssl >/dev/null 2>&1 || { warn "openssl is required"; exit 1; }
HUB_SESSION_SECRET="$(openssl rand -hex 32)"
write_env HUB_SESSION_SECRET "$HUB_SESSION_SECRET"
fi
stage "Render the private deployment bundle" 2
say "This renders platform.env, bootstrap.json, deploy.env, nginx.conf and OPERATE.md."
command -v node >/dev/null 2>&1 || { warn "Node.js is required to render safely"; exit 1; }
node "$SCRIPT_DIR/render_new_silo_bundle.mjs" "$ENV_FILE" "$OUTPUT_DIR"
chmod 0700 "$OUTPUT_DIR"
chmod 0600 "$OUTPUT_DIR"/*
say "Bundle: $OUTPUT_DIR"
warn "It contains database, Feishu, provider and session secrets. Never commit or paste it."
stage "Operator handoff and gates" 7
say "The deployment package is an internal retry/audit checkpoint; you do not operate it manually."
step "Target: $PUBLIC_BASE_URL$DEPLOY_HOST:$HUB_PORT"
step "Resources: database $DATABASE_NAME, workspace $WORKSPACE_ROOT, service cph-hub-$INSTANCE_ID"
warn "Confirmation will create server, database, TLS, and runtime state."
if confirm "Deploy this Organization now?"; then
bash "$SCRIPT_DIR/apply_new_silo.sh" "$OUTPUT_DIR"
else
warn "deployment skipped; rerun the wizard later and keep existing answers"
fi
finish
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const [answersPath, outputPath] = process.argv.slice(2);
if (!answersPath || !outputPath) {
throw new Error("usage: render_new_silo_bundle.mjs ANSWERS_ENV OUTPUT_DIR");
}
function parseAnswers(source) {
const values = new Map();
for (const [index, line] of source.split("\n").entries()) {
if (!line || line.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator < 1) throw new Error(`invalid answers line ${index + 1}`);
const key = line.slice(0, separator);
const value = line.slice(separator + 1);
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
throw new Error(`invalid answers key on line ${index + 1}: ${key}`);
}
if (value.includes("\r") || value.includes("\n")) {
throw new Error(`multiline value is not supported: ${key}`);
}
values.set(key, value);
}
return values;
}
function required(values, key) {
const value = values.get(key);
if (!value) throw new Error(`missing required answer: ${key}`);
return value;
}
function optional(values, key, fallback = "") {
return values.get(key) || fallback;
}
function assertMatch(label, value, pattern) {
if (!pattern.test(value)) throw new Error(`invalid ${label}: ${value}`);
}
function envLine(key, value) {
if (/[\r\n]/.test(value)) throw new Error(`unsafe newline in ${key}`);
return `${key}=${value}`;
}
const answers = parseAnswers(await readFile(resolve(answersPath), "utf8"));
const instanceId = required(answers, "INSTANCE_ID");
const orgId = required(answers, "ORGANIZATION_ID");
const orgSlug = required(answers, "ORGANIZATION_SLUG");
const port = required(answers, "HUB_PORT");
const workspaceRoot = required(answers, "WORKSPACE_ROOT");
const publicBaseUrl = required(answers, "PUBLIC_BASE_URL").replace(/\/$/, "");
const domain = new URL(publicBaseUrl).hostname;
const databasePassword = required(answers, "DATABASE_PASSWORD");
const databaseUrl = `postgresql://${encodeURIComponent(required(answers, "DATABASE_USER"))}:${encodeURIComponent(databasePassword)}@${required(answers, "DATABASE_HOST")}:${required(answers, "DATABASE_PORT")}/${encodeURIComponent(required(answers, "DATABASE_NAME"))}`;
assertMatch("INSTANCE_ID", instanceId, /^[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?$/);
assertMatch("organization slug", orgSlug, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
assertMatch("port", port, /^[1-9][0-9]{1,4}$/);
assertMatch("database name", required(answers, "DATABASE_NAME"), /^[a-z_][a-z0-9_]*$/);
assertMatch("database user", required(answers, "DATABASE_USER"), /^[a-z_][a-z0-9_]*$/);
assertMatch("default role id", required(answers, "DEFAULT_ROLE_ID"), /^[a-z][a-z0-9-]*$/);
assertMatch("release id", required(answers, "RELEASE_ID"), /^[A-Za-z0-9._-]+$/);
if (!publicBaseUrl.startsWith("https://")) throw new Error("PUBLIC_BASE_URL must use https");
for (const [label, value] of [
["WORKSPACE_ROOT", workspaceRoot],
["DEPLOY_BASE", required(answers, "DEPLOY_BASE")],
["DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")],
["CPH_BIN", required(answers, "CPH_BIN")],
]) {
if (!value.startsWith("/") || /\s/.test(value)) {
throw new Error(`${label} must be an absolute path without whitespace: ${value}`);
}
}
if (Number(port) > 65535) throw new Error(`invalid port: ${port}`);
if (Buffer.byteLength(workspaceRoot) > 16) {
throw new Error(`WORKSPACE_ROOT exceeds the 16-byte sandbox socket limit: ${workspaceRoot}`);
}
const outputDir = resolve(outputPath);
await mkdir(outputDir, { recursive: true, mode: 0o700 });
await chmod(outputDir, 0o700);
const base = required(answers, "DEPLOY_BASE");
const release = required(answers, "RELEASE_ID");
const releaseHub = `${base}/releases/${release}/hub`;
const secretDir = `${base}/.secrets/${instanceId}`;
const envPath = `${secretDir}/platform.env`;
const keyringPath = `${secretDir}/secret-keyring.json`;
const unit = `cph-hub-${instanceId}.service`;
const platformEnv = [
"# Generated by hub/deploy/new_silo.sh. Install root:root mode 0600.",
envLine("NODE_ENV", "production"),
envLine("DATABASE_URL", databaseUrl),
envLine("HUB_SILO_ORGANIZATION_ID", orgId),
envLine("HUB_SYSTEMD_UNIT", unit),
envLine("CPH_BIN", required(answers, "CPH_BIN")),
envLine("HOST", "127.0.0.1"),
envLine("PORT", port),
envLine("HUB_PROJECT_WORKSPACE_ROOT", workspaceRoot),
envLine("HUB_PUBLIC_BASE_URL", publicBaseUrl),
envLine("HUB_SESSION_SECRET", required(answers, "HUB_SESSION_SECRET")),
envLine("HUB_AGENT_MAX_TURNS", required(answers, "MAX_TURNS")),
envLine("HUB_AGENT_MAX_CONCURRENT_RUNS", required(answers, "MAX_CONCURRENT_RUNS")),
envLine("HUB_AGENT_MAX_RUN_SECONDS", required(answers, "MAX_RUN_SECONDS")),
envLine("HUB_HTTP_BODY_LIMIT_BYTES", required(answers, "HTTP_BODY_LIMIT_BYTES")),
envLine("HUB_MAX_FILES_PER_MESSAGE", required(answers, "MAX_FILES_PER_MESSAGE")),
envLine("HUB_MAX_FILE_BYTES", required(answers, "MAX_FILE_BYTES")),
envLine("HUB_HTTP_REQUESTS_PER_MINUTE", required(answers, "HTTP_REQUESTS_PER_MINUTE")),
envLine("HUB_FEISHU_EVENTS_PER_MINUTE", required(answers, "FEISHU_EVENTS_PER_MINUTE")),
envLine("HUB_FEISHU_LISTENER_ENABLED", "true"),
envLine("HTTP_PROXY", "http://127.0.0.1:7890"),
envLine("HTTPS_PROXY", "http://127.0.0.1:7890"),
envLine("ALL_PROXY", "socks5h://127.0.0.1:7890"),
envLine("NO_PROXY", "127.0.0.1,localhost,::1"),
envLine("NODE_USE_ENV_PROXY", "1"),
envLine("ANTHROPIC_DEFAULT_SONNET_MODEL", "anthropic/claude-sonnet-5"),
envLine("CPH_SANDBOX_EXTRA_DENY_READ", `${envPath}:${keyringPath}`),
"",
].join("\n");
const bootstrap = {
organization: {
id: orgId,
slug: orgSlug,
name: required(answers, "ORGANIZATION_NAME"),
},
owner: {
openId: required(answers, "OWNER_OPEN_ID"),
displayName: required(answers, "OWNER_DISPLAY_NAME"),
},
feishu: {
appId: required(answers, "FEISHU_APP_ID"),
appSecret: required(answers, "FEISHU_APP_SECRET"),
botOpenId: required(answers, "FEISHU_BOT_OPEN_ID"),
},
provider: {
providerId: required(answers, "PROVIDER_ID"),
baseUrl: required(answers, "PROVIDER_BASE_URL"),
authToken: required(answers, "PROVIDER_AUTH_TOKEN"),
},
teams: [],
};
const ownerUnionId = optional(answers, "OWNER_UNION_ID");
if (ownerUnionId) bootstrap.owner.unionId = ownerUnionId;
const deployEnv = [
"# Source this file locally before deploy_platform.sh (contains no app/provider secrets).",
envLine("PLATFORM_DEPLOY_HOST", required(answers, "DEPLOY_HOST")),
envLine("PLATFORM_DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")),
envLine("PLATFORM_DEPLOY_USER", required(answers, "DEPLOY_USER")),
envLine("PLATFORM_DEPLOY_PORT", required(answers, "DEPLOY_SSH_PORT")),
envLine("PLATFORM_DEPLOY_HUB_PORT", port),
envLine("PLATFORM_DEPLOY_BASE", base),
envLine("PLATFORM_DEPLOY_RELEASE", release),
envLine("PLATFORM_DEPLOY_INSTANCE", instanceId),
envLine("PLATFORM_DEPLOY_WORKSPACE_ROOT", workspaceRoot),
envLine("PLATFORM_DEPLOY_MEMORY_MAX", required(answers, "MEMORY_MAX")),
envLine("PLATFORM_DEPLOY_CPU_QUOTA", required(answers, "CPU_QUOTA")),
envLine("PLATFORM_DEPLOY_TASKS_MAX", required(answers, "TASKS_MAX")),
envLine("PLATFORM_DEPLOY_HEALTH_URL", `http://127.0.0.1:${port}/api/healthz`),
"",
].join("\n");
const nginx = `# Install as /etc/nginx/conf.d/${instanceId}.conf after obtaining TLS certificates.\nserver {\n listen 80;\n server_name ${domain};\n return 301 https://$host$request_uri;\n}\n\nserver {\n listen 443 ssl http2;\n server_name ${domain};\n\n ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;\n ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;\n\n location / {\n proxy_pass http://127.0.0.1:${port};\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto https;\n }\n}\n`;
const defaultRolePrompt = `你是一位教育机构智能助手,服务于学校和教培机构的日常管理与协作。
## 核心能力
**教务管理**:熟悉排课调课、班级管理、课时统计、师资调度等常见管理流程。
**教研支持**:理解课程设计、教案编写、例题/变式题、定理证明、随堂练习、阶段性测验、知识点拆解、大纲对标等教学业务概念。
**数字化操作**:熟练使用shell命令行进行文件管理、批量处理、脚本编写、文本处理(grep/sed/awk等)、自动化任务;能协助处理Markdown、Typst等文档、表格数据、格式转换等。
## 行为准则
- **实事求是**:不确定时明确说明,能力边界外的任务如实告知,不为完成目标而编造信息或勉强输出
- **简洁优先**:直接给出答案或方案,省略铺垫和客套
- **按需展开**:仅在问题复杂或用户明确要求时提供详细说明
- **结构清晰**:多步骤内容使用编号,便于飞书阅读
- **可操作性**:涉及操作时给出具体命令或步骤
保持专业、高效,像一位熟悉业务的同事。
`;
const operate = `# ${instanceId} deployment commands\n\nThis file contains no application/provider secrets. Run commands deliberately; do not source answers.env.\n\n## 1. DNS and Feishu\n\n- Point \`${domain}\` to \`${required(answers, "DEPLOY_HOST")}\`.\n- In the Feishu app, configure redirect URL: \`${publicBaseUrl}/auth/feishu/callback\`.\n- Enable the required bot/message/contact permissions and event subscription described in the customer setup document.\n\n## 2. Database (run as the PostgreSQL administrator)\n\nCreate one role and one database for this Silo. The password is in \`answers.env\`; use an interactive client or a protected SQL file, never a command-line argument.\n\n\`\`\`sql\nCREATE ROLE ${required(answers, "DATABASE_USER")} LOGIN PASSWORD '<copy DATABASE_PASSWORD from answers.env>';\nCREATE DATABASE ${required(answers, "DATABASE_NAME")} OWNER ${required(answers, "DATABASE_USER")};\n\`\`\`\n\n## 3. Publish the immutable release\n\nThe normal deploy script seeds the first-instance secrets and exits 78. That exit is expected only on this first pass.\n\n\`\`\`sh\nset -a; . ./deploy.env; set +a\nbash hub/deploy/deploy_platform.sh\n\`\`\`\n\n## 4. Install the generated secrets\n\nCopy \`platform.env\` and \`bootstrap.json\` to the server through a protected channel. On the server:\n\n\`\`\`sh\ninstall -d -o root -g root -m 0700 '${secretDir}'\ninstall -o root -g root -m 0600 platform.env '${envPath}'\ninstall -o root -g root -m 0600 bootstrap.json '/root/${instanceId}-bootstrap.json'\n# Copy ${keyringPath} to separate off-host recovery storage before continuing.\n\`\`\`\n\n## 5. Migrate, install the stopped service, and bootstrap\n\n\`\`\`sh\nset -a; . '${envPath}'; set +a\nnode '${releaseHub}/node_modules/prisma/build/index.js' migrate deploy --schema '${releaseHub}/prisma/schema.prisma'\nBASE='${base}' HUB_DIR='${releaseHub}' INSTANCE_ID='${instanceId}' WORKSPACE_ROOT='${workspaceRoot}' PORT='${port}' MEMORY_MAX='${required(answers, "MEMORY_MAX")}' CPU_QUOTA='${required(answers, "CPU_QUOTA")}' TASKS_MAX='${required(answers, "TASKS_MAX")}' bash '${releaseHub}/deploy/install_service.sh'\nnode '${releaseHub}/dist/deployment/bootstrap-silo-cli.js' --config-file '/root/${instanceId}-bootstrap.json' --keyring-file '${keyringPath}'\nrm -f '/root/${instanceId}-bootstrap.json'\n\`\`\`\n\n## 6. Runtime role and skills\n\nRuntime role/skill configuration is intentionally separate from the release. Follow NEW_SILO_RUNBOOK.md, staging each approved skill directory outside the immutable release, then use \`agent_config.sh\`. Do not silently copy a local personal skill collection.\n\n## 7. Nginx, start, and acceptance\n\nInstall \`nginx.conf\`, run \`nginx -t\`, reload Nginx, then:\n\n\`\`\`sh\nsystemctl start '${unit}'\nsystemctl is-active '${unit}'\ncurl --fail --silent --show-error 'http://127.0.0.1:${port}/api/healthz'\ncurl --fail --silent --show-error '${publicBaseUrl}/api/healthz'\njournalctl -u '${unit}' --since '-10 min' --no-pager\n\`\`\`\n\nAcceptance requires: Feishu OAuth completes, OWNER and a non-OWNER can @bot, a second group creates a separate project/session, Typst works when that skill is enabled, and restart preserves session cursor. Then take the first off-host backup.\n\n## 8. Later releases\n\nAfter the instance exists, source \`deploy.env\` with the new release id and run \`deploy_platform.sh\`. Never reuse another org's database, secret directory, workspace root, port, service identity, domain, Feishu app, or provider credential.\n`;
const selectedSkills = optional(answers, "APPROVED_SKILLS");
const roleInstructions = `
## Appendix: default runtime role
Copy \`default-role-prompt.md\` to \`/root/${instanceId}-default-role-prompt.md\`.
After installing each reviewed skill with \`agent_config.sh install-skill\`, run:
\`\`\`sh
INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} upsert-role --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --label ${JSON.stringify(required(answers, "DEFAULT_ROLE_LABEL"))} --model ${JSON.stringify(required(answers, "DEFAULT_MODEL"))} --system-prompt-file ${JSON.stringify(`/root/${instanceId}-default-role-prompt.md`)} --tools-json null
${selectedSkills ? `INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} set-role-skills --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --skills ${JSON.stringify(selectedSkills)}
` : "# No skills selected in the wizard; set-role-skills remains an explicit operator step.\n"}INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} list --organization ${JSON.stringify(orgId)}
\`\`\`
Changing a role's model, system prompt, tools or skill selection archives that
role's existing sessions by design. Finish this setup before inviting Alpha users.
`;
async function privateWrite(name, contents) {
const path = resolve(outputDir, name);
await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
await chmod(path, 0o600);
}
await privateWrite("platform.env", platformEnv);
await privateWrite("bootstrap.json", `${JSON.stringify(bootstrap, null, 2)}\n`);
await privateWrite("deploy.env", deployEnv);
await privateWrite("nginx.conf", nginx);
await privateWrite("default-role-prompt.md", defaultRolePrompt);
await privateWrite("OPERATE.md", `${operate}${roleInstructions}`);
console.log(`Rendered private Silo bundle: ${outputDir}`);
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const [appId, appSecret] = Buffer.concat(chunks).toString("utf8").split("\0");
if (!appId || !appSecret) throw new Error("Feishu App ID and App Secret are required on stdin");
const tokenResponse = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
method: "POST",
headers: { "content-type": "application/json; charset=utf-8" },
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
});
if (!tokenResponse.ok) throw new Error(`Feishu token request failed: HTTP ${tokenResponse.status}`);
const tokenPayload = await tokenResponse.json();
if (tokenPayload.code !== 0 || typeof tokenPayload.tenant_access_token !== "string") {
throw new Error(`Feishu token request failed: ${JSON.stringify(tokenPayload)}`);
}
const botResponse = await fetch("https://open.feishu.cn/open-apis/bot/v3/info", {
headers: { authorization: `Bearer ${tokenPayload.tenant_access_token}` },
});
if (!botResponse.ok) throw new Error(`Feishu bot info request failed: HTTP ${botResponse.status}`);
const botPayload = await botResponse.json();
if (botPayload.code !== 0 || typeof botPayload.bot?.open_id !== "string" || !botPayload.bot.open_id) {
throw new Error(`Feishu bot info request failed: ${JSON.stringify(botPayload)}`);
}
process.stdout.write(botPayload.bot.open_id);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.14",
"version": "0.0.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.14",
"version": "0.0.21",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.14",
"version": "0.0.21",
"private": true,
"type": "module",
"engines": {
@@ -0,0 +1,48 @@
import { readFile } from "node:fs/promises";
import { prisma } from "../db.js";
import { importLegacyProjects, type LegacyProjectManifestEntry } from "./legacyProjectImport.js";
import { readSiloOrganizationId } from "./silo.js";
async function main(argv: readonly string[]): Promise<void> {
const options = parseOptions(argv);
const organizationId = readSiloOrganizationId();
const manifest = JSON.parse(await readFile(required(options, "manifest"), "utf8")) as unknown;
if (!Array.isArray(manifest)) throw new Error("legacy import manifest must be a JSON array");
const state = await importLegacyProjects({
prisma,
organizationId,
actorFeishuOpenId: required(options, "actor-open-id"),
workspaceRoot: required(options, "workspace-root"),
sourceRoot: required(options, "source-root"),
stateFile: required(options, "state-file"),
projects: manifest as LegacyProjectManifestEntry[],
onProgress: (message) => console.error(`[legacy-import] ${message}`),
});
console.log(JSON.stringify({ imported: Object.keys(state.projects).length }));
}
function parseOptions(args: readonly string[]): Map<string, string> {
const options = new Map<string, string>();
for (let index = 0; index < args.length; index += 2) {
const flag = args[index];
const value = args[index + 1];
if (flag === undefined || !flag.startsWith("--") || value === undefined) {
throw new Error(`expected --name value, got: ${args.slice(index).join(" ")}`);
}
options.set(flag.slice(2), value);
}
return options;
}
function required(options: ReadonlyMap<string, string>, name: string): string {
const value = options.get(name)?.trim();
if (!value) throw new Error(`--${name} is required`);
return value;
}
main(process.argv.slice(2))
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
})
.finally(async () => prisma.$disconnect());
+369
View File
@@ -0,0 +1,369 @@
import { createHash } from "node:crypto";
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { createFolder, createProjectFromOrgAdmin } from "../projectOnboarding.js";
export interface LegacyProjectManifestEntry {
readonly legacyId: string;
readonly name: string;
readonly folderPath: readonly string[];
readonly sourceRelativePath: string;
}
export interface LegacyProjectImportState {
readonly version: 1;
readonly projects: Readonly<Record<string, {
readonly status: "PENDING" | "COMPLETED";
readonly projectId: string;
readonly workspaceDir: string;
readonly importedAt: string;
readonly sourceRelativePath: string;
}>>;
}
export async function importLegacyProjects(input: {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly workspaceRoot: string;
readonly sourceRoot: string;
readonly stateFile: string;
readonly projects: readonly LegacyProjectManifestEntry[];
readonly onProgress?: (message: string) => void;
}): Promise<LegacyProjectImportState> {
const sourceRoot = await realpath(input.sourceRoot);
const state = await readState(input.stateFile);
const projects = { ...state.projects };
const seen = new Set<string>();
for (const entry of input.projects) {
validateEntry(entry);
if (seen.has(entry.legacyId)) throw new Error(`duplicate legacy project id: ${entry.legacyId}`);
seen.add(entry.legacyId);
const sourceDir = await confinedSourceDir(sourceRoot, entry.sourceRelativePath);
const projectId = importedProjectId(input.organizationId, entry.legacyId);
const existing = await input.prisma.project.findUnique({ where: { id: projectId } });
const recorded = projects[entry.legacyId];
if (recorded !== undefined && (recorded.projectId !== projectId || recorded.sourceRelativePath !== entry.sourceRelativePath)) {
throw new Error(`legacy import state identity conflict: ${entry.legacyId}`);
}
if (recorded?.status === "COMPLETED" && existing === null) {
throw new Error(`legacy import state references missing project: ${entry.legacyId} -> ${recorded.projectId}`);
}
if (existing !== null) {
if (existing.organizationId !== input.organizationId || (recorded !== undefined && recorded.projectId !== existing.id)) {
throw new Error(`legacy import identity conflict: ${entry.legacyId} -> ${existing.id}`);
}
if (await hasCompletionMarker(existing.workspaceDir, entry)) {
await ensureImportAudit(input.prisma, existing.id, entry);
projects[entry.legacyId] = {
status: "COMPLETED",
projectId: existing.id,
workspaceDir: existing.workspaceDir,
importedAt: recorded?.importedAt || new Date().toISOString(),
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
input.onProgress?.(`skip ${entry.legacyId}: recovered completed import`);
continue;
}
if (recorded?.status !== "PENDING") {
throw new Error(`refusing to remove legacy project without a matching pending record: ${entry.legacyId}`);
}
input.onProgress?.(`recover ${entry.legacyId}: remove incomplete target`);
await removeIncompleteTarget(input.prisma, existing.id, existing.workspaceDir, input.workspaceRoot);
}
projects[entry.legacyId] = {
status: "PENDING",
projectId,
workspaceDir: "",
importedAt: "",
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
const folderId = await ensureFolderPath(input.prisma, input.organizationId, ["旧教学资产", ...entry.folderPath]);
input.onProgress?.(`import ${entry.legacyId}: ${entry.name}`);
const created = await createProjectFromOrgAdmin(input.prisma, {
organizationId: input.organizationId,
actorFeishuOpenId: input.actorFeishuOpenId,
name: entry.name,
workspaceRoot: input.workspaceRoot,
folderId,
projectId,
});
try {
await copyLegacyProject(sourceDir, created.workspaceDir, entry);
} catch (error) {
try {
await removeIncompleteTarget(input.prisma, created.projectId, created.workspaceDir, input.workspaceRoot);
delete projects[entry.legacyId];
await writeState(input.stateFile, { version: 1, projects });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
`legacy project import and target cleanup failed: ${entry.legacyId}`,
);
}
throw new Error(`legacy project import failed: ${entry.legacyId}: ${errorMessage(error)}`, { cause: error });
}
await ensureImportAudit(input.prisma, created.projectId, entry);
projects[entry.legacyId] = {
status: "COMPLETED",
projectId: created.projectId,
workspaceDir: created.workspaceDir,
importedAt: new Date().toISOString(),
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
}
return { version: 1, projects };
}
async function ensureFolderPath(
prisma: PrismaClient,
organizationId: string,
parts: readonly string[],
): Promise<string> {
let parentId: string | undefined;
for (const name of parts) {
const existing = await prisma.folder.findFirst({
where: { organizationId, parentId: parentId ?? null, name, archivedAt: null },
select: { id: true },
});
if (existing !== null) {
parentId = existing.id;
continue;
}
const created = await createFolder(prisma, {
organizationId,
name,
...(parentId !== undefined ? { parentId } : {}),
});
parentId = created.id;
}
if (parentId === undefined) throw new Error("legacy import folder path is empty");
return parentId;
}
async function copyLegacyProject(
sourceDir: string,
workspaceDir: string,
entry: LegacyProjectManifestEntry,
): Promise<void> {
const sourceWorkspace = join(sourceDir, "workspace");
await assertNoSymlinks(sourceWorkspace);
const names = await readdir(sourceWorkspace);
for (const name of names) {
if (name === ".claude" || name === ".cph") continue;
await cp(join(sourceWorkspace, name), join(workspaceDir, name), {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
filter: (source) => {
const parts = relative(sourceWorkspace, source).split(sep);
return !parts.includes(".claude") && !parts.includes(".cph");
},
});
}
const legacyDir = join(workspaceDir, ".legacy-source");
await mkdir(legacyDir, { mode: 0o750 });
await cp(join(sourceDir, "project.json"), join(legacyDir, "project.json"), {
force: false,
errorOnExist: true,
preserveTimestamps: true,
});
const rawDir = join(sourceDir, "_raw");
await assertNoSymlinks(rawDir).catch((error: unknown) => {
if (isMissing(error)) return;
throw error;
});
await cp(rawDir, join(legacyDir, "raw"), {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
}).catch((error: unknown) => {
if (isMissing(error)) return;
throw error;
});
await writeFile(join(legacyDir, "migration.json"), `${JSON.stringify({
source: "teaching-material-host-service",
legacyProjectId: entry.legacyId,
legacyPath: entry.sourceRelativePath,
migratedAt: new Date().toISOString(),
}, null, 2)}\n`, { mode: 0o640 });
}
function importedProjectId(organizationId: string, legacyId: string): string {
const digest = createHash("sha256")
.update("teaching-material-host-service\0")
.update(organizationId)
.update("\0")
.update(legacyId)
.digest("hex")
.slice(0, 32);
return `legacy_${digest}`;
}
async function hasCompletionMarker(
workspaceDir: string,
entry: LegacyProjectManifestEntry,
): Promise<boolean> {
try {
const marker = JSON.parse(await readFile(join(workspaceDir, ".legacy-source", "migration.json"), "utf8")) as unknown;
return typeof marker === "object" && marker !== null
&& "legacyProjectId" in marker && marker.legacyProjectId === entry.legacyId
&& "legacyPath" in marker && marker.legacyPath === entry.sourceRelativePath;
} catch (error) {
if (isMissing(error)) return false;
if (error instanceof SyntaxError) {
throw new Error(`invalid legacy completion marker: ${workspaceDir}`, { cause: error });
}
throw error;
}
}
async function ensureImportAudit(
prisma: PrismaClient,
projectId: string,
entry: LegacyProjectManifestEntry,
): Promise<void> {
const existing = await prisma.auditEntry.findFirst({
where: { projectId, action: "legacy_project.imported" },
select: { id: true },
});
if (existing !== null) return;
await prisma.auditEntry.create({
data: {
projectId,
action: "legacy_project.imported",
metadata: {
source: "teaching-material-host-service",
legacyProjectId: entry.legacyId,
legacyPath: entry.sourceRelativePath,
},
},
});
}
async function removeIncompleteTarget(
prisma: PrismaClient,
projectId: string,
workspaceDir: string,
workspaceRoot: string,
): Promise<void> {
await assertConfinedExistingPath(workspaceRoot, workspaceDir);
const failures: unknown[] = [];
try {
await prisma.project.delete({ where: { id: projectId } });
} catch (error) {
failures.push(error);
}
try {
await rm(workspaceDir, { recursive: true, force: true });
} catch (error) {
failures.push(error);
}
if (failures.length > 0) throw new AggregateError(failures, `failed to remove incomplete legacy target: ${projectId}`);
}
async function assertConfinedExistingPath(root: string, path: string): Promise<void> {
const trustedRoot = await realpath(root);
const candidate = await realpath(path);
const rel = relative(trustedRoot, candidate);
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
throw new Error(`refusing to remove path outside workspace root: ${path}`);
}
}
async function assertNoSymlinks(path: string): Promise<void> {
const metadata = await lstat(path);
if (metadata.isSymbolicLink()) throw new Error(`legacy import rejects symbolic link: ${path}`);
if (!metadata.isDirectory()) return;
for (const entry of await readdir(path)) await assertNoSymlinks(join(path, entry));
}
async function confinedSourceDir(sourceRoot: string, relativePath: string): Promise<string> {
const candidate = await realpath(resolve(sourceRoot, relativePath));
const rel = relative(sourceRoot, candidate);
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
throw new Error(`legacy source path escapes source root: ${relativePath}`);
}
return candidate;
}
function validateEntry(entry: LegacyProjectManifestEntry): void {
if (typeof entry !== "object" || entry === null) throw new Error("invalid legacy project entry");
if (typeof entry.legacyId !== "string") throw new Error("legacy project id must be a string");
if (!/^[A-Za-z0-9_-]+$/.test(entry.legacyId)) throw new Error(`invalid legacy project id: ${entry.legacyId}`);
if (typeof entry.name !== "string") throw new Error(`legacy project name must be a string: ${entry.legacyId}`);
if (entry.name.trim() === "") throw new Error(`legacy project name is empty: ${entry.legacyId}`);
if (typeof entry.sourceRelativePath !== "string") {
throw new Error(`legacy source path must be a string: ${entry.legacyId}`);
}
if (entry.sourceRelativePath === "" || resolve("/", entry.sourceRelativePath) === "/") {
throw new Error(`invalid legacy source path: ${entry.legacyId}`);
}
if (!Array.isArray(entry.folderPath)) throw new Error(`legacy folder path must be an array: ${entry.legacyId}`);
for (const part of entry.folderPath) {
if (typeof part !== "string" || part.trim() === "" || part === "." || part === ".." || part.includes("/") || part.includes("\\")) {
throw new Error(`invalid legacy folder part for ${entry.legacyId}: ${part}`);
}
}
}
async function readState(path: string): Promise<LegacyProjectImportState> {
try {
const parsed = JSON.parse(await readFile(path, "utf8")) as LegacyProjectImportState;
if (parsed.version !== 1 || typeof parsed.projects !== "object" || parsed.projects === null) {
throw new Error(`invalid legacy import state: ${path}`);
}
for (const [legacyId, record] of Object.entries(parsed.projects)) validateStateRecord(path, legacyId, record);
return parsed;
} catch (error) {
if (isMissing(error)) return { version: 1, projects: {} };
throw error;
}
}
function validateStateRecord(path: string, legacyId: string, record: unknown): void {
if (typeof record !== "object" || record === null || Array.isArray(record)) {
throw new Error(`invalid legacy import state record: ${path}#${legacyId}`);
}
const values = record as Record<string, unknown>;
const expectedKeys = ["importedAt", "projectId", "sourceRelativePath", "status", "workspaceDir"];
if (Object.keys(values).sort().join("\0") !== expectedKeys.join("\0")) {
throw new Error(`invalid legacy import state fields: ${path}#${legacyId}`);
}
if (values.status !== "PENDING" && values.status !== "COMPLETED") {
throw new Error(`invalid legacy import state status: ${path}#${legacyId}`);
}
for (const field of ["projectId", "workspaceDir", "importedAt", "sourceRelativePath"] as const) {
if (typeof values[field] !== "string") throw new Error(`invalid legacy import state ${field}: ${path}#${legacyId}`);
}
if (values.projectId === "" || values.sourceRelativePath === "") {
throw new Error(`invalid legacy import state identity: ${path}#${legacyId}`);
}
if (values.status === "PENDING" && (values.workspaceDir !== "" || values.importedAt !== "")) {
throw new Error(`invalid pending legacy import state: ${path}#${legacyId}`);
}
if (values.status === "COMPLETED" && (values.workspaceDir === "" || values.importedAt === "")) {
throw new Error(`invalid completed legacy import state: ${path}#${legacyId}`);
}
}
async function writeState(path: string, state: LegacyProjectImportState): Promise<void> {
await mkdir(dirname(path), { recursive: true, mode: 0o750 });
const temporary = `${path}.tmp`;
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, path);
}
function isMissing(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+9 -1
View File
@@ -22,6 +22,7 @@ export function buildUnboundChatOnboardingCard(params: {
readonly folders: readonly OnboardingFolderOption[];
readonly projects: readonly OnboardingProjectOption[];
readonly canCreateProject: boolean;
readonly searchQuery?: string | undefined;
}): Record<string, unknown> {
const actions: unknown[] = [];
if (params.canCreateProject) {
@@ -64,7 +65,14 @@ export function buildUnboundChatOnboardingCard(params: {
`这个飞书群还没有绑定项目。`,
``,
`组织: **${escapeMarkdown(params.organizationName)}**`,
`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`,
...(params.searchQuery === undefined || params.searchQuery === ""
? [`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`]
: [
`项目搜索: **${escapeMarkdown(params.searchQuery)}**`,
params.projects.length === 0
? `没有匹配的未绑定项目。请 @bot 后换一个项目关键词。`
: `请选择匹配项目绑定到本群;如未找到,请 @bot 后换一个更具体的关键词。`,
]),
].join("\n"),
},
];
+43 -26
View File
@@ -1070,10 +1070,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role);
const searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100);
const projects = await listBindableProjectsForActor({
organizationId: organization.organizationId,
actorFeishuOpenId: senderOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
searchQuery,
});
const folders = await listCreatableRootFolders(organization.organizationId);
@@ -1086,6 +1088,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
folders,
projects,
canCreateProject,
searchQuery,
}),
sendOptionsForTriggerMessage(msg),
);
@@ -1182,37 +1185,51 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean;
readonly searchQuery: string;
}): Promise<readonly OnboardingProjectOption[]> {
const candidates = await deps.prisma.project.findMany({
where: {
const allowed: OnboardingProjectOption[] = [];
let cursor: string | undefined;
while (allowed.length < 5) {
const candidates = await deps.prisma.project.findMany({
where: {
organizationId: input.organizationId,
archivedAt: null,
groupBindings: { none: { archivedAt: null } },
},
select: {
id: true,
name: true,
folder: { select: { name: true } },
},
orderBy: { updatedAt: "desc" },
take: 20,
});
const allowed: OnboardingProjectOption[] = [];
for (const project of candidates) {
if (!input.isOrgAdmin) {
const decision = await authorizer.can({
actor: { feishuOpenId: input.actorFeishuOpenId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: project.id },
});
if (!decision.allowed) continue;
}
allowed.push({
projectId: project.id,
name: project.name,
...(project.folder?.name !== undefined ? { folderName: project.folder.name } : {}),
...(input.searchQuery === "" ? {} : {
OR: [
{ name: { contains: input.searchQuery, mode: "insensitive" } },
{ folder: { name: { contains: input.searchQuery, mode: "insensitive" } } },
],
}),
},
select: {
id: true,
name: true,
folder: { select: { name: true } },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 20,
...(cursor === undefined ? {} : { cursor: { id: cursor }, skip: 1 }),
});
if (allowed.length >= 5) break;
for (const project of candidates) {
if (!input.isOrgAdmin) {
const decision = await authorizer.can({
actor: { feishuOpenId: input.actorFeishuOpenId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: project.id },
});
if (!decision.allowed) continue;
}
allowed.push({
projectId: project.id,
name: project.name,
...(project.folder?.name !== undefined ? { folderName: project.folder.name } : {}),
});
if (allowed.length >= 5) break;
}
if (candidates.length < 20) break;
cursor = candidates.at(-1)?.id;
if (cursor === undefined) break;
}
return allowed;
}
+5 -1
View File
@@ -27,6 +27,8 @@ export interface CreateOrgAdminProjectInput {
readonly workspaceRoot: string;
readonly folderId?: string | undefined;
readonly sortKey?: string | undefined;
/** Stable internal identifier for resumable imports; ordinary callers must omit it. */
readonly projectId?: string | undefined;
}
export interface CreateFeishuChatProjectInput {
@@ -147,6 +149,7 @@ export async function createProjectFromOrgAdmin(
workspaceRoot: input.workspaceRoot,
folderId: input.folderId,
sortKey: input.sortKey,
projectId: input.projectId,
chatId: undefined,
});
}
@@ -291,6 +294,7 @@ async function createManagedProject(
readonly workspaceRoot: string;
readonly folderId: string | undefined;
readonly sortKey?: string | undefined;
readonly projectId?: string | undefined;
readonly chatId: string | undefined;
},
): Promise<ProjectOnboardingResult> {
@@ -301,7 +305,7 @@ async function createManagedProject(
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
requireActiveOrganizationStatus(organization.id, organization.status);
const projectId = createProjectId();
const projectId = input.projectId ?? createProjectId();
const workspaceDir = projectWorkspaceDir({
workspaceRoot: input.workspaceRoot,
organizationSlug: organization.slug,
@@ -0,0 +1,185 @@
import { mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { importLegacyProjects } from "../../src/deployment/legacyProjectImport.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
const temporaryRoots: string[] = [];
describe("legacy teaching-material project import", () => {
beforeEach(async () => {
await resetDb();
await prisma.user.create({
data: {
id: "legacy-import-owner",
feishuOpenId: "ou_legacy_owner",
displayName: "Legacy Import Owner",
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
},
});
});
afterEach(async () => {
while (temporaryRoots.length > 0) {
const root = temporaryRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
afterAll(async () => prisma.$disconnect());
it("imports each legacy project as an unbound resumable project under its old folder path", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-source-");
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
const projectSource = join(sourceRoot, "物理", "M-243-牛顿力学");
await mkdir(join(projectSource, "workspace", "chapters"), { recursive: true });
await mkdir(join(projectSource, "workspace", ".claude"), { recursive: true });
await mkdir(join(projectSource, "workspace", "chapters", ".cph"), { recursive: true });
await mkdir(join(projectSource, "_raw"), { recursive: true });
await writeFile(join(projectSource, "workspace", "project.toml"), "title = \"牛顿力学\"\n");
await writeFile(join(projectSource, "workspace", "chapters", "lesson.typ"), "= 牛顿第二定律\n");
await writeFile(join(projectSource, "workspace", ".claude", "session.json"), "{}\n");
await writeFile(join(projectSource, "workspace", "chapters", ".cph", "runtime.json"), "{}\n");
await writeFile(join(projectSource, "project.json"), "{\"id\":\"M-243\"}\n");
await writeFile(join(projectSource, "_raw", "source.txt"), "legacy source\n");
const stateFile = join(workspaceRoot, "migration-state", "state.json");
const manifest = [{
legacyId: "M-243",
name: "牛顿力学",
folderPath: ["物理"],
sourceRelativePath: "物理/M-243-牛顿力学",
}];
const first = await importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
});
const imported = first.projects["M-243"];
expect(imported).toBeDefined();
if (imported === undefined) throw new Error("missing imported project state");
const project = await prisma.project.findUniqueOrThrow({
where: { id: imported.projectId },
include: { folder: { include: { parent: true } }, groupBindings: true },
});
expect(project.name).toBe("牛顿力学");
expect(project.folder?.name).toBe("物理");
expect(project.folder?.parent?.name).toBe("旧教学资产");
expect(project.groupBindings).toEqual([]);
await expect(readFile(join(imported.workspaceDir, "chapters", "lesson.typ"), "utf8"))
.resolves.toBe("= 牛顿第二定律\n");
await expect(readFile(join(imported.workspaceDir, ".claude", "session.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(imported.workspaceDir, "chapters", ".cph", "runtime.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "project.json"), "utf8"))
.resolves.toContain("M-243");
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "raw", "source.txt"), "utf8"))
.resolves.toBe("legacy source\n");
await unlink(stateFile);
const second = await importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
});
expect(second.projects["M-243"]?.projectId).toBe(imported.projectId);
await expect(prisma.project.count({ where: { organizationId: DEFAULT_ORG_ID } })).resolves.toBe(1);
expect(JSON.parse(await readFile(stateFile, "utf8"))).toMatchObject({
version: 1,
projects: { "M-243": { projectId: imported.projectId } },
});
await expect(prisma.auditEntry.count({
where: { projectId: imported.projectId, action: "legacy_project.imported" },
})).resolves.toBe(1);
await writeFile(join(imported.workspaceDir, ".legacy-source", "migration.json"), "not json\n");
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
})).rejects.toThrow(/invalid legacy completion marker/);
await expect(prisma.project.count({ where: { id: imported.projectId } })).resolves.toBe(1);
});
it("rejects symlinks instead of importing paths outside the staged project", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-symlink-");
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
const projectSource = join(sourceRoot, "legacy");
await mkdir(join(projectSource, "workspace"), { recursive: true });
await writeFile(join(projectSource, "project.json"), "{}\n");
await symlink("/etc/passwd", join(projectSource, "workspace", "outside"));
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile: join(workspaceRoot, "state.json"),
projects: [{ legacyId: "symlink", name: "Symlink", folderPath: [], sourceRelativePath: "legacy" }],
})).rejects.toThrow(/rejects symbolic link/);
await expect(prisma.project.count()).resolves.toBe(0);
});
it("rejects a manifest path that escapes the staged source root", async () => {
const parent = await temporaryRoot("cph-legacy-escape-");
const sourceRoot = join(parent, "source");
const outside = join(parent, "outside");
await mkdir(sourceRoot);
await mkdir(outside);
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot: await temporaryRoot("cph-legacy-target-"),
sourceRoot,
stateFile: join(parent, "state.json"),
projects: [{
legacyId: "escape",
name: "Escape",
folderPath: [],
sourceRelativePath: "../outside",
}],
})).rejects.toThrow(/escapes source root/);
});
it("rejects malformed resume state before creating a project", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-state-source-");
const workspaceRoot = await temporaryRoot("cph-legacy-state-target-");
const stateFile = join(workspaceRoot, "state.json");
await writeFile(stateFile, JSON.stringify({ version: 1, projects: { broken: { status: "MAYBE" } } }));
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: [],
})).rejects.toThrow(/invalid legacy import state fields/);
await expect(prisma.project.count()).resolves.toBe(0);
});
});
async function temporaryRoot(prefix: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix));
temporaryRoots.push(root);
return root;
}
+94
View File
@@ -361,6 +361,100 @@ describe("trigger full lifecycle (integration)", () => {
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目");
});
it("uses unbound-chat mention text to search bindable projects", async () => {
await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER");
const folder = await prisma.folder.create({
data: { organizationId: DEFAULT_ORG_ID, name: "物理竞赛" },
});
await prisma.project.createMany({
data: [
{
id: "p-search-newton",
organizationId: DEFAULT_ORG_ID,
folderId: folder.id,
name: "牛顿力学专题",
workspaceDir: join(await tempWorkspaceRoot(), "p-search-newton"),
},
{
id: "p-search-optics",
organizationId: DEFAULT_ORG_ID,
folderId: folder.id,
name: "几何光学专题",
workspaceDir: join(await tempWorkspaceRoot(), "p-search-optics"),
},
],
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: await tempWorkspaceRoot(),
});
await trigger(makeEvent("chat-onboard-search", "@_user_1 牛顿", "ou_onboard_search"), rt);
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
project_onboarding: {
action: "bind_project",
organization_id: DEFAULT_ORG_ID,
project_id: "p-search-newton",
},
});
expect(cardActionValues(rt.sentCards[0])).not.toContainEqual(expect.objectContaining({
project_onboarding: expect.objectContaining({ project_id: "p-search-optics" }),
}));
expect(JSON.stringify(rt.sentCards[0])).toContain("牛顿");
expect(runAgentCalls).toHaveLength(0);
});
it("continues searching past unauthorized matches for a manageable project", async () => {
await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER");
const workspaceRoot = await tempWorkspaceRoot();
await prisma.project.createMany({
data: [
...Array.from({ length: 20 }, (_, index) => ({
id: `z-search-denied-${String(index).padStart(2, "0")}`,
organizationId: DEFAULT_ORG_ID,
name: `迁移项目 ${index}`,
workspaceDir: join(workspaceRoot, `denied-${index}`),
})),
{
id: "a-search-allowed",
organizationId: DEFAULT_ORG_ID,
name: "迁移项目 可管理",
workspaceDir: join(workspaceRoot, "allowed"),
},
],
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "a-search-allowed",
principalType: "USER",
principalId: "ou_onboard_page",
role: "MANAGE",
},
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: workspaceRoot,
});
await trigger(makeEvent("chat-onboard-page", "@_user_1 迁移", "ou_onboard_page"), rt);
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
project_onboarding: {
action: "bind_project",
organization_id: DEFAULT_ORG_ID,
project_id: "a-search-allowed",
},
});
});
it("batches quick text messages from the same chat and sender into one run", async () => {
await seedProject("proj-1b", "chat-1b");
const trigger = makeTriggerHandler({
@@ -0,0 +1,61 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
const execute = promisify(execFile);
const temporaryRoots: string[] = [];
describe("legacy project manifest builder", () => {
afterEach(async () => {
while (temporaryRoots.length > 0) {
const root = temporaryRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
it("stops at a project root and excludes trash projects", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-"));
temporaryRoots.push(root);
const projectRoot = join(root, "物理", "legacy__牛顿力学");
await mkdir(join(projectRoot, "workspace", "nested"), { recursive: true });
await mkdir(join(projectRoot, "_raw"), { recursive: true });
await mkdir(join(root, ".trash", "deleted"), { recursive: true });
await writeFile(join(projectRoot, "project.json"), JSON.stringify({
id: "legacy",
name: "牛顿力学",
folderPath: ["物理"],
}));
await writeFile(join(projectRoot, "workspace", "nested", "project.json"), "not metadata");
await writeFile(join(projectRoot, "_raw", "project.json"), "not metadata");
await writeFile(join(root, ".trash", "deleted", "project.json"), JSON.stringify({
id: "deleted",
name: "Deleted",
}));
const { stdout } = await execute(process.execPath, [
resolve("deploy/build_legacy_project_manifest.mjs"),
root,
]);
expect(JSON.parse(stdout)).toEqual([{
legacyId: "legacy",
name: "牛顿力学",
folderPath: ["物理"],
sourceRelativePath: "物理/legacy__牛顿力学",
}]);
});
it("rejects symlinked entries instead of silently omitting them", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-link-"));
temporaryRoots.push(root);
await symlink("/tmp", join(root, "linked-project"));
await expect(execute(process.execPath, [
resolve("deploy/build_legacy_project_manifest.mjs"),
root,
])).rejects.toMatchObject({ stderr: expect.stringContaining("rejects symbolic link") });
});
});