feat(deploy): 产物包部署链路与 naku 打包 CLI

Docker Compose 五服务 + Debian slim 单文件运行时;migrate 幂等门闩;
naku pack/build(citty + clack);部署方案文档入档。
This commit is contained in:
2026-10-13 00:56:43 +08:00
parent 86782c63ad
commit 905d4ce77e
18 changed files with 1050 additions and 6 deletions
+255
View File
@@ -0,0 +1,255 @@
/**
* naku 打包/构建 CLI:citty 定义命令,@clack/prompts 负责交互。
* 有 TTY 且未给全 flag 时进交互;--yes / 无 TTY 则纯 flag。
*/
import { defineCommand, runMain } from "citty";
import * as p from "@clack/prompts";
import {
PACK_ITEMS,
PACK_TARGETS,
currentPlatformTarget,
packRelease,
type PackItem,
} from "./pack-core.ts";
function isInteractive(yes: boolean | undefined): boolean {
if (yes) return false;
return Boolean(process.stdin.isTTY);
}
function cancel(): never {
p.cancel("Operation cancelled");
process.exit(0);
}
function assertNotCancel<T>(value: T | symbol): T {
if (p.isCancel(value)) cancel();
return value as T;
}
const targetOption = {
target: {
type: "string",
description: `编译目标,如 ${PACK_TARGETS[0]};默认交互选择或 ${PACK_TARGETS[0]}`,
},
} as const;
const pack = defineCommand({
meta: {
name: "pack",
description: "打部署产物包(dist + 单文件 + drizzle)",
},
args: {
...targetOption,
only: {
type: "string",
description: `产物范围,逗号分隔;可选 ${PACK_ITEMS.join("|")}`,
},
out: {
type: "string",
default: "release",
description: "输出目录",
},
tarball: {
type: "boolean",
description: "额外打 tar.gz(默认交互询问,否则否)",
},
yes: {
type: "boolean",
alias: "y",
description: "全部默认,不进交互",
},
},
async run({ args }) {
const interactive = isInteractive(args.yes as boolean | undefined);
p.intro("NAKU 打包");
// --- target ---
let target = args.target as string | undefined;
if (!target) {
if (interactive) {
const picked = assertNotCancel(
await p.select({
message: "目标平台",
options: (() => {
const current = currentPlatformTarget();
const base = PACK_TARGETS.map((t) => ({
label: t === "bun-linux-x64" ? `${t}(服务器常用)` : t,
value: t,
}));
// 当前平台已在列表中则不再追加,避免重复
return PACK_TARGETS.includes(current as (typeof PACK_TARGETS)[number])
? base
: [
...base,
{
label: `${current}(当前平台,仅本地)`,
value: current,
},
];
})(),
initialValue: "bun-linux-x64",
}),
);
target = picked as string;
} else {
target = "bun-linux-x64";
}
}
// --- only ---
let only: PackItem[];
const onlyArg = args.only as string | undefined;
if (onlyArg) {
only = onlyArg
.split(",")
.map((s) => s.trim())
.filter(Boolean) as PackItem[];
for (const item of only) {
if (!PACK_ITEMS.includes(item)) {
p.cancel(`未知产物: ${item}`);
process.exit(1);
}
}
} else if (interactive) {
const picked = assertNotCancel(
await p.multiselect({
message: "打包内容",
options: PACK_ITEMS.map((item) => ({ label: item, value: item })),
initialValues: [...PACK_ITEMS],
required: true,
}),
);
only = picked as PackItem[];
} else {
only = [...PACK_ITEMS];
}
// --- out ---
const outDir = (args.out as string) || "release";
// --- tarball ---
let tarball = args.tarball as boolean | undefined;
if (tarball === undefined) {
if (interactive) {
tarball = assertNotCancel(
await p.confirm({ message: "是否额外打 tar.gz?", initialValue: false }),
) as boolean;
} else {
tarball = false;
}
}
const s = p.spinner();
s.start(`编译 ${target} · ${only.join(" + ")}`);
try {
const out = await packRelease({
target,
only,
outDir,
tarball,
onLog: (msg) => {
// spinner 期间不逐条刷屏,保留关键节点可在结束后看
if (msg.startsWith("tar ") || msg.startsWith("target=")) {
s.message(msg);
}
},
});
s.stop(`完成 → ${out}`);
p.outro(
[
`产物:${only.join(" · ")}`,
`目标:${target}`,
tarball ? "已附 tar.gz(仓库根目录)" : "未打 tar.gz",
"",
"服务器:覆盖 /opt/naku/release/ 后执行 docker compose up -d --build",
].join("\n"),
);
} catch (err) {
s.stop("失败");
p.cancel(err instanceof Error ? err.message : String(err));
process.exit(1);
}
},
});
const build = defineCommand({
meta: {
name: "build",
description: "构建产物到 bin/(当前平台,适合本地试跑)",
},
args: {
item: {
type: "positional",
required: false,
description: `可选:${PACK_ITEMS.join("|")},不填则交互多选`,
},
...targetOption,
yes: {
type: "boolean",
alias: "y",
description: "全部默认,不进交互",
},
},
async run({ args }) {
const interactive = isInteractive(args.yes as boolean | undefined);
const target =
(args.target as string | undefined) ?? currentPlatformTarget();
let only: PackItem[];
const positional = args.item as string | undefined;
if (positional) {
if (!PACK_ITEMS.includes(positional as PackItem)) {
p.cancel(`未知产物: ${positional}`);
process.exit(1);
}
only = [positional as PackItem];
} else if (interactive) {
p.intro("NAKU 本地构建");
only = assertNotCancel(
await p.multiselect({
message: "构建什么",
options: PACK_ITEMS.map((item) => ({ label: item, value: item })),
// 默认不选,由用户自选
initialValues: [],
required: true,
}),
) as PackItem[];
} else {
only = ["naku-server", "naku-engine"];
}
const s = p.spinner();
s.start(`构建 ${only.join(" + ")} → bin/`);
try {
await packRelease({
target,
only,
outDir: "bin",
tarball: false,
// 前端进 bin/dist/,避免与单文件混放
nestDist: true,
onLog: (msg) => {
if (msg.startsWith("compile") || msg.startsWith("build")) s.message(msg);
},
});
s.stop("完成");
p.outro(`已写入 bin/(${target})`);
} catch (err) {
s.stop("失败");
p.cancel(err instanceof Error ? err.message : String(err));
process.exit(1);
}
},
});
const main = defineCommand({
meta: {
name: "naku",
version: "0.0.0",
description: "NAKU 打包 / 本地构建",
},
subCommands: { pack, build },
});
runMain(main);
+153
View File
@@ -0,0 +1,153 @@
/**
* 打包核心:供 CLI / 脚本共用,不带交互。
* 产物:dist/ · naku-server · naku-engine · naku-migrate · drizzle/
*/
import { cp, mkdir, rm, stat, readdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
export const PACK_ITEMS = [
"dist",
"naku-server",
"naku-engine",
"naku-migrate",
"drizzle",
] as const;
export type PackItem = (typeof PACK_ITEMS)[number];
export const PACK_TARGETS = [
"bun-linux-x64",
"bun-linux-arm64",
"bun-darwin-arm64",
"bun-darwin-x64",
"bun-windows-x64",
"bun-windows-arm64",
] as const;
export function currentPlatformTarget(): string {
const arch = process.arch === "arm64" ? "arm64" : "x64";
if (process.platform === "win32") return `bun-windows-${arch}`;
if (process.platform === "darwin") return `bun-darwin-${arch}`;
return `bun-linux-${arch}`;
}
export interface PackOptions {
target: string;
only: PackItem[];
outDir: string;
tarball?: boolean;
root?: string;
/** true:前端放到 <out>/dist/(部署包);false:直接铺在 <out>/(本地 dist) */
nestDist?: boolean;
onLog?: (msg: string) => void;
}
function fail(msg: string): never {
throw new Error(msg);
}
async function mustExist(path: string, label: string): Promise<void> {
if (!existsSync(path)) fail(`missing ${label}: ${path}`);
}
async function nonEmptyDir(path: string, label: string): Promise<void> {
const entries = await readdir(path);
if (entries.length === 0) fail(`empty ${label}: ${path}`);
}
async function run(cmd: string[], cwd: string, onLog?: (m: string) => void) {
onLog?.(`$ ${cmd.join(" ")}`);
const proc = Bun.spawn(cmd, { cwd, stdout: "inherit", stderr: "inherit" });
const code = await proc.exited;
if (code !== 0) fail(`command failed (${code}): ${cmd.join(" ")}`);
}
export async function packRelease(opts: PackOptions): Promise<string> {
const root = opts.root ?? resolve(import.meta.dir, "..");
const releaseDir = resolve(root, opts.outDir);
const log = opts.onLog ?? (() => {});
const only = new Set(opts.only);
const needBin = (name: PackItem) => only.has(name);
log(`target=${opts.target}`);
log(`out=${releaseDir}`);
// 输出目录:只删本次会写入的条目,避免误删未选产物的旧文件?——部署包语义为整包覆盖,仍清空
await rm(releaseDir, { recursive: true, force: true });
await mkdir(releaseDir, { recursive: true });
if (needBin("dist")) {
log("build web…");
await run(["bun", "run", "build:web"], root, log);
const webDist = join(root, "apps", "web", "dist");
await mustExist(webDist, "web dist");
const dest = opts.nestDist === false ? releaseDir : join(releaseDir, "dist");
await cp(webDist, dest, { recursive: true });
}
const compileOne = async (entry: string, name: PackItem) => {
if (!needBin(name)) return;
log(`compile ${name}…`);
await run(
[
"bun",
"build",
"--compile",
`--target=${opts.target}`,
`--outfile=${join(releaseDir, name)}`,
entry,
],
root,
log,
);
};
await compileOne("apps/server/src/index.ts", "naku-server");
await compileOne("apps/engine/src/index.ts", "naku-engine");
await compileOne("packages/db/src/migrate-cli.ts", "naku-migrate");
if (needBin("drizzle")) {
const drizzleSrc = join(root, "packages", "db", "drizzle");
await mustExist(drizzleSrc, "drizzle folder");
await cp(drizzleSrc, join(releaseDir, "drizzle"), { recursive: true });
}
for (const item of opts.only) {
if (item === "dist" && opts.nestDist === false) {
// web 铺在 outDir 根:检查 outDir 非空即可
await nonEmptyDir(releaseDir, "outDir");
continue;
}
// Windows 本机编译可能带 .exe
const candidates = [
join(releaseDir, item),
join(releaseDir, `${item}.exe`),
];
const found = candidates.find((c) => existsSync(c));
if (!found) fail(`missing ${item}: ${candidates[0]}`);
if (item === "dist" || item === "drizzle") {
await nonEmptyDir(found, `release/${item}`);
} else {
const s = await stat(found);
if (s.size <= 0) fail(`empty file: ${item}`);
}
}
if (opts.tarball) {
const date = new Date();
const ymd = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
const tarName = `naku-release-${ymd}.tar.gz`;
const tarPath = join(root, tarName);
log(`tar ${tarName}…`);
// 在 outDir 的父目录打包,使 tar 内为 release/ 根
const parent = resolve(releaseDir, "..");
const folderName = releaseDir.split(/[\\/]/).pop() ?? "release";
await run(
["tar", "-czf", tarPath, "-C", parent, folderName],
root,
log,
);
log(`tarball → ${tarPath}`);
}
return releaseDir;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* 兼容入口:等价于 `naku pack --yes --target=bun-linux-x64`。
* 交互请用 `bun run naku pack`。
*/
import { packRelease } from "./pack-core.ts";
function readTarget(): string {
const arg = process.argv.find((a) => a.startsWith("--target="));
return arg?.slice("--target=".length) || process.env.PACK_TARGET || "bun-linux-x64";
}
const target = readTarget();
console.log(`[pack-release] non-interactive pack (target=${target})`);
try {
const out = await packRelease({
target,
only: ["dist", "naku-server", "naku-engine", "naku-migrate", "drizzle"],
outDir: "release",
tarball: false,
onLog: (m) => console.log("[pack-release]", m),
});
console.log("[pack-release] done →", out);
} catch (err) {
console.error("[pack-release]", err instanceof Error ? err.message : err);
process.exit(1);
}