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);