@akairos/code-deploy 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/app.js +215 -0
- package/dist/cli.js +121 -0
- package/dist/config.js +105 -0
- package/dist/credentials.js +24 -0
- package/dist/deploy.js +152 -0
- package/dist/preflight.js +129 -0
- package/dist/process.js +53 -0
- package/dist/settings.js +184 -0
- package/package.json +29 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aatrox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Deploy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
基于 Ink 8、React 和 `@inkjs/ui`,把本地目录 A 的内容发布到 SSH 服务器目录 B。支持直接发布已有内容,或先执行自定义构建命令。工具不包含任何业务环境、服务器或分支预设;真实配置仅存用户目录。
|
|
4
|
+
|
|
5
|
+
## 启动
|
|
6
|
+
|
|
7
|
+
需要 Node.js 22+、pnpm、本地 `ssh`、`scp`、`tar`、`find`。启用 Git 检查或分支锁时需要 `git`。远端需要 POSIX shell 和常用文件命令。
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm install
|
|
11
|
+
pnpm build
|
|
12
|
+
npm link
|
|
13
|
+
code-deploy
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
TUI 使用左侧环境导航和主工作区。按 `n` 新建,`e` 编辑;配置表单左侧显示字段和草稿值,右侧编辑当前项。方向键、Tab 选择字段,Enter 编辑或切换开关,Ctrl+S 保存整份配置,Esc 取消当前编辑或丢弃草稿。表单超过窗口时滚动,已填内容保留且可以回看。输入 `/` 可使用 `/new`、`/config`、`/deploy`、`/check`、`/environments` 和 `/quit`。
|
|
17
|
+
|
|
18
|
+
## 源目录与三个锁
|
|
19
|
+
|
|
20
|
+
实际源目录 A 是**启动工具时的当前目录**,工具不会隐式切换到另一个项目。`projectDir` 保存需要绑定的本地目录。
|
|
21
|
+
|
|
22
|
+
| 配置 | 开启后行为 | 默认 |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| 锁定本地目录 | 当前目录必须等于配置的 A;子目录不算,真实路径相同的符号链接视为同一目录 | 关闭 |
|
|
25
|
+
| 锁定发布分支 | 当前 Git 分支必须等于 `expectedBranch`,否则直接拒绝 | 关闭 |
|
|
26
|
+
| 锁定映射关系 | 在 A 中显式开启,记录真实目录、当前分支、服务器及 B 的快照;发布时逐项匹配 | 关闭 |
|
|
27
|
+
| Git 状态检查 | 刷新上游引用,发现 Git 异常后等待明确确认 | 开启 |
|
|
28
|
+
|
|
29
|
+
三个锁独立配置。映射锁包含目录和分支的约束,即使另外两个开关关闭也会检查;锁定项不匹配时不能用确认警告绕过。修改映射绑定项前必须解除映射锁,修改后在正确目录和分支重新开启。用户配置内已开启的映射必须一一对应,同一个目录和分支不能绑定两个远程目标,同一个远程目标也不能绑定不同的目录和分支。服务器以配置中的 host、账号、端口识别;指向同一服务器的不同 SSH 别名不自动合并。
|
|
30
|
+
|
|
31
|
+
旧配置按此前行为迁移:开启目录锁;原来填写了分支要求的配置开启分支锁;Git 检查默认开启。现有服务器、目录及密码来源继续保留在用户配置中。
|
|
32
|
+
|
|
33
|
+
## Git 检查与确认
|
|
34
|
+
|
|
35
|
+
正式发布前使用当前分支的 upstream 执行 `git fetch --no-tags`,不执行 pull、merge 或 checkout。检查暂存、未暂存、未跟踪文件,以及落后或领先上游的提交。无 Git 仓库、detached HEAD、缺少上游或 fetch 失败也会明确提醒,不能当作“干净且同步”。
|
|
36
|
+
|
|
37
|
+
有提醒时,在执行构建和上传前显示详情并等待 `[y/N]`;默认取消。TUI 可用 PgUp/PgDn 查看长提醒。确认后再次检查目录、分支、HEAD 和 Git 状态,发生变化时重新确认。构建结束、上传之前再次核验目录、分支和 HEAD,变化则拒绝。用户仍应避免在发布期间同时修改源文件。
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
code-deploy list
|
|
41
|
+
code-deploy example
|
|
42
|
+
code-deploy check example
|
|
43
|
+
code-deploy deploy example --yes
|
|
44
|
+
code-deploy config
|
|
45
|
+
code-deploy init /absolute/path/config.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`--yes` 仅确认“开始发布”,不跳过 Git 异常确认。非交互执行遇到 Git 提醒时退出失败,不会构建或上传。`check` 会检查本地规则和 Git 状态,并只读列出远程目录;它可能 fetch,但不发布文件。
|
|
49
|
+
|
|
50
|
+
## 配置与密码
|
|
51
|
+
|
|
52
|
+
配置默认为 `~/.config/code-deploy/config.json`,权限 `600`;`CODE_DEPLOY_CONFIG` 可指定其他文件。项目内的 `config.example.json` 仅是虚构示例,不在启动时自动导入。首次可通过 `init` 导入;已有配置不会被覆盖。
|
|
53
|
+
|
|
54
|
+
`buildCommand` 留空表示跳过构建,否则通过 `/bin/sh` 在 A 执行用户信任的命令。`outputDir` 是 A 内的相对路径:`.` 表示 A 本身,`dist` 表示 A/dist。不要求 `index.html`,可以发布任意非空目录。直接发布 `.` 时会包含隐藏文件和 `.git` 等实际存在的内容,请选择真正需要发布的目录;本工具不内置排除规则。
|
|
55
|
+
|
|
56
|
+
默认使用现有 SSH 密钥 / Agent,沿用 `~/.ssh/config`。也支持配置密码环境变量名,或在 TUI 中显式将密码保存到系统钥匙串。JSON 不存密码,密码不会作为命令行参数传递。钥匙串条目绑定环境标识、服务器、账号及端口;修改目标后需要重新保存。Linux 无持久凭据服务时建议使用环境变量或 SSH 密钥。
|
|
57
|
+
|
|
58
|
+
## 发布方式
|
|
59
|
+
|
|
60
|
+
上传临时压缩包后,在 B 同级目录完整解压,校验非空,然后把旧 B 改名为 `.<目录名>.backup-<uuid>`,再切换新 B。切换失败时尝试恢复旧目录,备份需要按需手工清理。使用同级锁目录避免本工具实例同时切换同一目标。
|
|
61
|
+
|
|
62
|
+
这是目录整体替换,B 中原来存在的其他文件会进入备份而不进入新目录。目标需要至少两级绝对路径,不能是常见系统目录或符号链接;发布内容也不能含符号链接。需要 B 的父目录写权限;两次重命名之间有短暂间隔,容器挂载点等特殊目录需先确认兼容。远端异常退出可能留下锁或临时压缩包;取消后应确认实际版本。
|
|
63
|
+
|
|
64
|
+
## 验证
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
pnpm build
|
|
68
|
+
pnpm test
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
测试只使用临时目录、本地 bare Git 仓库和模拟 SSH,覆盖目录/分支/映射锁、旧配置迁移、Git fetch 与异常确认、完整发布、备份恢复、取消及 TUI 表单交互,不发布真实服务器。
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
|
+
import { ConfirmInput, Spinner, TextInput, ThemeProvider, defaultTheme, extendTheme } from "@inkjs/ui";
|
|
5
|
+
import { configFile, saveConfig } from "./config.js";
|
|
6
|
+
import { deletePassword } from "./credentials.js";
|
|
7
|
+
import { checkServer, deploy, steps } from "./deploy.js";
|
|
8
|
+
import { inspectProject } from "./preflight.js";
|
|
9
|
+
import { Settings, colors, authNames } from "./settings.js";
|
|
10
|
+
const theme = extendTheme(defaultTheme, { components: {
|
|
11
|
+
Spinner: { styles: { frame: () => ({ color: colors.accent }) } }
|
|
12
|
+
} });
|
|
13
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
14
|
+
const formatDuration = (ms) => ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
|
15
|
+
// 目录只留最后两级,帮助辨认目标。
|
|
16
|
+
const shortDir = (dir) => `…/${dir.split("/").filter(Boolean).slice(-2).join("/")}`;
|
|
17
|
+
// 源目录和实际分支放在配置旁边,发布前能直接核对 A → B。
|
|
18
|
+
function Summary({ profile, project }) {
|
|
19
|
+
const rows = [
|
|
20
|
+
["源目录 A", profile.lockDirectory || profile.lockMapping ? profile.projectDir : `${process.cwd()}(启动目录)`],
|
|
21
|
+
["Git 状态", project], ["远程目录 B", `${profile.user}@${profile.host}:${profile.port}${profile.remoteDir}`],
|
|
22
|
+
["构建命令", profile.buildCommand || "跳过构建,直接发布"], ["发布内容", profile.outputDir],
|
|
23
|
+
["登录方式", `${authNames[profile.auth]}${profile.auth === "env" ? ` · ${profile.passwordEnv}` : ""}`],
|
|
24
|
+
["目录锁", profile.lockDirectory ? `开启 · ${profile.projectDir}` : "关闭"],
|
|
25
|
+
["分支锁", profile.lockBranch ? `开启 · ${profile.expectedBranch}` : "关闭"],
|
|
26
|
+
["映射锁", profile.lockMapping ? `开启 · ${profile.mapping ? shortDir(profile.mapping.remoteDir) : "未绑定"}` : "关闭"],
|
|
27
|
+
["Git 检查", profile.checkGit ? "开启 · 异常需确认" : "关闭"]
|
|
28
|
+
];
|
|
29
|
+
return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: colors.warm, children: [profile.name, " ", _jsxs(Text, { color: colors.muted, children: ["/ ", profile.id] })] }), rows.map(([label, value]) => _jsxs(Box, { children: [_jsx(Box, { width: 11, flexShrink: 0, children: _jsx(Text, { color: colors.muted, children: label }) }), _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(Text, { wrap: "truncate", children: value }) })] }, label))] });
|
|
30
|
+
}
|
|
31
|
+
// 纵向环境列表 + 下方摘要;每行带服务器和目录简写,↑↓ 选择。
|
|
32
|
+
export function App({ initialConfig, initialId }) {
|
|
33
|
+
const { exit } = useApp();
|
|
34
|
+
const { stdout: output } = useStdout();
|
|
35
|
+
const stdout = output;
|
|
36
|
+
const [size, setSize] = useState({ columns: stdout.columns || 110, rows: stdout.rows || 32 });
|
|
37
|
+
const [config, setConfig] = useState(initialConfig);
|
|
38
|
+
const [index, setIndex] = useState(Math.max(0, initialConfig.profiles.findIndex(profile => profile.id === initialId)));
|
|
39
|
+
const [screen, setScreen] = useState("dashboard");
|
|
40
|
+
const [creating, setCreating] = useState(false);
|
|
41
|
+
const [notice, setNotice] = useState("");
|
|
42
|
+
const [project, setProject] = useState("读取中…");
|
|
43
|
+
const [command, setCommand] = useState(false);
|
|
44
|
+
const [job, setJob] = useState();
|
|
45
|
+
const [gitPrompt, setGitPrompt] = useState();
|
|
46
|
+
const [warningOffset, setWarningOffset] = useState(0);
|
|
47
|
+
const [previewOffset, setPreviewOffset] = useState(0);
|
|
48
|
+
const [seed, setSeed] = useState();
|
|
49
|
+
const controller = useRef(undefined);
|
|
50
|
+
const confirmGit = useRef(undefined);
|
|
51
|
+
const profile = config.profiles[index];
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const resize = () => setSize({ columns: stdout.columns || 110, rows: stdout.rows || 32 });
|
|
54
|
+
stdout.on("resize", resize);
|
|
55
|
+
return () => { stdout.off("resize", resize); controller.current?.abort(); confirmGit.current?.(false); };
|
|
56
|
+
}, [stdout]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!profile || screen === "settings")
|
|
59
|
+
return;
|
|
60
|
+
const abort = new AbortController();
|
|
61
|
+
setProject("读取中…");
|
|
62
|
+
inspectProject(profile, abort.signal).then(state => {
|
|
63
|
+
if (!abort.signal.aborted)
|
|
64
|
+
setProject(`${state.branch || "无分支"} · ${state.commit.slice(0, 8) || "无提交"}${state.changes ? " · 有未提交修改" : ""}`);
|
|
65
|
+
}).catch(error => { if (!abort.signal.aborted)
|
|
66
|
+
setProject(message(error)); });
|
|
67
|
+
return () => abort.abort();
|
|
68
|
+
}, [profile, screen]);
|
|
69
|
+
// 复制配置时解除映射锁:新环境通常要改目标,映射锁会挡住编辑。
|
|
70
|
+
function openSettings(isNew, source) {
|
|
71
|
+
setSeed(source ? { ...source, id: "", name: `${source.name} 副本`, lockMapping: false, mapping: undefined } : undefined);
|
|
72
|
+
setCreating(isNew);
|
|
73
|
+
setScreen("settings");
|
|
74
|
+
setNotice("");
|
|
75
|
+
}
|
|
76
|
+
async function persist(next) { await saveConfig(next); setConfig(next); }
|
|
77
|
+
function answerGit(value) { confirmGit.current?.(value); confirmGit.current = undefined; setGitPrompt(undefined); }
|
|
78
|
+
function startJob(connectionOnly) {
|
|
79
|
+
if (!profile)
|
|
80
|
+
return;
|
|
81
|
+
const abort = new AbortController();
|
|
82
|
+
controller.current = abort;
|
|
83
|
+
setJob({ title: connectionOnly ? "检查服务器" : "发布目录", step: 0, logs: "", status: "running", started: Date.now() });
|
|
84
|
+
setNotice("");
|
|
85
|
+
setScreen("job");
|
|
86
|
+
const append = (text) => setJob(current => current && ({ ...current, logs: (current.logs + text).slice(-60_000) }));
|
|
87
|
+
const task = connectionOnly ? checkServer(profile, abort.signal).then(text => append(`${text}\n`))
|
|
88
|
+
: deploy(profile, event => {
|
|
89
|
+
if (event.type === "step")
|
|
90
|
+
setJob(current => current && ({ ...current, step: event.index }));
|
|
91
|
+
else
|
|
92
|
+
append(event.text);
|
|
93
|
+
}, abort.signal, state => new Promise(resolve => {
|
|
94
|
+
setGitPrompt(state);
|
|
95
|
+
setWarningOffset(0);
|
|
96
|
+
const cancel = () => answerGit(false);
|
|
97
|
+
confirmGit.current = value => { abort.signal.removeEventListener("abort", cancel); resolve(value); };
|
|
98
|
+
abort.signal.addEventListener("abort", cancel, { once: true });
|
|
99
|
+
if (abort.signal.aborted)
|
|
100
|
+
cancel();
|
|
101
|
+
}));
|
|
102
|
+
void task.then(() => setJob(current => current && ({ ...current, status: "success", elapsed: Date.now() - current.started })))
|
|
103
|
+
.catch(error => { append(`\n${message(error)}\n`); setJob(current => current && ({ ...current, status: "error", elapsed: Date.now() - current.started })); });
|
|
104
|
+
}
|
|
105
|
+
function execute(value) {
|
|
106
|
+
setCommand(false);
|
|
107
|
+
setNotice("");
|
|
108
|
+
const action = value.trim().replace(/^\//, "");
|
|
109
|
+
if (action === "new")
|
|
110
|
+
openSettings(true);
|
|
111
|
+
else if (action === "quit")
|
|
112
|
+
exit();
|
|
113
|
+
else if (action === "environments")
|
|
114
|
+
setScreen("dashboard");
|
|
115
|
+
else if (!profile)
|
|
116
|
+
setNotice("请先新建或选择发布配置");
|
|
117
|
+
else if (action === "config")
|
|
118
|
+
openSettings(false);
|
|
119
|
+
else if (action === "copy")
|
|
120
|
+
openSettings(true, profile);
|
|
121
|
+
else if (action === "deploy")
|
|
122
|
+
setScreen("confirm");
|
|
123
|
+
else if (action === "check")
|
|
124
|
+
startJob(true);
|
|
125
|
+
else
|
|
126
|
+
setNotice("可用命令:/new /config /deploy /check /environments /quit");
|
|
127
|
+
}
|
|
128
|
+
const summaryLines = 11; // Summary 标题 + 10 行
|
|
129
|
+
const previewHeight = Math.max(4, size.rows - (screen === "dashboard" ? 7 : 12));
|
|
130
|
+
const previewMaxOffset = Math.max(0, summaryLines - previewHeight);
|
|
131
|
+
useInput((input, key) => {
|
|
132
|
+
if (key.ctrl && input === "c") {
|
|
133
|
+
if (screen === "job" && job?.status === "running")
|
|
134
|
+
controller.current?.abort();
|
|
135
|
+
else
|
|
136
|
+
exit();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (screen === "settings")
|
|
140
|
+
return;
|
|
141
|
+
if (gitPrompt && (key.pageDown || key.pageUp)) {
|
|
142
|
+
const maximum = Math.max(0, gitPrompt.warnings.join("\n\n").split("\n").length - Math.max(3, size.rows - 15));
|
|
143
|
+
setWarningOffset(current => Math.max(0, Math.min(maximum, current + (key.pageDown ? 5 : -5))));
|
|
144
|
+
}
|
|
145
|
+
if ((key.pageDown || key.pageUp) && (screen === "dashboard" || screen === "confirm" || screen === "delete")) {
|
|
146
|
+
setPreviewOffset(current => Math.max(0, Math.min(previewMaxOffset, current + (key.pageDown ? 4 : -4))));
|
|
147
|
+
}
|
|
148
|
+
if (command) {
|
|
149
|
+
if (key.escape)
|
|
150
|
+
setCommand(false);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (key.escape) {
|
|
154
|
+
if (gitPrompt)
|
|
155
|
+
answerGit(false);
|
|
156
|
+
else if (screen === "job" && job?.status === "running")
|
|
157
|
+
controller.current?.abort();
|
|
158
|
+
else {
|
|
159
|
+
setScreen("dashboard");
|
|
160
|
+
setNotice("");
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (screen !== "dashboard")
|
|
165
|
+
return;
|
|
166
|
+
if (input === "/") {
|
|
167
|
+
setCommand(true);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (input === "n") {
|
|
171
|
+
openSettings(true);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (key.upArrow) {
|
|
175
|
+
setIndex(Math.max(0, index - 1));
|
|
176
|
+
setPreviewOffset(0);
|
|
177
|
+
}
|
|
178
|
+
if (key.downArrow) {
|
|
179
|
+
setIndex(Math.min(config.profiles.length - 1, index + 1));
|
|
180
|
+
setPreviewOffset(0);
|
|
181
|
+
}
|
|
182
|
+
if (!profile)
|
|
183
|
+
return;
|
|
184
|
+
if (input === "e")
|
|
185
|
+
openSettings(false);
|
|
186
|
+
else if (input === "c")
|
|
187
|
+
startJob(true);
|
|
188
|
+
else if (input === "x")
|
|
189
|
+
setScreen("delete");
|
|
190
|
+
else if (input === "d")
|
|
191
|
+
openSettings(true, profile);
|
|
192
|
+
else if (key.return)
|
|
193
|
+
setScreen("confirm");
|
|
194
|
+
});
|
|
195
|
+
return _jsx(ThemeProvider, { theme: theme, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, width: size.columns, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: colors.accent, children: (screen === "confirm" || screen === "delete" || screen === "job") && profile ? profile.name : "code-deploy" }), _jsx(Text, { color: colors.muted, children: "v0.2" })] }), screen !== "settings" && screen !== "job" && _jsxs(Box, { marginTop: 1, children: [screen === "dashboard" && _jsxs(Box, { flexDirection: "column", width: 48, flexShrink: 0, paddingRight: 1, children: [config.profiles.map((item, offset) => _jsxs(Box, { children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: offset === index ? colors.accent : undefined, children: offset === index ? "›" : "" }) }), _jsx(Box, { width: 14, flexShrink: 0, children: _jsx(Text, { bold: offset === index, color: offset === index ? colors.accent : undefined, wrap: "truncate", children: item.name }) }), _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsxs(Text, { color: colors.muted, wrap: "truncate", children: [item.user, "@", item.host, " ", shortDir(item.remoteDir)] }) })] }, item.id)), !config.profiles.length && _jsx(Text, { color: colors.muted, children: "\u6682\u65E0\u914D\u7F6E \u00B7 \u6309 n \u65B0\u5EFA" })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, minWidth: 0, paddingLeft: 2, borderStyle: screen === "dashboard" ? "single" : undefined, borderTop: false, borderBottom: false, borderRight: false, borderColor: colors.muted, children: [profile && _jsxs(_Fragment, { children: [_jsx(Box, { height: previewHeight, overflow: "hidden", flexDirection: "column", children: _jsx(Box, { marginTop: -previewOffset, flexDirection: "column", children: _jsx(Summary, { profile: profile, project: project }) }) }), previewMaxOffset > 0 && _jsxs(Text, { color: colors.muted, children: ["PgUp / PgDn \u6EDA\u52A8\u9884\u89C8 ", previewOffset > 0 ? "↑" : "", previewOffset < previewMaxOffset ? "↓" : ""] })] }), screen === "dashboard" && !profile && _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "\u914D\u7F6E\u4E00\u6B21\uFF0C\u9009\u62E9\u53D1\u5E03" }), _jsx(Text, { children: "\u586B\u5199\u672C\u5730\u6E90\u76EE\u5F55\u3001\u670D\u52A1\u5668\u548C\u8FDC\u7A0B\u76EE\u5F55\uFF0C\u6309\u9700\u5F00\u542F\u9501\u5B9A\u89C4\u5219\u3002" }), _jsx(Text, { color: colors.accent, children: "\u6309 n \u65B0\u5EFA\uFF0C\u6216\u8F93\u5165 /new\u3002" })] }), screen === "confirm" && _jsxs(_Fragment, { children: [_jsx(Text, { color: colors.warm, children: "\u53D1\u5E03\u524D\u5C06\u68C0\u67E5\u9501\u5B9A\u89C4\u5219\u548C Git \u72B6\u6001\uFF0C\u518D\u66FF\u6362\u8FDC\u7A0B\u76EE\u5F55 B\u3002" }), _jsx(Text, { children: "\u786E\u8BA4\u53D1\u5E03\uFF1F [y/N]" }), _jsx(ConfirmInput, { defaultChoice: "cancel", onConfirm: () => startJob(false), onCancel: () => setScreen("dashboard") })] }), screen === "delete" && profile && _jsxs(_Fragment, { children: [_jsx(Text, { color: colors.warm, children: "\u5220\u9664\u914D\u7F6E\u53CA\u5173\u8054\u5BC6\u7801\uFF1F\u670D\u52A1\u5668\u6587\u4EF6\u4E0D\u53D8\u3002[y/N]" }), _jsx(ConfirmInput, { defaultChoice: "cancel", onCancel: () => setScreen("dashboard"), onConfirm: () => {
|
|
196
|
+
void (async () => {
|
|
197
|
+
await deletePassword(profile);
|
|
198
|
+
await persist({ version: 1, profiles: config.profiles.filter(item => item.id !== profile.id) });
|
|
199
|
+
setIndex(Math.max(0, index - 1));
|
|
200
|
+
setScreen("dashboard");
|
|
201
|
+
})().catch(error => setNotice(message(error)));
|
|
202
|
+
} })] })] })] }), screen === "settings" && _jsx(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: _jsx(Settings, { config: config, profile: creating ? undefined : profile, initial: creating ? seed : undefined, onCancel: () => { setSeed(undefined); setScreen("dashboard"); }, onSave: async (next) => {
|
|
203
|
+
const profiles = creating ? [...config.profiles, next] : config.profiles.map(item => item.id === next.id ? next : item);
|
|
204
|
+
await persist({ version: 1, profiles });
|
|
205
|
+
setIndex(profiles.findIndex(item => item.id === next.id));
|
|
206
|
+
setSeed(undefined);
|
|
207
|
+
setScreen("dashboard");
|
|
208
|
+
setNotice("配置已保存");
|
|
209
|
+
} }, creating ? `new-${seed?.name || "blank"}` : profile?.id) }), screen === "job" && job && _jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { bold: true, color: colors.warm, children: job.title }), gitPrompt ? _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: colors.warm, children: "Git \u72B6\u6001\u9700\u8981\u786E\u8BA4\uFF0C\u5C1A\u672A\u6267\u884C\u6784\u5EFA\u6216\u4E0A\u4F20" }), _jsxs(Text, { children: [gitPrompt.directory, " \u00B7 ", gitPrompt.branch || "无分支"] }), _jsx(Box, { height: Math.max(3, size.rows - 15), overflow: "hidden", children: _jsx(Text, { children: gitPrompt.warnings.join("\n\n").split("\n").slice(warningOffset, warningOffset + Math.max(3, size.rows - 15)).join("\n") }) }), _jsx(Text, { color: colors.muted, children: "PgUp / PgDn \u6EDA\u52A8\u67E5\u770B Git \u63D0\u9192" }), _jsx(Text, { children: "\u4ECD\u8981\u4F7F\u7528\u5F53\u524D\u672C\u5730\u5185\u5BB9\u53D1\u5E03\uFF1F [y/N]" }), _jsx(ConfirmInput, { defaultChoice: "cancel", onConfirm: () => answerGit(true), onCancel: () => answerGit(false) })] }) : _jsxs(_Fragment, { children: [_jsx(Box, { gap: 2, flexWrap: "wrap", children: steps.map((label, step) => {
|
|
210
|
+
const done = job.status === "success" || step < job.step;
|
|
211
|
+
const failed = job.status === "error" && step === job.step;
|
|
212
|
+
const active = job.status === "running" && step === job.step;
|
|
213
|
+
return _jsxs(Box, { gap: 1, children: [done ? _jsx(Text, { color: colors.good, children: "\u2713" }) : failed ? _jsx(Text, { color: colors.error, children: "\u2717" }) : active ? _jsx(Spinner, {}) : _jsx(Text, { color: colors.muted, children: "\u00B7" }), _jsx(Text, { color: done ? colors.good : failed ? colors.error : active ? colors.accent : colors.muted, children: label })] }, label);
|
|
214
|
+
}) }), _jsx(Box, { flexDirection: "column", height: Math.max(5, size.rows - 12), overflow: "hidden", marginY: 1, children: _jsx(Text, { wrap: "truncate", children: job.logs.split("\n").slice(-Math.max(3, size.rows - 14)).join("\n") || "正在检查…" }) }), job.status === "running" ? _jsx(Spinner, { label: "\u6267\u884C\u4E2D \u00B7 Ctrl+C \u53D6\u6D88" }) : _jsxs(_Fragment, { children: [_jsx(Text, { color: job.status === "success" ? colors.good : colors.error, children: job.status === "success" ? `✓ 执行完成 · 用时 ${formatDuration(job.elapsed ?? 0)}` : `执行失败 / 已取消 · 用时 ${formatDuration(job.elapsed ?? 0)}` }), _jsx(Text, { color: colors.accent, children: "\u6309 Esc \u8FD4\u56DE" })] })] })] }), notice && _jsx(Text, { color: colors.warm, children: notice }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [command && _jsxs(Box, { children: [_jsx(Text, { color: colors.accent, children: "/ " }), _jsx(TextInput, { placeholder: "new \u00B7 config \u00B7 deploy \u00B7 check \u00B7 environments \u00B7 quit", onSubmit: execute })] }), (screen === "dashboard" || screen === "confirm" || screen === "delete") && _jsx(Text, { color: colors.muted, wrap: "truncate", children: "\u2191\u2193 \u9009\u62E9 \u00B7 Enter \u53D1\u5E03 \u00B7 e \u7F16\u8F91 \u00B7 c \u68C0\u67E5 \u00B7 x \u5220\u9664 \u00B7 d \u590D\u5236 \u00B7 n \u65B0\u5EFA \u00B7 / \u547D\u4EE4" }), _jsx(Text, { color: colors.muted, wrap: "truncate", children: configFile })] })] }) });
|
|
215
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { render } from "ink";
|
|
6
|
+
import { App } from "./app.js";
|
|
7
|
+
import { configFile, loadConfig, saveConfig } from "./config.js";
|
|
8
|
+
import { checkServer, deploy, steps } from "./deploy.js";
|
|
9
|
+
import { inspectProject } from "./preflight.js";
|
|
10
|
+
const help = `DEPLOY · 本地目录 A → 服务器目录 B
|
|
11
|
+
|
|
12
|
+
用法:
|
|
13
|
+
code-deploy 打开交互界面
|
|
14
|
+
code-deploy <环境标识> 打开指定环境
|
|
15
|
+
code-deploy list 列出环境
|
|
16
|
+
code-deploy check <环境标识> 检查锁定规则、Git 状态和远程目录
|
|
17
|
+
code-deploy deploy <环境标识> --yes 按配置构建并部署
|
|
18
|
+
code-deploy config 显示配置文件路径
|
|
19
|
+
code-deploy init <配置文件> 首次导入环境配置,不覆盖已有环境
|
|
20
|
+
|
|
21
|
+
选项:
|
|
22
|
+
-y, --yes 确认发布,Git 异常仍须交互确认
|
|
23
|
+
-h, --help 显示帮助
|
|
24
|
+
|
|
25
|
+
可使用 CODE_DEPLOY_CONFIG 指定其他配置文件。密码不写入 JSON。
|
|
26
|
+
`;
|
|
27
|
+
async function main() {
|
|
28
|
+
const { values, positionals } = parseArgs({
|
|
29
|
+
options: { help: { type: "boolean", short: "h" }, yes: { type: "boolean", short: "y" } },
|
|
30
|
+
allowPositionals: true
|
|
31
|
+
});
|
|
32
|
+
if (values.help) {
|
|
33
|
+
console.log(help);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const [command, id] = positionals;
|
|
37
|
+
if (command === "config") {
|
|
38
|
+
console.log(configFile);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let config = await loadConfig();
|
|
42
|
+
if (command === "init") {
|
|
43
|
+
if (!id)
|
|
44
|
+
throw new Error("用法:code-deploy init <配置文件>");
|
|
45
|
+
if (config.profiles.length)
|
|
46
|
+
throw new Error("已有环境配置,请通过 TUI 编辑,避免覆盖");
|
|
47
|
+
config = await loadConfig(id);
|
|
48
|
+
if (!config.profiles.length)
|
|
49
|
+
throw new Error("导入文件不存在或没有环境配置");
|
|
50
|
+
await saveConfig(config);
|
|
51
|
+
console.log(`已导入 ${config.profiles.length} 个环境:${configFile}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (command === "list") {
|
|
55
|
+
if (!config.profiles.length)
|
|
56
|
+
console.log("暂无环境,运行 code-deploy 新建。");
|
|
57
|
+
for (const profile of config.profiles) {
|
|
58
|
+
console.log(`${profile.id} ${profile.name}\n A: ${profile.lockDirectory || profile.lockMapping ? profile.projectDir : "启动目录"}\n B: ${profile.user}@${profile.host}:${profile.port}${profile.remoteDir}\n 目录锁: ${profile.lockDirectory} · 分支锁: ${profile.lockBranch ? profile.expectedBranch : "关闭"} · 映射锁: ${profile.lockMapping} · Git 检查: ${profile.checkGit}\n`);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (command === "check" || command === "deploy") {
|
|
63
|
+
const profile = config.profiles.find(item => item.id === id);
|
|
64
|
+
if (!profile)
|
|
65
|
+
throw new Error(`找不到环境 ${id || "(未指定)"},运行 code-deploy list 查看`);
|
|
66
|
+
if (command === "deploy" && !values.yes) {
|
|
67
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
68
|
+
await render(_jsx(App, { initialConfig: config, initialId: id }), { alternateScreen: true, exitOnCtrlC: false }).waitUntilExit();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
throw new Error("非交互发布需要 --yes 明确确认;发布将替换配置的远程目录");
|
|
72
|
+
}
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const abort = () => controller.abort();
|
|
75
|
+
process.once("SIGINT", abort);
|
|
76
|
+
process.once("SIGTERM", abort);
|
|
77
|
+
try {
|
|
78
|
+
if (command === "check") {
|
|
79
|
+
const project = await inspectProject(profile, controller.signal, profile.checkGit);
|
|
80
|
+
console.log(`${project.directory}\n${project.branch || "无分支"} · ${project.commit.slice(0, 10) || "无提交"}`);
|
|
81
|
+
if (project.warnings.length)
|
|
82
|
+
console.log(`Git 状态提醒:\n${project.warnings.join("\n\n")}`);
|
|
83
|
+
console.log(await checkServer(profile, controller.signal));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
console.log(`发布 ${profile.name}\nA: ${process.cwd()}\nB: ${profile.user}@${profile.host}:${profile.port}${profile.remoteDir}`);
|
|
87
|
+
const started = Date.now();
|
|
88
|
+
await deploy(profile, event => {
|
|
89
|
+
if (event.type === "step")
|
|
90
|
+
console.log(`\n[${event.index + 1}/${steps.length}] ${event.text}`);
|
|
91
|
+
else
|
|
92
|
+
process.stdout.write(event.text);
|
|
93
|
+
}, controller.signal, process.stdin.isTTY && process.stdout.isTTY ? async (state) => {
|
|
94
|
+
console.log(`\nGit 状态需要确认:\n${state.warnings.join("\n\n")}`);
|
|
95
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
96
|
+
try {
|
|
97
|
+
const answer = await prompt.question("仍使用当前本地内容发布? [y/N] ", { signal: controller.signal });
|
|
98
|
+
return answer.trim().toLowerCase() === "y";
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
prompt.close();
|
|
102
|
+
}
|
|
103
|
+
} : undefined);
|
|
104
|
+
console.log(`\n部署完成,用时 ${((Date.now() - started) / 1000).toFixed(1)}s。`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
process.off("SIGINT", abort);
|
|
109
|
+
process.off("SIGTERM", abort);
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (command && !config.profiles.some(profile => profile.id === command))
|
|
114
|
+
throw new Error(`未知命令或环境:${command}\n${help}`);
|
|
115
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
116
|
+
console.log(help);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await render(_jsx(App, { initialConfig: config, initialId: command }), { alternateScreen: true, exitOnCtrlC: false }).waitUntilExit();
|
|
120
|
+
}
|
|
121
|
+
main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
// 配置文件独立于项目,切换工作目录不会改变环境列表。
|
|
7
|
+
export const configFile = process.env.CODE_DEPLOY_CONFIG || path.join(homedir(), ".config/code-deploy/config.json");
|
|
8
|
+
const singleLine = z.string().refine(value => !/[\x00-\x1f\x7f]/.test(value), "不能包含控制字符");
|
|
9
|
+
// 拒绝根目录、系统目录及带跳转的路径,降低误覆盖风险。
|
|
10
|
+
export function isSafeRemoteDir(value) {
|
|
11
|
+
const parts = value.split("/").filter(Boolean);
|
|
12
|
+
return value.startsWith("/") && parts.length >= 2 &&
|
|
13
|
+
!/[\x00-\x1f\x7f]/.test(value) && !parts.some(part => part === "." || part === "..") &&
|
|
14
|
+
!["bin", "sbin", "etc", "usr", "lib", "lib64", "proc", "sys", "dev", "boot", "run"].includes(parts[0]);
|
|
15
|
+
}
|
|
16
|
+
const mappingSchema = z.object({
|
|
17
|
+
projectDir: singleLine.refine(path.isAbsolute), branch: singleLine.min(1),
|
|
18
|
+
host: singleLine.min(1), user: singleLine.min(1), port: z.number().int().min(1).max(65535),
|
|
19
|
+
remoteDir: z.string().refine(isSafeRemoteDir)
|
|
20
|
+
});
|
|
21
|
+
export const profileSchema = z.object({
|
|
22
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "环境标识只能使用小写字母、数字和连字符"),
|
|
23
|
+
name: singleLine.min(1, "请输入环境名称"),
|
|
24
|
+
projectDir: singleLine.refine(path.isAbsolute, "本地目录必须是绝对路径"),
|
|
25
|
+
host: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/, "请输入 IP、主机名或 SSH 别名"),
|
|
26
|
+
user: z.string().regex(/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/, "请输入服务器账号"),
|
|
27
|
+
port: z.number().int().min(1).max(65535),
|
|
28
|
+
remoteDir: z.string().refine(isSafeRemoteDir, "远程目录必须是至少两级的绝对路径,且不能是系统目录"),
|
|
29
|
+
buildCommand: singleLine,
|
|
30
|
+
outputDir: singleLine.min(1).refine(value => !path.isAbsolute(value) && !value.split(/[\\/]/).includes(".."), "发布内容必须是源目录内的相对路径,或使用 . 发布源目录本身"),
|
|
31
|
+
expectedBranch: singleLine,
|
|
32
|
+
auth: z.enum(["env", "keychain", "ssh"]),
|
|
33
|
+
passwordEnv: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "环境变量名称无效"),
|
|
34
|
+
lockDirectory: z.boolean().default(false),
|
|
35
|
+
lockBranch: z.boolean().default(false),
|
|
36
|
+
lockMapping: z.boolean().default(false),
|
|
37
|
+
checkGit: z.boolean().default(true),
|
|
38
|
+
mapping: mappingSchema.optional()
|
|
39
|
+
});
|
|
40
|
+
const configSchema = z.object({ version: z.literal(1), profiles: z.array(profileSchema) }).superRefine((data, context) => {
|
|
41
|
+
if (new Set(data.profiles.map(profile => profile.id)).size !== data.profiles.length) {
|
|
42
|
+
context.addIssue({ code: "custom", message: "环境标识不能重复", path: ["profiles"] });
|
|
43
|
+
}
|
|
44
|
+
for (const profile of data.profiles) {
|
|
45
|
+
if (profile.lockBranch && !profile.expectedBranch)
|
|
46
|
+
context.addIssue({ code: "custom", message: `${profile.name}:开启分支锁时必须填写指定分支` });
|
|
47
|
+
if (!profile.lockMapping)
|
|
48
|
+
continue;
|
|
49
|
+
const mapping = profile.mapping;
|
|
50
|
+
if (!mapping || path.resolve(profile.projectDir) !== path.resolve(mapping.projectDir) ||
|
|
51
|
+
targetKey(profile) !== targetKey(mapping)) {
|
|
52
|
+
context.addIssue({ code: "custom", message: `${profile.name}:配置与锁定映射不一致,请先解除映射锁再修改` });
|
|
53
|
+
}
|
|
54
|
+
for (const other of data.profiles) {
|
|
55
|
+
if (other.id === profile.id || !other.lockMapping || !other.mapping || !mapping)
|
|
56
|
+
continue;
|
|
57
|
+
const sameSource = path.resolve(mapping.projectDir) === path.resolve(other.mapping.projectDir);
|
|
58
|
+
const sameTarget = targetKey(mapping) === targetKey(other.mapping);
|
|
59
|
+
if (sameSource !== sameTarget)
|
|
60
|
+
context.addIssue({ code: "custom", message: `${profile.name} 与 ${other.name} 的锁定映射不是一一对应` });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
// 服务器和目录共同标识远程目标,规范化末尾斜线。
|
|
65
|
+
export function targetKey(target) {
|
|
66
|
+
return JSON.stringify([target.host, target.user, target.port, path.posix.normalize(target.remoteDir).replace(/\/$/, "")]);
|
|
67
|
+
}
|
|
68
|
+
// 旧配置曾固定从 projectDir 构建,迁移时显式保留这一约束。
|
|
69
|
+
function migrateConfig(value) {
|
|
70
|
+
if (!value || typeof value !== "object" || !("profiles" in value) || !Array.isArray(value.profiles))
|
|
71
|
+
return value;
|
|
72
|
+
return { ...value, profiles: value.profiles.map(profile => ({
|
|
73
|
+
...profile,
|
|
74
|
+
lockDirectory: profile.lockDirectory ?? true,
|
|
75
|
+
lockBranch: profile.lockBranch ?? Boolean(profile.expectedBranch)
|
|
76
|
+
})) };
|
|
77
|
+
}
|
|
78
|
+
// 只将“文件不存在”视为空配置,避免损坏配置被悄悄覆盖。
|
|
79
|
+
export async function loadConfig(file = configFile) {
|
|
80
|
+
try {
|
|
81
|
+
return configSchema.parse(migrateConfig(JSON.parse(await readFile(file, "utf8"))));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code === "ENOENT")
|
|
85
|
+
return { version: 1, profiles: [] };
|
|
86
|
+
throw new Error(`读取配置失败 ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function saveConfig(config, file = configFile) {
|
|
90
|
+
const parsed = configSchema.parse(config);
|
|
91
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
92
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
93
|
+
await writeFile(temporary, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
94
|
+
await rename(temporary, file);
|
|
95
|
+
await chmod(file, 0o600);
|
|
96
|
+
}
|
|
97
|
+
// 新配置使用通用默认值,服务器、目录和分支均由用户填写。
|
|
98
|
+
export function newProfile() {
|
|
99
|
+
return {
|
|
100
|
+
id: "", name: "", projectDir: process.cwd(), host: "", user: process.env.USER || "deploy", port: 22,
|
|
101
|
+
remoteDir: "", buildCommand: "", outputDir: ".",
|
|
102
|
+
expectedBranch: "", auth: "ssh", passwordEnv: "DEPLOY_SERVER_PASSWORD",
|
|
103
|
+
lockDirectory: false, lockBranch: false, lockMapping: false, checkGit: true
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { AsyncEntry } from "@napi-rs/keyring";
|
|
2
|
+
// 凭据绑定环境及服务器,编辑目标后不会误用旧服务器密码。
|
|
3
|
+
function entry(profile) {
|
|
4
|
+
return new AsyncEntry("code-deploy", `${profile.id}:${profile.user}@${profile.host}:${profile.port}`);
|
|
5
|
+
}
|
|
6
|
+
export async function savePassword(profile, password) {
|
|
7
|
+
if (!password)
|
|
8
|
+
throw new Error("密码不能为空");
|
|
9
|
+
await entry(profile).setPassword(password);
|
|
10
|
+
}
|
|
11
|
+
export async function deletePassword(profile) {
|
|
12
|
+
await entry(profile).deletePassword();
|
|
13
|
+
}
|
|
14
|
+
// 密码仅传给 SSH 子进程,不写配置、命令行参数或日志。
|
|
15
|
+
export async function resolvePassword(profile) {
|
|
16
|
+
if (profile.auth === "ssh")
|
|
17
|
+
return undefined;
|
|
18
|
+
const password = profile.auth === "env" ? process.env[profile.passwordEnv] : await entry(profile).getPassword();
|
|
19
|
+
if (!password)
|
|
20
|
+
throw new Error(profile.auth === "env"
|
|
21
|
+
? `请设置环境变量 ${profile.passwordEnv},或在环境设置中改用钥匙串密码`
|
|
22
|
+
: "钥匙串中没有此环境的密码,请在环境设置中保存");
|
|
23
|
+
return password;
|
|
24
|
+
}
|
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { lstat, mkdtemp, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { profileSchema } from "./config.js";
|
|
6
|
+
import { resolvePassword } from "./credentials.js";
|
|
7
|
+
import { run, quote } from "./process.js";
|
|
8
|
+
import { prepareRelease, inspectProject } from "./preflight.js";
|
|
9
|
+
export const steps = ["发布前检查", "准备发布内容", "打包上传", "切换版本"];
|
|
10
|
+
export { quote, run } from "./process.js";
|
|
11
|
+
export { inspectProject } from "./preflight.js";
|
|
12
|
+
// 使用 OpenSSH,沿用 ~/.ssh/config、known_hosts 及本机 SSH agent。
|
|
13
|
+
async function withSsh(profile, task) {
|
|
14
|
+
const password = await resolvePassword(profile);
|
|
15
|
+
const temporary = await mkdtemp(path.join(tmpdir(), "code-deploy-ssh-"));
|
|
16
|
+
try {
|
|
17
|
+
const askpass = path.join(temporary, "askpass.sh");
|
|
18
|
+
if (password)
|
|
19
|
+
await writeFile(askpass, '#!/bin/sh\nprintf "%s\\n" "$CODE_DEPLOY_SSH_PASSWORD"\n', { mode: 0o700 });
|
|
20
|
+
const base = ["-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-o", "NumberOfPasswordPrompts=1"];
|
|
21
|
+
if (!password)
|
|
22
|
+
base.push("-o", "BatchMode=yes");
|
|
23
|
+
return await task((command, args, options = {}) => run(command, [...base, command === "scp" ? "-P" : "-p", String(profile.port), ...args], {
|
|
24
|
+
...options,
|
|
25
|
+
env: {
|
|
26
|
+
...process.env,
|
|
27
|
+
...(password ? { CODE_DEPLOY_SSH_PASSWORD: password, DISPLAY: process.env.DISPLAY || "code-deploy", SSH_ASKPASS: askpass, SSH_ASKPASS_REQUIRE: "force" } : {})
|
|
28
|
+
},
|
|
29
|
+
onOutput: text => options.onOutput?.(password ? text.split(password).join("[已隐藏]") : text)
|
|
30
|
+
}).then(result => password ? result.split(password).join("[已隐藏]") : result).catch(error => {
|
|
31
|
+
throw new Error(password ? String(error.message).split(password).join("[已隐藏]") : error.message);
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await rm(temporary, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function checkServer(profile, signal) {
|
|
39
|
+
profileSchema.parse(profile);
|
|
40
|
+
return withSsh(profile, ssh => ssh("ssh", [`${profile.user}@${profile.host}`, `set -eu
|
|
41
|
+
target=${quote(profile.remoteDir)}
|
|
42
|
+
if [ -L "$target" ]; then echo '目标目录是符号链接,请选择真实目录' >&2; exit 1; fi
|
|
43
|
+
if [ -d "$target" ]; then ls -ld "$target"; ls -1 "$target" | head -20; else echo '目标尚不存在,部署时会创建'; fi`], { signal }));
|
|
44
|
+
}
|
|
45
|
+
// version.json 用标记包裹传回,本地解析后美化展示。
|
|
46
|
+
const versionBegin = "__CODE_DEPLOY_VERSION_BEGIN__";
|
|
47
|
+
const versionEnd = "__CODE_DEPLOY_VERSION_END__";
|
|
48
|
+
// 先完整解压,再保留旧版本并切换;失败时恢复旧目录。
|
|
49
|
+
export function remoteDeployScript(profile, archive, token) {
|
|
50
|
+
const target = path.posix.normalize(profile.remoteDir).replace(/\/$/, "");
|
|
51
|
+
const parent = path.posix.dirname(target);
|
|
52
|
+
const name = path.posix.basename(target);
|
|
53
|
+
return `set -eu
|
|
54
|
+
target=${quote(target)}
|
|
55
|
+
parent=${quote(parent)}
|
|
56
|
+
archive=${quote(archive)}
|
|
57
|
+
stage=${quote(`${parent}/.${name}.stage-${token}`)}
|
|
58
|
+
backup=${quote(`${parent}/.${name}.backup-${token}`)}
|
|
59
|
+
lock=${quote(`${parent}/.${name}.code-deploy-lock`)}
|
|
60
|
+
switched=0
|
|
61
|
+
mkdir -p "$parent"
|
|
62
|
+
if [ -L "$target" ]; then echo '目标目录是符号链接,已停止' >&2; exit 1; fi
|
|
63
|
+
if [ -e "$target" ] && [ ! -d "$target" ]; then echo '目标不是目录,已停止' >&2; exit 1; fi
|
|
64
|
+
if ! mkdir "$lock"; then echo '目标正在部署或留有锁目录,请核实后处理' >&2; exit 1; fi
|
|
65
|
+
cleanup() {
|
|
66
|
+
status=$?
|
|
67
|
+
trap - EXIT HUP INT TERM
|
|
68
|
+
if [ "$switched" = 0 ] && [ -d "$backup" ] && [ ! -e "$target" ]; then mv "$backup" "$target"; fi
|
|
69
|
+
rm -rf -- "$stage"
|
|
70
|
+
rm -f -- "$archive"
|
|
71
|
+
rmdir "$lock"
|
|
72
|
+
exit "$status"
|
|
73
|
+
}
|
|
74
|
+
trap cleanup EXIT
|
|
75
|
+
trap 'exit 130' HUP INT TERM
|
|
76
|
+
mkdir "$stage"
|
|
77
|
+
tar -xzf "$archive" -C "$stage"
|
|
78
|
+
test -n "$(ls -A "$stage")"
|
|
79
|
+
if [ -e "$target" ]; then mv "$target" "$backup"; fi
|
|
80
|
+
mv "$stage" "$target"
|
|
81
|
+
switched=1
|
|
82
|
+
echo "部署完成: $target"
|
|
83
|
+
if [ -d "$backup" ]; then echo "旧版本保留: $backup"; fi
|
|
84
|
+
if [ -f "$target/version.json" ]; then echo '${versionBegin}'; cat "$target/version.json"; echo; echo '${versionEnd}'; fi`;
|
|
85
|
+
}
|
|
86
|
+
// 标记内的 JSON 解析为逐行键值;解析失败保留原文。
|
|
87
|
+
export function formatRemoteOutput(text) {
|
|
88
|
+
const start = text.indexOf(versionBegin);
|
|
89
|
+
const stop = start < 0 ? -1 : text.indexOf(versionEnd, start);
|
|
90
|
+
if (start < 0 || stop < 0)
|
|
91
|
+
return text;
|
|
92
|
+
const body = text.slice(start + versionBegin.length, stop).trim();
|
|
93
|
+
let pretty = body;
|
|
94
|
+
try {
|
|
95
|
+
const data = JSON.parse(body);
|
|
96
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
97
|
+
pretty = ["version.json", ...Object.entries(data).map(([key, value]) => ` ${key}: ${value !== null && typeof value === "object" ? JSON.stringify(value) : String(value)}`)].join("\n");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch { /* 保留原文 */ }
|
|
101
|
+
return `${text.slice(0, start)}${pretty}\n${text.slice(stop + versionEnd.length)}`;
|
|
102
|
+
}
|
|
103
|
+
export async function deploy(profile, emit, signal, confirm) {
|
|
104
|
+
const step = (index) => emit({ type: "step", index, text: steps[index] });
|
|
105
|
+
const log = (text) => emit({ type: "log", text });
|
|
106
|
+
step(0);
|
|
107
|
+
const project = await prepareRelease(profile, signal, confirm);
|
|
108
|
+
log(`${project.directory}\n${project.branch || "无分支"} · ${project.commit.slice(0, 10) || "无提交"}\n`);
|
|
109
|
+
// 构建前核验凭据,避免构建完成才发现没有密码。
|
|
110
|
+
await resolvePassword(profile);
|
|
111
|
+
step(1);
|
|
112
|
+
if (profile.buildCommand)
|
|
113
|
+
await run("/bin/sh", ["-c", profile.buildCommand], { cwd: project.directory, signal, onOutput: log });
|
|
114
|
+
else
|
|
115
|
+
log("未配置构建命令,直接发布现有目录内容\n");
|
|
116
|
+
const current = await inspectProject(profile, signal);
|
|
117
|
+
if (current.directory !== project.directory || current.branch !== project.branch || current.commit !== project.commit) {
|
|
118
|
+
throw new Error("准备期间源目录、分支或 HEAD 发生变化,请重新发起发布");
|
|
119
|
+
}
|
|
120
|
+
const output = await realpath(path.join(project.directory, profile.outputDir));
|
|
121
|
+
const relative = path.relative(project.directory, output);
|
|
122
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
123
|
+
throw new Error("构建产物必须位于项目目录内");
|
|
124
|
+
if (!(await lstat(output)).isDirectory())
|
|
125
|
+
throw new Error("构建产物不是目录");
|
|
126
|
+
if (!(await readdir(output)).length)
|
|
127
|
+
throw new Error("发布目录为空");
|
|
128
|
+
// 禁止归档符号链接,避免服务器解压时逃离暂存目录。
|
|
129
|
+
const links = await run("find", [output, "-type", "l"], { signal });
|
|
130
|
+
if (links)
|
|
131
|
+
throw new Error(`构建产物包含符号链接,请先移除:\n${links}`);
|
|
132
|
+
const temporary = await mkdtemp(path.join(tmpdir(), "code-deploy-build-"));
|
|
133
|
+
const token = randomUUID();
|
|
134
|
+
const localArchive = path.join(temporary, "dist.tar.gz");
|
|
135
|
+
const remoteArchive = `/tmp/code-deploy-${token}.tar.gz`;
|
|
136
|
+
try {
|
|
137
|
+
step(2);
|
|
138
|
+
await run("tar", [...(process.platform === "darwin" ? ["--no-xattrs"] : []), "-czf", localArchive, "-C", output, "."], {
|
|
139
|
+
signal, env: { ...process.env, COPYFILE_DISABLE: "1" }
|
|
140
|
+
});
|
|
141
|
+
await withSsh(profile, async (ssh) => {
|
|
142
|
+
await ssh("scp", [localArchive, `${profile.user}@${profile.host.includes(":") ? `[${profile.host}]` : profile.host}:${remoteArchive}`], { signal, onOutput: log });
|
|
143
|
+
step(3);
|
|
144
|
+
let remoteOutput = "";
|
|
145
|
+
await ssh("ssh", [`${profile.user}@${profile.host}`, remoteDeployScript(profile, remoteArchive, token)], { signal, onOutput: text => { remoteOutput += text; } });
|
|
146
|
+
log(formatRemoteOutput(remoteOutput));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
await rm(temporary, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { profileSchema, targetKey } from "./config.js";
|
|
4
|
+
import { run } from "./process.js";
|
|
5
|
+
// 当前目录就是实际源目录;目录锁不会偷偷切换到另一个工作区。
|
|
6
|
+
export async function inspectProject(profile, signal, refresh = false, cwd = process.cwd()) {
|
|
7
|
+
profileSchema.parse(profile);
|
|
8
|
+
const directory = await realpath(cwd);
|
|
9
|
+
if (!(await stat(directory)).isDirectory())
|
|
10
|
+
throw new Error("源目录不是目录");
|
|
11
|
+
if (profile.lockDirectory || profile.lockMapping) {
|
|
12
|
+
const expected = await realpath(profile.projectDir);
|
|
13
|
+
if (directory !== expected)
|
|
14
|
+
throw new Error(`本地目录已锁定,请在 ${expected} 运行;当前 ${directory}`);
|
|
15
|
+
}
|
|
16
|
+
const state = { directory, branch: "", commit: "", changes: "", upstream: "", ahead: 0, behind: 0, warnings: [], fingerprint: "" };
|
|
17
|
+
const git = (args) => run("git", args, { cwd: directory, signal, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
18
|
+
let isGit = false;
|
|
19
|
+
try {
|
|
20
|
+
isGit = await git(["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (signal?.aborted)
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (!isGit) {
|
|
27
|
+
if (profile.lockBranch || profile.lockMapping)
|
|
28
|
+
throw new Error("分支锁或映射锁要求源目录属于 Git 工作区");
|
|
29
|
+
if (profile.checkGit)
|
|
30
|
+
state.warnings.push("源目录不属于 Git 工作区,无法检查分支状态");
|
|
31
|
+
return finish(state);
|
|
32
|
+
}
|
|
33
|
+
state.branch = await git(["branch", "--show-current"]);
|
|
34
|
+
state.commit = await git(["rev-parse", "HEAD"]).catch(error => {
|
|
35
|
+
if (signal?.aborted)
|
|
36
|
+
throw error;
|
|
37
|
+
return "";
|
|
38
|
+
});
|
|
39
|
+
enforceLocks(profile, state);
|
|
40
|
+
state.changes = await git(["status", "--porcelain=v1", "--untracked-files=all"]);
|
|
41
|
+
if (!profile.checkGit)
|
|
42
|
+
return finish(state);
|
|
43
|
+
if (state.changes)
|
|
44
|
+
state.warnings.push(`存在未提交改动(含暂存、未暂存或未跟踪文件):\n${state.changes}`);
|
|
45
|
+
if (!state.branch)
|
|
46
|
+
state.warnings.push("当前处于 detached HEAD,未检出命名分支");
|
|
47
|
+
try {
|
|
48
|
+
state.upstream = await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (signal?.aborted)
|
|
52
|
+
throw error;
|
|
53
|
+
state.warnings.push("当前分支没有可用的上游跟踪分支,无法确认是否有未拉取提交");
|
|
54
|
+
return finish(state);
|
|
55
|
+
}
|
|
56
|
+
if (refresh) {
|
|
57
|
+
try {
|
|
58
|
+
const remote = await git(["config", "--get", `branch.${state.branch}.remote`]);
|
|
59
|
+
if (remote && remote !== ".")
|
|
60
|
+
await git(["fetch", "--no-tags", "--", remote]);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (signal?.aborted)
|
|
64
|
+
throw error;
|
|
65
|
+
state.warnings.push(`刷新远程分支失败,未拉取状态可能过期:\n${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const [ahead, behind] = (await git(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])).split(/\s+/).map(Number);
|
|
69
|
+
state.ahead = ahead;
|
|
70
|
+
state.behind = behind;
|
|
71
|
+
if (state.behind)
|
|
72
|
+
state.warnings.push(`当前分支落后 ${state.upstream} ${state.behind} 个提交,存在未拉取代码`);
|
|
73
|
+
if (state.ahead)
|
|
74
|
+
state.warnings.push(`当前分支领先 ${state.upstream} ${state.ahead} 个提交,存在未推送代码`);
|
|
75
|
+
return finish(state);
|
|
76
|
+
}
|
|
77
|
+
// 锁定规则属于硬约束,确认 Git 警告不能绕过。
|
|
78
|
+
function enforceLocks(profile, state) {
|
|
79
|
+
if (profile.lockBranch && (!profile.expectedBranch || state.branch !== profile.expectedBranch)) {
|
|
80
|
+
throw new Error(`分支不匹配:要求 ${profile.expectedBranch || "(未配置)"},当前 ${state.branch || "detached HEAD"}`);
|
|
81
|
+
}
|
|
82
|
+
if (!profile.lockMapping)
|
|
83
|
+
return;
|
|
84
|
+
const mapping = profile.mapping;
|
|
85
|
+
if (!mapping || state.directory !== mapping.projectDir || targetKey(profile) !== targetKey(mapping)) {
|
|
86
|
+
throw new Error("锁定映射不匹配:本地目录、服务器及远程目录必须与保存的映射完全对应;请先解除映射锁再修改");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 指纹用于确认后复核,目录、提交或警告发生变化时重新等待确认。
|
|
90
|
+
function finish(state) {
|
|
91
|
+
state.fingerprint = createHash("sha256").update(JSON.stringify({
|
|
92
|
+
directory: state.directory, branch: state.branch, commit: state.commit,
|
|
93
|
+
changes: state.changes, upstream: state.upstream, ahead: state.ahead, behind: state.behind
|
|
94
|
+
})).digest("hex");
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
export async function captureMapping(profile, signal) {
|
|
98
|
+
// 配置阶段只抓取现状,分支锁、映射锁的判定留到发布时。
|
|
99
|
+
const state = await inspectProject({ ...profile, lockMapping: false, lockDirectory: true, lockBranch: false, checkGit: false }, signal);
|
|
100
|
+
if (!state.branch)
|
|
101
|
+
throw new Error("请先检出要锁定的发布分支");
|
|
102
|
+
return { ...profile, projectDir: state.directory, lockMapping: true, mapping: {
|
|
103
|
+
projectDir: state.directory, branch: state.branch, host: profile.host, user: profile.user,
|
|
104
|
+
port: profile.port, remoteDir: profile.remoteDir
|
|
105
|
+
} };
|
|
106
|
+
}
|
|
107
|
+
export class GitConfirmationRequired extends Error {
|
|
108
|
+
state;
|
|
109
|
+
constructor(state) {
|
|
110
|
+
super(`Git 状态需要确认:\n${state.warnings.join("\n\n")}`);
|
|
111
|
+
this.state = state;
|
|
112
|
+
this.name = "GitConfirmationRequired";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// TUI 和命令行共用确认入口,默认拒绝无交互的异常状态发布。
|
|
116
|
+
export async function prepareRelease(profile, signal, confirm) {
|
|
117
|
+
let state = await inspectProject(profile, signal, profile.checkGit);
|
|
118
|
+
while (state.warnings.length) {
|
|
119
|
+
if (!confirm)
|
|
120
|
+
throw new GitConfirmationRequired(state);
|
|
121
|
+
if (!await confirm(state))
|
|
122
|
+
throw new Error("已取消发布");
|
|
123
|
+
const current = await inspectProject(profile, signal, false);
|
|
124
|
+
if (current.fingerprint === state.fingerprint)
|
|
125
|
+
return current;
|
|
126
|
+
state = await inspectProject(profile, signal, profile.checkGit);
|
|
127
|
+
}
|
|
128
|
+
return state;
|
|
129
|
+
}
|
package/dist/process.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
// 参数以数组传递,只有用户明确配置的构建命令通过 shell 执行。
|
|
4
|
+
export function run(command, args, options = {}) {
|
|
5
|
+
if (options.signal?.aborted)
|
|
6
|
+
return Promise.reject(new Error("操作已取消"));
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(command, args, {
|
|
9
|
+
cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"],
|
|
10
|
+
detached: process.platform !== "win32"
|
|
11
|
+
});
|
|
12
|
+
let output = "";
|
|
13
|
+
let killTimer;
|
|
14
|
+
function kill(signal) {
|
|
15
|
+
try {
|
|
16
|
+
if (process.platform !== "win32" && child.pid)
|
|
17
|
+
process.kill(-child.pid, signal);
|
|
18
|
+
else
|
|
19
|
+
child.kill(signal);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code !== "ESRCH")
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const abort = () => {
|
|
27
|
+
kill("SIGTERM");
|
|
28
|
+
killTimer = setTimeout(() => kill("SIGKILL"), 3000);
|
|
29
|
+
killTimer.unref();
|
|
30
|
+
};
|
|
31
|
+
const collect = (chunk) => {
|
|
32
|
+
const text = stripVTControlCharacters(chunk).replace(/\r/g, "");
|
|
33
|
+
output = (output + text).slice(-100_000);
|
|
34
|
+
options.onOutput?.(text);
|
|
35
|
+
};
|
|
36
|
+
child.stdout.setEncoding("utf8").on("data", collect);
|
|
37
|
+
child.stderr.setEncoding("utf8").on("data", collect);
|
|
38
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
39
|
+
child.on("error", reject);
|
|
40
|
+
child.on("close", (code, signal) => {
|
|
41
|
+
clearTimeout(killTimer);
|
|
42
|
+
options.signal?.removeEventListener("abort", abort);
|
|
43
|
+
if (options.signal?.aborted)
|
|
44
|
+
reject(new Error("操作已取消;若已进入远程切换阶段,请检查服务器上的当前版本"));
|
|
45
|
+
else if (code !== 0)
|
|
46
|
+
reject(new Error(`${command} 执行失败 (${signal || code})\n${output.slice(-4000)}`));
|
|
47
|
+
else
|
|
48
|
+
resolve(output.trim());
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// 单引号转义用于远程 POSIX shell,支持目录中的空格及引号。
|
|
53
|
+
export const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { PasswordInput, TextInput } from "@inkjs/ui";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { newProfile, profileSchema } from "./config.js";
|
|
7
|
+
import { savePassword } from "./credentials.js";
|
|
8
|
+
import { captureMapping } from "./preflight.js";
|
|
9
|
+
export const colors = { accent: "#A78BFA", warm: "#E8BC73", muted: "#7C8499", good: "#8DC7A5", error: "#E88E8E" };
|
|
10
|
+
export const authNames = { env: "环境变量", keychain: "系统钥匙串", ssh: "SSH 密钥 / Agent" };
|
|
11
|
+
// 向导按此顺序逐项提问;已答项留在上方可回看。
|
|
12
|
+
const fields = [
|
|
13
|
+
{ key: "id", label: "环境标识", hint: "小写字母、数字、连字符;创建后固定" },
|
|
14
|
+
{ key: "name", label: "显示名称", hint: "为这条发布配置取一个名称" },
|
|
15
|
+
{ key: "projectDir", label: "本地目录 A", hint: "目录锁或映射锁开启时必须在此目录运行;未锁定时使用启动目录" },
|
|
16
|
+
{ key: "host", label: "目标服务器", hint: "IP、域名或 SSH 别名" },
|
|
17
|
+
{ key: "user", label: "登录账号", hint: "服务器上的 SSH 登录账号" },
|
|
18
|
+
{ key: "port", label: "SSH 端口", hint: "服务器的 SSH 端口" },
|
|
19
|
+
{ key: "remoteDir", label: "远程目录 B", hint: "发布时整体替换此目录,旧内容保留为同级备份" },
|
|
20
|
+
{ key: "buildCommand", label: "构建命令", hint: "在源目录执行;留空表示直接发布已有内容" },
|
|
21
|
+
{ key: "outputDir", label: "发布内容", hint: "源目录内的相对路径;. 表示整个源目录,dist 表示构建产物" },
|
|
22
|
+
{ key: "expectedBranch", label: "指定分支", hint: "开启分支锁时必须填写;关闭时仅保存此值,不限制发布" },
|
|
23
|
+
{ key: "lockDirectory", label: "锁定本地目录", hint: "开启后只能在本地目录 A 本身运行;子目录也不允许" },
|
|
24
|
+
{ key: "lockBranch", label: "锁定发布分支", hint: "发布时检查实际分支等于指定分支,用错分支时拦截提醒" },
|
|
25
|
+
{ key: "lockMapping", label: "锁定映射关系", hint: "记录目录 A 与服务器、目录 B 的对应关系;分支由分支锁单独约束;需在目录 A 开启" },
|
|
26
|
+
{ key: "checkGit", label: "Git 状态检查", hint: "默认开启;刷新上游,检查未提交、未拉取及未推送代码,异常时等待确认" },
|
|
27
|
+
{ key: "auth", label: "登录方式", hint: "支持 SSH 密钥、环境变量或系统钥匙串" },
|
|
28
|
+
{ key: "passwordEnv", label: "密码环境变量", hint: "仅环境变量认证使用;这里填写变量名,不是密码" }
|
|
29
|
+
];
|
|
30
|
+
const actions = ["保存配置", "保存钥匙串密码", "取消修改"];
|
|
31
|
+
const protectedFields = ["projectDir", "host", "user", "port", "remoteDir", "expectedBranch"];
|
|
32
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
33
|
+
export function fieldValue(profile, key) {
|
|
34
|
+
if (typeof profile[key] === "boolean")
|
|
35
|
+
return profile[key] ? "开启" : "关闭";
|
|
36
|
+
if (key === "auth")
|
|
37
|
+
return authNames[profile.auth];
|
|
38
|
+
return String(profile[key] || "(未设置)");
|
|
39
|
+
}
|
|
40
|
+
// 引导式向导:一次只问当前一项,已答项保留在上方;↑↓ 回看或跳过。
|
|
41
|
+
export function Settings({ config, profile, initial, onSave, onCancel }) {
|
|
42
|
+
const [draft, setDraft] = useState(initial || profile || newProfile);
|
|
43
|
+
const [step, setStep] = useState(0);
|
|
44
|
+
const [choice, setChoice] = useState(0);
|
|
45
|
+
const [enteringPassword, setEnteringPassword] = useState(false);
|
|
46
|
+
const [error, setError] = useState("");
|
|
47
|
+
const [busy, setBusy] = useState(false);
|
|
48
|
+
const field = fields[step];
|
|
49
|
+
const done = step >= fields.length;
|
|
50
|
+
const fixed = field?.key === "id" && Boolean(profile);
|
|
51
|
+
const locked = Boolean(field && draft.lockMapping && protectedFields.includes(field.key));
|
|
52
|
+
const isBoolean = field ? typeof draft[field.key] === "boolean" : false;
|
|
53
|
+
async function attempt(task) {
|
|
54
|
+
if (busy)
|
|
55
|
+
return;
|
|
56
|
+
setBusy(true);
|
|
57
|
+
setError("");
|
|
58
|
+
try {
|
|
59
|
+
await task();
|
|
60
|
+
}
|
|
61
|
+
catch (failure) {
|
|
62
|
+
setError(message(failure));
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
setBusy(false);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function save() {
|
|
69
|
+
if (!profile && config.profiles.some(item => item.id === draft.id))
|
|
70
|
+
throw new Error("环境标识已存在");
|
|
71
|
+
profileSchema.parse(draft);
|
|
72
|
+
if (draft.lockBranch && !draft.expectedBranch)
|
|
73
|
+
throw new Error("开启分支锁时必须填写指定分支");
|
|
74
|
+
await onSave(draft);
|
|
75
|
+
}
|
|
76
|
+
function advance() { setError(""); setStep(current => Math.min(fields.length, current + 1)); }
|
|
77
|
+
function submitText(raw) {
|
|
78
|
+
if (!field)
|
|
79
|
+
return;
|
|
80
|
+
let value = field.key === "port" ? Number(raw) : raw.trim();
|
|
81
|
+
if (field.key === "projectDir" && typeof value === "string" && value.startsWith("~/"))
|
|
82
|
+
value = `${homedir()}/${value.slice(2)}`;
|
|
83
|
+
const result = profileSchema.shape[field.key].safeParse(value);
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
setError(result.error.issues[0].message);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setDraft({ ...draft, [field.key]: result.data });
|
|
89
|
+
advance();
|
|
90
|
+
}
|
|
91
|
+
function toggleBoolean() {
|
|
92
|
+
if (!field)
|
|
93
|
+
return;
|
|
94
|
+
const value = draft[field.key];
|
|
95
|
+
if (field.key === "lockMapping" && !value)
|
|
96
|
+
void attempt(async () => { setDraft(await captureMapping(profileSchema.parse(draft))); });
|
|
97
|
+
else
|
|
98
|
+
setDraft({ ...draft, [field.key]: !value });
|
|
99
|
+
}
|
|
100
|
+
function cycleAuth(direction) {
|
|
101
|
+
const order = Object.keys(authNames);
|
|
102
|
+
setDraft({ ...draft, auth: order[(order.indexOf(draft.auth) + direction + order.length) % order.length] });
|
|
103
|
+
}
|
|
104
|
+
useInput((input, key) => {
|
|
105
|
+
if (key.ctrl && input === "s") {
|
|
106
|
+
void attempt(save);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (busy)
|
|
110
|
+
return;
|
|
111
|
+
if (key.escape) {
|
|
112
|
+
if (enteringPassword) {
|
|
113
|
+
setEnteringPassword(false);
|
|
114
|
+
setError("");
|
|
115
|
+
}
|
|
116
|
+
else
|
|
117
|
+
onCancel();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (enteringPassword)
|
|
121
|
+
return;
|
|
122
|
+
if (done) {
|
|
123
|
+
if (key.leftArrow)
|
|
124
|
+
setChoice((choice + actions.length - 1) % actions.length);
|
|
125
|
+
if (key.rightArrow)
|
|
126
|
+
setChoice((choice + 1) % actions.length);
|
|
127
|
+
if (key.upArrow)
|
|
128
|
+
setStep(fields.length - 1);
|
|
129
|
+
if (!key.return)
|
|
130
|
+
return;
|
|
131
|
+
setError("");
|
|
132
|
+
if (choice === 0)
|
|
133
|
+
void attempt(save);
|
|
134
|
+
else if (choice === 1)
|
|
135
|
+
setEnteringPassword(true);
|
|
136
|
+
else
|
|
137
|
+
onCancel();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!field)
|
|
141
|
+
return;
|
|
142
|
+
if (key.upArrow) {
|
|
143
|
+
setError("");
|
|
144
|
+
setStep(Math.max(0, step - 1));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (key.downArrow) {
|
|
148
|
+
advance();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (fixed || locked) {
|
|
152
|
+
if (key.return)
|
|
153
|
+
advance();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (isBoolean) {
|
|
157
|
+
if (input === " " || key.leftArrow || key.rightArrow)
|
|
158
|
+
toggleBoolean();
|
|
159
|
+
if (key.return)
|
|
160
|
+
advance();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (field.key === "auth") {
|
|
164
|
+
if (key.leftArrow)
|
|
165
|
+
cycleAuth(-1);
|
|
166
|
+
if (key.rightArrow || input === " ")
|
|
167
|
+
cycleAuth(1);
|
|
168
|
+
if (key.return)
|
|
169
|
+
advance();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// 文本字段的 Enter 由 TextInput 的 onSubmit 处理
|
|
173
|
+
});
|
|
174
|
+
return _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Text, { bold: true, color: colors.warm, children: [profile ? "编辑配置" : "新建配置", " ", _jsxs(Text, { color: colors.muted, children: ["\u00B7 ", Math.min(step + 1, fields.length), "/", fields.length, " \u00B7 \u5DF2\u7B54\u9879\u4FDD\u7559\u5728\u4E0A\u65B9"] })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [fields.slice(0, step).map(item => _jsxs(Box, { children: [_jsx(Box, { width: 16, flexShrink: 0, children: _jsx(Text, { color: colors.muted, wrap: "truncate", children: item.label }) }), _jsx(Box, { flexGrow: 1, minWidth: 0, children: _jsx(Text, { wrap: "truncate", color: typeof draft[item.key] === "boolean" ? (draft[item.key] ? colors.good : colors.muted) : (!draft[item.key] ? colors.muted : undefined), children: fieldValue(draft, item.key) }) })] }, item.key)), !done && field && _jsxs(Box, { flexDirection: "column", marginTop: step > 0 ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Box, { width: 16, flexShrink: 0, children: _jsxs(Text, { bold: true, color: colors.accent, wrap: "truncate", children: ["\u203A ", field.label] }) }), _jsx(Box, { flexGrow: 1, minWidth: 0, children: isBoolean || field.key === "auth"
|
|
175
|
+
? _jsxs(Text, { color: isBoolean && !draft[field.key] ? colors.muted : colors.accent, children: [fieldValue(draft, field.key), " ", _jsx(Text, { color: colors.muted, children: "\uFF08\u2190\u2192 \u5207\u6362\uFF09" })] })
|
|
176
|
+
: fixed || locked
|
|
177
|
+
? _jsxs(Text, { wrap: "truncate", children: [fieldValue(draft, field.key), " ", _jsxs(Text, { color: colors.muted, children: ["\uFF08", fixed ? "创建后固定" : "映射锁已锁定", "\uFF09"] })] })
|
|
178
|
+
: _jsx(TextInput, { defaultValue: String(draft[field.key] || ""), onSubmit: submitText }, `${field.key}-${step}`) })] }), _jsxs(Text, { color: colors.muted, wrap: "truncate", children: [" ", field.hint] })] }), done && _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { gap: 2, children: actions.map((label, offset) => _jsxs(Text, { bold: offset === choice, color: offset === choice ? colors.accent : colors.muted, children: [offset === choice ? "› " : " ", label] }, label)) }), _jsx(Text, { color: colors.muted, children: "\u2190\u2192 \u9009\u62E9 \u00B7 Enter \u786E\u8BA4 \u00B7 \u2191 \u8FD4\u56DE\u4FEE\u6539" }), enteringPassword && _jsx(PasswordInput, { onSubmit: password => void attempt(async () => {
|
|
179
|
+
const parsed = profileSchema.parse(draft);
|
|
180
|
+
await savePassword(parsed, password);
|
|
181
|
+
setDraft({ ...parsed, auth: "keychain" });
|
|
182
|
+
setEnteringPassword(false);
|
|
183
|
+
}) })] }), draft.lockMapping && draft.mapping && _jsxs(Text, { color: colors.warm, wrap: "truncate", children: ["\u9501\u5B9A\u6620\u5C04\uFF1A", draft.mapping.projectDir, " \u2192 ", draft.mapping.host, ":", draft.mapping.remoteDir, "\uFF08\u5206\u652F\u7531\u5206\u652F\u9501\u5355\u72EC\u7EA6\u675F\uFF09"] }), error && _jsx(Text, { color: colors.error, wrap: "truncate", children: error })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: colors.muted, wrap: "truncate", children: busy ? "正在处理…" : "Enter 确认 · ↑↓ 上一项 / 跳过 · Ctrl+S 保存 · Esc 取消" }) })] });
|
|
184
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akairos/code-deploy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Configurable local-to-SSH directory publishing with an Ink terminal interface",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "code-deploy": "dist/cli.js" },
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"engines": { "node": ">=22" },
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"prepack": "tsc",
|
|
13
|
+
"dev": "tsx src/cli.tsx",
|
|
14
|
+
"start": "node dist/cli.js",
|
|
15
|
+
"test": "tsx --test test/*.test.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@inkjs/ui": "^2.0.0",
|
|
19
|
+
"@napi-rs/keyring": "^2.1.1",
|
|
20
|
+
"ink": "^8.0.0",
|
|
21
|
+
"react": "^19.2.0",
|
|
22
|
+
"zod": "^4.0.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"@types/react": "^19.2.0",
|
|
27
|
+
"ink-testing-library": "^4.0.0",
|
|
28
|
+
"tsx": "^4.20.0",
|
|
29
|
+
"typescript": "^5.9.0"
|
|
30
|
+
}
|
|
31
|
+
}
|