feat: 添加 cloud 命令(cs-cloud 二进制下载与命令转发)

This commit is contained in:
DoSun 2026-05-07 10:44:43 +08:00
parent ae6366c504
commit 9a8faf1481
2 changed files with 268 additions and 0 deletions

259
src/cli/handlers/cloud.ts Normal file
View File

@ -0,0 +1,259 @@
import { execFile, spawn } from "child_process"
import { createHash } from "node:crypto"
import { createWriteStream, promises as fsp } from "node:fs"
import fs from "fs"
import os from "os"
import path from "path"
import { getCoStrictBaseURL } from "src/costrict/provider/auth.js"
const CLOUD_API_PREFIX = "cloud-api"
function getCloudBaseUrl(): string {
const raw = getCoStrictBaseURL().replace(/\/$/, "")
if (raw.endsWith(`/${CLOUD_API_PREFIX}`)) return raw
return `${raw}/${CLOUD_API_PREFIX}`
}
function csCloudBin(): string {
const ext = process.platform === "win32" ? ".exe" : ""
const binDir = path.join(os.homedir(), ".costrict", "bin")
return path.join(binDir, `cs-cloud${ext}`)
}
function getReleasePlatform(): string {
const p = process.platform === "win32" ? "windows" : process.platform
const arch = process.arch === "x64" ? "amd64" : process.arch === "arm64" ? "arm64" : "amd64"
return `${p}-${arch}`
}
interface UpdateCheckResponse {
can_update: boolean
version: string
changelog?: string
download_url?: string
sha256?: string
force?: boolean
min_client_version?: string
release_date?: string
size?: number
}
async function fetchUpdateInfo(): Promise<UpdateCheckResponse> {
const base = getCloudBaseUrl()
const platform = getReleasePlatform()
const url = `${base}/api/updates/check?platform=${encodeURIComponent(platform)}&version=0.0.0`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`update check failed: ${res.status} ${await res.text().catch(() => "")}`)
}
return (await res.json()) as UpdateCheckResponse
}
type ArchiveFormat = "targz" | "zip" | "raw"
function detectFormatByUrl(url: string): ArchiveFormat | undefined {
const lower = url.toLowerCase()
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "targz"
if (lower.endsWith(".zip")) return "zip"
return undefined
}
async function detectFormatByMagic(file: string): Promise<ArchiveFormat> {
const handle = await fsp.open(file, "r")
const buf = Buffer.alloc(4)
await handle.read(buf, 0, 4, 0)
await handle.close()
if (buf[0] === 0x1f && buf[1] === 0x8b) return "targz"
if (buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04) return "zip"
return "raw"
}
async function detectArchiveFormat(file: string, url: string): Promise<ArchiveFormat> {
return detectFormatByUrl(url) ?? (await detectFormatByMagic(file))
}
const BIN_NAME = process.platform === "win32" ? "cs-cloud.exe" : "cs-cloud"
async function extractFromTarGz(archive: string, outDir: string): Promise<string> {
await new Promise<void>((resolve, reject) => {
execFile("tar", ["xzf", archive, "-C", outDir], (err) => (err ? reject(err) : resolve()))
})
for (const name of [BIN_NAME, "cs-cloud"]) {
const p = path.join(outDir, name)
if (fs.existsSync(p)) {
const dest = path.join(outDir, BIN_NAME)
if (p !== dest) await fsp.rename(p, dest)
return dest
}
}
throw new Error("cs-cloud binary not found in tar.gz archive")
}
async function extractFromZip(archive: string, outDir: string): Promise<string> {
const bin = path.join(outDir, BIN_NAME)
if (process.platform === "win32") {
const zipPath = archive.endsWith(".zip") ? archive : archive + ".zip"
if (zipPath !== archive) await fsp.rename(archive, zipPath)
await new Promise<void>((resolve, reject) => {
execFile(
"powershell",
["-NoProfile", "-Command", `Expand-Archive -LiteralPath '${zipPath}' -DestinationPath '${outDir}' -Force`],
(err) => (err ? reject(err) : resolve()),
)
}).finally(() => {
if (zipPath !== archive) fsp.rename(zipPath, archive).catch(() => {})
})
const extracted = path.join(outDir, "cs-cloud.exe")
if (fs.existsSync(extracted)) {
if (extracted !== bin) await fsp.rename(extracted, bin)
} else if (!fs.existsSync(bin)) {
throw new Error("cs-cloud.exe not found in zip archive")
}
return bin
}
await new Promise<void>((resolve, reject) => {
execFile("unzip", ["-o", archive, "-d", outDir], (err) => (err ? reject(err) : resolve()))
})
for (const name of [BIN_NAME, "cs-cloud"]) {
const p = path.join(outDir, name)
if (fs.existsSync(p)) {
if (p !== bin) await fsp.rename(p, bin)
return bin
}
}
throw new Error("cs-cloud binary not found in zip archive")
}
async function extractBinary(archive: string, outDir: string, url: string): Promise<string> {
const fmt = await detectArchiveFormat(archive, url)
switch (fmt) {
case "targz":
return extractFromTarGz(archive, outDir)
case "zip":
return extractFromZip(archive, outDir)
default: {
const dest = path.join(outDir, BIN_NAME)
await fsp.rename(archive, dest)
if (process.platform !== "win32") await fsp.chmod(dest, 0o755)
return dest
}
}
}
async function downloadToTemp(url: string, expectedSha256?: string, totalSize?: number): Promise<string> {
const tmp = path.join(os.tmpdir(), `cs-cloud-download-${Date.now()}`)
const res = await fetch(url)
if (!res.ok) throw new Error(`download failed: ${res.status}`)
const body = res.body
if (!body) throw new Error("empty response body")
const ws = createWriteStream(tmp, { mode: 0o755 })
const hash = expectedSha256 ? createHash("sha256") : null
const reader = body.getReader()
let downloaded = 0
let lastLog = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
hash?.update(value)
ws.write(value)
downloaded += value.length
const now = Date.now()
if (totalSize && now - lastLog > 500) {
const pct = Math.min(Math.round((downloaded / totalSize) * 100), 100)
const mb = (downloaded / 1048576).toFixed(1)
const total = (totalSize / 1048576).toFixed(1)
process.stdout.write(`\rdownloading... ${pct}% (${mb}/${total} MB)`)
lastLog = now
}
}
} finally {
reader.releaseLock()
}
if (totalSize) process.stdout.write("\r")
ws.end()
await new Promise<void>((resolve, reject) => {
ws.on("finish", resolve)
ws.on("error", reject)
})
if (hash && expectedSha256) {
const actual = hash.digest("hex")
if (actual !== expectedSha256) {
await fsp.unlink(tmp).catch(() => {})
throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${actual}`)
}
}
return tmp
}
async function ensureCsCloud(): Promise<string> {
const bin = csCloudBin()
if (fs.existsSync(bin)) return bin
console.log("cs-cloud not found, downloading...")
const info = await fetchUpdateInfo()
if (!info.can_update || !info.download_url) {
throw new Error("no cs-cloud release available for your platform")
}
const binDir = path.dirname(bin)
await fsp.mkdir(binDir, { recursive: true })
const archive = await downloadToTemp(info.download_url, info.sha256, info.size)
try {
const extracted = await extractBinary(archive, binDir, info.download_url)
if (extracted !== bin && fs.existsSync(extracted)) {
await fsp.rename(extracted, bin)
}
} finally {
await fsp.unlink(archive).catch(() => {})
}
if (process.platform === "darwin") {
await new Promise<void>((resolve, reject) => {
execFile("xattr", ["-d", "com.apple.quarantine", bin], (err) => {
if (err && !(err as NodeJS.ErrnoException).message?.includes("NO SUCH")) reject(err)
else resolve()
})
})
}
console.log(`cs-cloud ${info.version} installed`)
return bin
}
async function runCsCloud(args: string[]): Promise<void> {
const bin = await ensureCsCloud()
const child = spawn(bin, args, {
stdio: "inherit",
windowsHide: false,
env: { ...process.env },
})
const code = await new Promise<number | null>((resolve) => {
child.on("error", (err) => {
console.error(`failed to run cs-cloud: ${err.message}`)
resolve(1)
})
child.on("exit", resolve)
})
process.exit(code ?? 1)
}
export async function cloudHandler(rawArgs: string[]): Promise<void> {
if (rawArgs.length === 0) {
console.error("specify a subcommand. usage: csc cloud <command> [args...]")
process.exit(1)
}
if (rawArgs[0] === "upgrade") {
const bin = csCloudBin()
if (fs.existsSync(bin)) {
await runCsCloud(rawArgs)
return
}
await ensureCsCloud()
return
}
await runCsCloud(rawArgs)
}

View File

@ -6630,6 +6630,15 @@ async function run(): Promise<CommanderCommand> {
});
}
// Cloud command - manage cs-cloud daemon (download binary + forward commands)
program
.command('cloud [args...]')
.description('Manage cloud daemon (register device and connect via WebSocket tunnel)')
.action(async (args: string[]) => {
const { cloudHandler } = await import('./cli/handlers/cloud.js')
await cloudHandler(args)
})
// Update command - check and install updates
program
.command('update')