@tcb-sandbox/cli 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { spawnSandboxed } from "./sandbox.js";
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
8
  function readJsonVersion(pkgPath) {
8
9
  try {
@@ -125,7 +126,7 @@ export function defaultWorkspaceRoot() {
125
126
  return path.join(os.homedir(), ".tcb-sandbox", "workspaces");
126
127
  }
127
128
  const MOTD_ASCII = "\n ____ _ _ ____\n / ___| | ___ _ _ __| | __ ) __ _ ___ ___\n | | | |/ _ \\| | | |/ _` | _ \\ / _` / __|/ _ \\\n | |___| | (_) | |_| | (_| | |_) | (_| \\__ \\ __/\n \\____|_|\\___/ \\__,_|\\__,_|____/ \\__,_|___/\\___|\n Remote Workspace\n";
128
- export function printServeBanner(opts, trwMain) {
129
+ export function printServeBanner(opts, trwMain, sandboxStatus) {
129
130
  const trwVer = trwPackageVersion(trwMain);
130
131
  const cliVer = cliPackageVersion();
131
132
  const displayHost = opts.host === "0.0.0.0" ? "127.0.0.1" : opts.host;
@@ -138,9 +139,13 @@ export function printServeBanner(opts, trwMain) {
138
139
  lines.push(MOTD_ASCII.replace(/^\n/, "").replace(/\n$/, ""));
139
140
  lines.push("");
140
141
  lines.push(` ${S.dim("Listen :")} ${base}`);
141
- lines.push(` ${S.dim("Session :")} ${S.bold("X-Cloudbase-Session-Id")} ${S.dim("(示例 test-03271546 → 子目录 test-03271546)")}`);
142
+ lines.push(` ${S.dim("Session :")} ${S.bold("X-Cloudbase-Session-Id")} ${S.dim("(不传时默认 default;示例 test-03271546 → 子目录 test-03271546)")}`);
142
143
  lines.push(` ${S.dim("Workspace:")} ${wsRoot}${path.sep}${S.dim("<session-id>")}`);
143
144
  lines.push(` ${S.dim("Disk :")} ${disk}`);
145
+ if (sandboxStatus) {
146
+ const sandboxColor = sandboxStatus.startsWith("active") ? S.green : S.yellow;
147
+ lines.push(` ${S.dim("Sandbox :")} ${sandboxColor(sandboxStatus)}`);
148
+ }
144
149
  lines.push(` ${S.dim("Runtime :")} Node ${nodeVer} · Bun ${bunVersion()} · Python ${pythonVersion()}`);
145
150
  lines.push(` ${S.dim("Versions :")} TCB ${trwVer} · CLI ${cliVer}`);
146
151
  lines.push("");
@@ -154,44 +159,138 @@ export function printServeBanner(opts, trwMain) {
154
159
  lines.push(` ${S.cyan("skills")} AI 技能管理${S.dim("(add / list / find)")}`);
155
160
  lines.push(` ${S.cyan("tmux")} 多窗口${S.dim("(Ctrl-b c 新建 · Ctrl-b % 分屏)")}`);
156
161
  lines.push("");
157
- lines.push(` ${S.yellow("▸")} ${S.bold("须知")} ${S.dim("(本机 serve,非 SCF 容器)")}`);
158
- lines.push(` ${S.dim("·")} 默认无鉴权;默认仅监听回环,${S.bold("--host 0.0.0.0")} 会暴露到网络,请自行评估风险`);
159
- lines.push(` ${S.dim("·")} ${S.bold("Ctrl+C")} 停止本进程;无容器冻结 / TTL / 唤醒策略`);
160
- lines.push(` ${S.dim("·")} 若系统 Python 受 PEP 668 限制,用 ${S.bold("uv")} ${S.dim("(推荐)")} 或 ${S.bold("python3 -m venv")}`);
161
- lines.push(` ${S.dim("·")} 各 session 工作区在 ${S.bold(wsRoot + path.sep + "<session-id>")} 下;工具侧路径策略与 tcb-remote-workspace 一致`);
162
- lines.push(` ${S.dim("·")} 长时间任务优先 ${S.bold("tmux")} 或 ${S.bold("tcb-sandbox pty")};子进程分离 ${S.bold("(cmd > log 2>&1 &)")} 仍可能随会话结束被清理`);
163
- lines.push("");
164
162
  process.stdout.write(lines.join("\n"));
165
163
  }
166
- export function runServe(opts) {
167
- const trwMain = resolveTcbEmbeddedMain();
168
- fs.mkdirSync(opts.workspaceRoot, { recursive: true });
169
- printServeBanner(opts, trwMain);
170
- const child = spawn(process.execPath, [trwMain], {
171
- stdio: "inherit",
172
- env: {
173
- ...process.env,
174
- HOST: opts.host,
175
- PORT: String(opts.port),
176
- WORKSPACE_ROOT: opts.workspaceRoot,
177
- },
178
- });
179
- const forward = (sig) => {
164
+ async function waitForReady(host, port, timeoutMs) {
165
+ const base = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
166
+ const deadline = Date.now() + timeoutMs;
167
+ const interval = 300;
168
+ while (Date.now() < deadline) {
180
169
  try {
181
- child.kill(sig);
170
+ const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(2000) });
171
+ if (res.ok)
172
+ return true;
182
173
  }
183
174
  catch {
184
175
  }
176
+ await new Promise((r) => setTimeout(r, interval));
177
+ }
178
+ return false;
179
+ }
180
+ async function isPortInUse(host, port) {
181
+ const net = await import("node:net");
182
+ return new Promise((resolve) => {
183
+ const server = net.createServer();
184
+ server.once("error", (err) => {
185
+ resolve(err.code === "EADDRINUSE");
186
+ });
187
+ server.once("listening", () => {
188
+ server.close(() => resolve(false));
189
+ });
190
+ server.listen(port, host);
191
+ });
192
+ }
193
+ const SHUTDOWN_TIMEOUT_MS = 10_000;
194
+ const MAX_RESTART_COUNT = 3;
195
+ const MIN_UPTIME_FOR_HEALTHY_MS = 5_000;
196
+ function ensureMcporterConfig(workspaceRoot) {
197
+ const mcporterDir = path.join(os.homedir(), ".mcporter");
198
+ const configPath = path.join(mcporterDir, "mcporter.json");
199
+ if (fs.existsSync(configPath))
200
+ return;
201
+ fs.mkdirSync(mcporterDir, { recursive: true });
202
+ const envId = process.env.CLOUDBASE_ENV_ID ?? "";
203
+ const mcpBin = process.env.CLOUDBASE_MCP_BIN ?? "";
204
+ const servers = {};
205
+ if (envId && mcpBin) {
206
+ servers.cloudbase = {
207
+ command: "node",
208
+ args: [mcpBin],
209
+ env: {
210
+ CLOUDBASE_ENV_ID: envId,
211
+ TENCENTCLOUD_SECRETID: process.env.TENCENTCLOUD_SECRETID ?? "",
212
+ TENCENTCLOUD_SECRETKEY: process.env.TENCENTCLOUD_SECRETKEY ?? "",
213
+ TENCENTCLOUD_SESSIONTOKEN: process.env.TENCENTCLOUD_SESSIONTOKEN ?? "",
214
+ INTEGRATION_IDE: process.env.INTEGRATION_IDE ?? "codebuddy",
215
+ WORKSPACE_FOLDER_PATHS: process.env.WORKSPACE_FOLDER_PATHS ?? "",
216
+ },
217
+ };
218
+ }
219
+ fs.writeFileSync(configPath, JSON.stringify({ mcpServers: servers }, null, 2), "utf8");
220
+ }
221
+ function spawnTrw(trwMain, env, workspaceRoot) {
222
+ if (process.platform === "darwin") {
223
+ const result = spawnSandboxed(trwMain, env, workspaceRoot);
224
+ return { child: result.child, sandboxStatus: result.status };
225
+ }
226
+ const child = spawn(process.execPath, [trwMain], { stdio: "inherit", env });
227
+ return { child, sandboxStatus: `skipped (${process.platform})` };
228
+ }
229
+ export async function runServe(opts) {
230
+ const trwMain = resolveTcbEmbeddedMain();
231
+ fs.mkdirSync(opts.workspaceRoot, { recursive: true });
232
+ ensureMcporterConfig(opts.workspaceRoot);
233
+ if (await isPortInUse(opts.host, opts.port)) {
234
+ process.stderr.write(`[serve] Port ${opts.port} is already in use on ${opts.host}. Try --port ${opts.port + 1}\n`);
235
+ return Promise.reject(new Error(`Port ${opts.port} is already in use`));
236
+ }
237
+ const env = {
238
+ ...process.env,
239
+ HOST: opts.host,
240
+ PORT: String(opts.port),
241
+ WORKSPACE_ROOT: opts.workspaceRoot,
185
242
  };
186
- process.on("SIGINT", () => forward("SIGINT"));
187
- process.on("SIGTERM", () => forward("SIGTERM"));
243
+ let shutdownRequested = false;
244
+ let restartCount = 0;
245
+ let bannerPrinted = false;
188
246
  return new Promise((resolve, reject) => {
189
- child.on("error", reject);
190
- child.on("close", (code, signal) => {
191
- if (signal)
192
- resolve(code);
193
- else
247
+ function startChild() {
248
+ const { child, sandboxStatus } = spawnTrw(trwMain, env, opts.workspaceRoot);
249
+ const startedAt = Date.now();
250
+ if (!bannerPrinted) {
251
+ waitForReady(opts.host, opts.port, 30_000).then((ready) => {
252
+ bannerPrinted = true;
253
+ printServeBanner(opts, trwMain, sandboxStatus);
254
+ if (!ready) {
255
+ process.stderr.write("[serve] WARNING: TRW did not become ready within 30s. Service may not be healthy.\n");
256
+ }
257
+ });
258
+ }
259
+ const forward = (sig) => {
260
+ shutdownRequested = true;
261
+ try {
262
+ child.kill(sig);
263
+ }
264
+ catch {
265
+ }
266
+ setTimeout(() => {
267
+ process.stderr.write(`[serve] TRW did not exit within ${SHUTDOWN_TIMEOUT_MS / 1000}s after ${sig}, forcing exit.\n`);
268
+ process.exit(1);
269
+ }, SHUTDOWN_TIMEOUT_MS).unref();
270
+ };
271
+ process.removeAllListeners("SIGINT");
272
+ process.removeAllListeners("SIGTERM");
273
+ process.on("SIGINT", () => forward("SIGINT"));
274
+ process.on("SIGTERM", () => forward("SIGTERM"));
275
+ child.on("error", reject);
276
+ child.on("close", (code, _signal) => {
277
+ if (shutdownRequested) {
278
+ resolve(code);
279
+ return;
280
+ }
281
+ const uptime = Date.now() - startedAt;
282
+ if (code !== 0 && restartCount < MAX_RESTART_COUNT && uptime >= MIN_UPTIME_FOR_HEALTHY_MS) {
283
+ restartCount++;
284
+ process.stderr.write(`[serve] TRW exited with code ${code} after ${Math.round(uptime / 1000)}s. Restarting (${restartCount}/${MAX_RESTART_COUNT})...\n`);
285
+ startChild();
286
+ return;
287
+ }
288
+ if (code !== 0 && restartCount >= MAX_RESTART_COUNT) {
289
+ process.stderr.write(`[serve] TRW crashed ${MAX_RESTART_COUNT} times. Giving up.\n`);
290
+ }
194
291
  resolve(code);
195
- });
292
+ });
293
+ }
294
+ startChild();
196
295
  });
197
296
  }