feat(deploy): 产物包部署链路与 naku 打包 CLI
Docker Compose 五服务 + Debian slim 单文件运行时;migrate 幂等门闩; naku pack/build(citty + clack);部署方案文档入档。
This commit is contained in:
+255
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user