This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.gitea
|
||||
.next
|
||||
node_modules
|
||||
data/projects/*
|
||||
!.gitkeep
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -1,5 +1,9 @@
|
||||
name: CI
|
||||
|
||||
concurrency:
|
||||
group: rednote-tool-studio-production
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -21,3 +25,16 @@ jobs:
|
||||
run: npm test
|
||||
- name: Production build
|
||||
run: npm run build
|
||||
- name: Deploy production container
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
STUDIO_USERNAME: studio
|
||||
STUDIO_PASSWORD: ${{ secrets.STUDIO_PASSWORD }}
|
||||
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
|
||||
GITHUB_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
if [ -z "$STUDIO_PASSWORD" ]; then
|
||||
echo "Deployment skipped: repository secret STUDIO_PASSWORD is not configured"
|
||||
exit 0
|
||||
fi
|
||||
node scripts/deploy.mjs
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Rednote Tool Studio 是确定性的项目、构建与校验服务,不内嵌任何 LLM。Codex、Claude Code 或其他编码代理通过 HTTP 操作它。
|
||||
|
||||
生产环境使用 HTTP Basic Auth。用户名默认为 `studio`,密码由部署方保存在 `STUDIO_PASSWORD` 中。代理调用所有业务 API 时都应发送 `Authorization` 请求头;`GET /api/health` 是唯一无需认证的接口。
|
||||
|
||||
## 建议工作流
|
||||
|
||||
1. `POST /api/projects` 创建项目,保存响应中的 `id` 和 `revision`。
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
FROM node:24-bookworm-slim AS dependencies
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
FROM node:24-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0
|
||||
RUN groupadd --system --gid 1001 studio && useradd --system --uid 1001 --gid studio studio
|
||||
COPY --from=builder --chown=studio:studio /app/.next/standalone ./
|
||||
COPY --from=builder --chown=studio:studio /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=studio:studio /app/templates ./templates
|
||||
COPY --from=builder --chown=studio:studio /app/vendor ./vendor
|
||||
RUN mkdir -p /app/data/projects && chown -R studio:studio /app/data
|
||||
USER studio
|
||||
EXPOSE 3000
|
||||
VOLUME ["/app/data/projects"]
|
||||
HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=8 CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -14,6 +14,7 @@
|
||||
- 固定版本 `minitool-zip-builder 1.6.0` 审计
|
||||
- 校验通过后生成 ZIP
|
||||
- 闲置项目 `node_modules` 自动清理
|
||||
- 生产环境 Basic Auth、Docker 健康检查、持久化部署与失败回滚
|
||||
|
||||
## 本地运行
|
||||
|
||||
@@ -24,6 +25,16 @@ npm run dev
|
||||
|
||||
访问 `http://localhost:3000`。API 契约位于 `http://localhost:3000/openapi.json`,编码代理说明见 [AGENT_GUIDE.md](./AGENT_GUIDE.md)。
|
||||
|
||||
开发模式未配置 `STUDIO_PASSWORD` 时允许本地访问;生产模式必须提供密码,否则业务页面和 API 返回 503。`GET /api/health` 始终公开,用于容器健康检查。
|
||||
|
||||
## 持续部署
|
||||
|
||||
`main` 分支的 CI 全部通过后,会通过 runner 挂载的 Docker socket 构建并部署 `rednote-tool-studio` 容器。默认映射宿主机端口 `3000`,使用命名卷 `rednote-tool-studio-data` 保存项目。
|
||||
|
||||
仓库 Actions Secret 必须包含 `STUDIO_PASSWORD`。可选仓库变量 `DEPLOY_PORT` 用于覆盖宿主机端口。部署先停止并保留旧容器,候选容器健康后才删除旧容器;候选失败时恢复旧版本。
|
||||
|
||||
首次配置时,在 Gitea 仓库的 `Settings → Actions → Secrets` 创建 `STUDIO_PASSWORD`。若本机由 Codex 生成过密码,可从 macOS Keychain 的 service `rednote-tool-studio`、account `studio` 读取;不要把密码提交到仓库或粘贴到聊天中。Secret 尚未配置时,CI 会完成全部验证并安全跳过部署。
|
||||
|
||||
## 闲置依赖清理
|
||||
|
||||
服务启动后每小时扫描一次托管项目。项目超过 24 小时没有 API、预览或构建访问时,会删除:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ status: "ok", service: "rednote-tool-studio" });
|
||||
}
|
||||
@@ -6,7 +6,9 @@ export function GET(request: Request) {
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Rednote Tool Studio API", version: "0.1.0", description: "供 Codex 等外部编码代理创建、修改、构建、校验并打包小红书离线 H5 小工具。写操作使用 revision 进行乐观并发控制。" },
|
||||
servers: [{ url: origin }],
|
||||
security: [{ basicAuth: [] }],
|
||||
paths: {
|
||||
"/api/health": { get: { operationId: "health", summary: "无需认证的容器健康检查", security: [], responses: { "200": { description: "服务健康" } } } },
|
||||
"/api/projects": {
|
||||
get: { operationId: "listProjects", summary: "列出项目", responses: { "200": { description: "项目列表" } } },
|
||||
post: { operationId: "createProject", summary: "从模板创建项目", requestBody: { required: true, content: { "application/json": { schema: { type: "object", properties: { name: { type: "string" }, template: { type: "string", default: "basic" } } } } } }, responses: { "201": { description: "已创建" } } },
|
||||
@@ -25,6 +27,7 @@ export function GET(request: Request) {
|
||||
"/api/maintenance/cleanup": { post: { operationId: "cleanupIdleNodeModules", summary: "清理闲置项目 node_modules", requestBody: { content: { "application/json": { schema: { type: "object", properties: { idleHours: { type: "number", minimum: 0, default: 24 } } } } } }, responses: { "200": { description: "清理结果" } } } },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: { basicAuth: { type: "http", scheme: "basic" } },
|
||||
parameters: {
|
||||
ProjectId: { name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
FilePath: { name: "path", in: "path", required: true, schema: { type: "string" }, description: "项目相对路径,例如 src/App.jsx" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const config: NextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["esbuild"],
|
||||
};
|
||||
|
||||
|
||||
Generated
+926
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"dockerode": "^4.0.8",
|
||||
"tar-fs": "^3.1.1",
|
||||
"tsx": "^4.20.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
function sameValue(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
if (request.nextUrl.pathname === "/api/health") return NextResponse.next();
|
||||
|
||||
const password = process.env.STUDIO_PASSWORD;
|
||||
const username = process.env.STUDIO_USERNAME || "studio";
|
||||
if (!password) {
|
||||
if (process.env.NODE_ENV !== "production") return NextResponse.next();
|
||||
return NextResponse.json({ error: "STUDIO_PASSWORD is not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const authorization = request.headers.get("authorization") || "";
|
||||
const expected = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
||||
if (!sameValue(authorization, expected)) {
|
||||
return new NextResponse("Authentication required", {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": 'Basic realm="Rednote Tool Studio", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import process from "node:process";
|
||||
import Docker from "dockerode";
|
||||
import tar from "tar-fs";
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
const service = "rednote-tool-studio";
|
||||
const candidate = `${service}-candidate`;
|
||||
const previous = `${service}-previous`;
|
||||
const volume = `${service}-data`;
|
||||
const sha = (process.env.GITHUB_SHA || process.env.CI_COMMIT_SHA || "dev").slice(0, 12);
|
||||
const image = `${service}:${sha}`;
|
||||
const port = process.env.DEPLOY_PORT || "3000";
|
||||
const password = process.env.STUDIO_PASSWORD;
|
||||
const username = process.env.STUDIO_USERNAME || "studio";
|
||||
|
||||
if (!password) throw new Error("STUDIO_PASSWORD secret is required");
|
||||
if (!/^\d{2,5}$/.test(port) || Number(port) > 65535) throw new Error("DEPLOY_PORT must be a valid TCP port");
|
||||
|
||||
async function removeContainer(name) {
|
||||
const container = docker.getContainer(name);
|
||||
try {
|
||||
const state = await container.inspect();
|
||||
if (state.State.Running) await container.stop({ t: 10 });
|
||||
await container.remove({ force: true });
|
||||
} catch (error) {
|
||||
if (error.statusCode !== 404) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealth(container, timeoutMs = 90_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const state = await container.inspect();
|
||||
const health = state.State.Health?.Status;
|
||||
if (health === "healthy") return;
|
||||
if (health === "unhealthy" || !state.State.Running) throw new Error(`Candidate container is ${health || state.State.Status}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
}
|
||||
throw new Error("Timed out waiting for candidate health check");
|
||||
}
|
||||
|
||||
async function followBuild(stream) {
|
||||
await new Promise((resolve, reject) => {
|
||||
docker.modem.followProgress(stream, (error, output) => error ? reject(error) : resolve(output), (event) => {
|
||||
if (event.stream) process.stdout.write(event.stream);
|
||||
if (event.error) process.stderr.write(`${event.error}\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Building ${image}`);
|
||||
const context = tar.pack(process.cwd(), {
|
||||
ignore: (name) => [".git", ".next", "node_modules"].some((part) => name === part || name.includes(`/${part}/`)),
|
||||
});
|
||||
await followBuild(await docker.buildImage(context, { t: image, dockerfile: "Dockerfile" }));
|
||||
|
||||
try { await docker.createVolume({ Name: volume }); } catch (error) { if (error.statusCode !== 409) throw error; }
|
||||
await removeContainer(candidate);
|
||||
await removeContainer(previous);
|
||||
|
||||
const current = docker.getContainer(service);
|
||||
let hasCurrent = false;
|
||||
try {
|
||||
const state = await current.inspect();
|
||||
hasCurrent = true;
|
||||
if (state.State.Running) await current.stop({ t: 15 });
|
||||
await current.rename({ name: previous });
|
||||
} catch (error) {
|
||||
if (error.statusCode !== 404) throw error;
|
||||
}
|
||||
|
||||
let next;
|
||||
try {
|
||||
next = await docker.createContainer({
|
||||
name: candidate,
|
||||
Image: image,
|
||||
Env: [`STUDIO_USERNAME=${username}`, `STUDIO_PASSWORD=${password}`, "NODE_ENV=production"],
|
||||
ExposedPorts: { "3000/tcp": {} },
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
PortBindings: { "3000/tcp": [{ HostIp: "0.0.0.0", HostPort: port }] },
|
||||
Mounts: [{ Type: "volume", Source: volume, Target: "/app/data/projects" }],
|
||||
},
|
||||
});
|
||||
await next.start();
|
||||
await waitForHealth(next);
|
||||
await next.rename({ name: service });
|
||||
if (hasCurrent) await removeContainer(previous);
|
||||
console.log(`Deployed ${image} on port ${port}`);
|
||||
} catch (error) {
|
||||
if (next) await removeContainer(candidate).catch(() => undefined);
|
||||
if (hasCurrent) {
|
||||
const rollback = docker.getContainer(previous);
|
||||
await rollback.rename({ name: service });
|
||||
await rollback.start();
|
||||
console.error("Candidate failed; previous container restored");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
Reference in New Issue
Block a user