#!/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 '';\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}`);