shipwatch 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shipwatch contributors
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 ADDED
@@ -0,0 +1,284 @@
1
+ # Shipwatch
2
+
3
+ 通过 npm 全局安装的 Node.js 自动部署 CLI。后台监听当前或指定目录,将文件变化增量发布到 **Linux 和 Windows Server**,提供任务管理、实时日志、失败重试、SHA-256 发布校验与历史版本保留。
4
+
5
+ ## 安装与环境
6
+
7
+ 运行 CLI 的机器:**Node.js 22+、OpenSSH Client、macOS / Linux**;Windows 本机可通过 WSL 运行 CLI。
8
+
9
+ 目标服务器:**Linux 或 Windows Server,Node.js 22+、OpenSSH Server**。远程非交互式 SSH 会话必须能够直接执行 `node`。Windows 目标使用本地 NTFS 路径与目录联接(junction),部署账号须有创建目录联接及读写部署目录的权限。无需 rsync、Python、PM2 或额外 npm 运行时依赖。
10
+
11
+ 本仓库尚未发布至 npm registry,可以直接全局安装本地包:
12
+
13
+ ```bash
14
+ npm pack
15
+ npm install -g ./shipwatch-0.1.0.tgz
16
+ shipwatch --version
17
+ sw --version
18
+ ```
19
+
20
+ 开发时也可以运行 `node bin/shipwatch.js --help`。
21
+
22
+ ## 快速开始
23
+
24
+ ```bash
25
+ # 先输入必填任务名称,再填写其它信息,生成 shipwatch.config.json
26
+ shipwatch init
27
+
28
+ # 或指定监听目录,例如构建产物
29
+ shipwatch init ./dist
30
+
31
+ # 默认读取当前目录的 shipwatch.config.json
32
+ shipwatch
33
+
34
+ # 向默认配置追加项目,重新执行 shipwatch 启动全部项目
35
+ shipwatch add
36
+
37
+ # 创建并编辑另一份配置,不覆盖默认配置
38
+ shipwatch add production
39
+ shipwatch edit production
40
+
41
+ # 命名配置必须明确指定文件,不会被默认命令自动选中
42
+ shipwatch production.shipwatch.config.json
43
+ sw production.shipwatch.config.json
44
+ shipwatch list
45
+ shipwatch logs <任务名称> --follow
46
+ ```
47
+
48
+ `shipwatch` 和 `sw` 是完全相同的命令。`init` 首先要求输入任务名称 `name`,没有默认名称,留空或格式错误时必须重新输入。`add <name>` 则直接使用命令中的必填名称。名称须以字母或数字开头,只能包含字母、数字、`_`、`-`,最多 64 字符。
49
+
50
+ `init`、`add`、`edit` 会逐项提示本地监听目录、服务器地址、SSH 用户名/端口、服务器系统及目标目录;这些字段必填,显示默认值时可以按回车接受。密码和更新后的服务命令可选。可以继续添加多台服务器。
51
+
52
+ `add 名称` 创建 `名称.shipwatch.config.json`(内容仍为 JSON),拒绝覆盖已有文件和使用默认文件名。`edit 名称` 编辑同名配置;`edit ID` 从已注册任务定位实际配置文件(不受当前目录影响);`edit` 不传名称时默认编辑当前目录的 `shipwatch.config.json`,也可显式执行 `edit shipwatch.config.json`。编辑时回车保留原值,密码不回显;取消或校验失败不会写入文件。修改后重新执行 `shipwatch 文件名` 会重新读取配置并重启对应任务,也会应用新的监听目录。同名任务属于另一份配置时会报错。
53
+
54
+ 启动前,请用同一账号验证 SSH 密钥/Agent 登录,核对并信任主机指纹:
55
+
56
+ ```bash
57
+ ssh deploy@linux.example.com node --version
58
+ ssh deploy@windows.example.com node --version
59
+ ```
60
+
61
+ Shipwatch 始终使用 `StrictHostKeyChecking=yes`,不会自动接受未知主机。无密码时使用 `BatchMode=yes` 和密钥/Agent;密码认证通过 [OpenSSH SSH_ASKPASS](https://man.openbsd.org/ssh.1#SSH_ASKPASS_REQUIRE) 完成,服务器须允许 SSH password 认证。后台进程继承启动时的 SSH Agent 环境;更换 Agent 后需要重新启动守护进程,也可在配置中使用密钥文件。
62
+
63
+ 密码有三种方式:
64
+
65
+ - 向导中留空:使用密钥/Agent;编辑已有配置时表示保留原认证方式。
66
+ - 向导中输入密码,或手动填写 `servers[].password`:密码保存在配置文件中,向导写入权限为 `0600`。默认和命名配置及其临时文件均自动排除在部署源之外。
67
+ - 向导中输入 `:prompt`:启动时手动输入密码,仅在当前守护进程内存中保存。也可用 `shipwatch production.shipwatch.config.json --password` 或 `sw --password` 临时输入。守护进程重启后需再次执行配置文件命令输入密码;密码不会写入任务注册表、命令行参数或环境变量。
68
+
69
+ 编辑时输入 `:clear` 可以清除密码或服务命令。后续新建配置统一使用 `.json` 后缀;旧的 `shipwatch.config` 不再被默认读取,但可以通过 `sw shipwatch.config` 或 `shipwatch start -c shipwatch.config` 显式启动,旧命名配置也可显式指定。已有配置不会自动改名,内容本来就是 JSON。
70
+
71
+ ## 多项目配置
72
+
73
+ `sw add` 不带参数时向当前目录的 `shipwatch.config.json` 添加项目,任务名称必填。文件不存在时创建;原来是单项目配置时自动转换为 `projects` 数组,原项目的密码和高级配置保留。`sw add production` 仍创建独立的 `production.shipwatch.config.json`。
74
+
75
+ ```json
76
+ {
77
+ "projects": [
78
+ {
79
+ "name": "web",
80
+ "directory": "./web/dist",
81
+ "deployMode": "auto",
82
+ "servers": [{ "name": "production", "host": "linux.example.com", "user": "deploy", "platform": "linux", "path": "/srv/web" }]
83
+ },
84
+ {
85
+ "name": "game",
86
+ "directory": "./game/dist",
87
+ "deployMode": "manual",
88
+ "servers": [{ "name": "production", "host": "windows.example.com", "user": "deploy", "platform": "windows", "path": "C:\\apps\\game" }]
89
+ }
90
+ ]
91
+ }
92
+ ```
93
+
94
+ 原来的单项目对象仍可直接使用。每份配置支持 1–100 个项目,各自配置服务器、部署模式及重试等参数;名称和监听目录不能重复,目录相对于配置文件解析。
95
+
96
+ `sw`、`sw 文件名` 和 `sw start -c 文件名` 启动该文件全部项目;先校验全部配置再注册任务。重新执行 `sw` 会重启该文件的已有项目并加入新项目,已有 ID 保持不变。自动模式监听并发布,手动模式等待 `sw deploy ID`。多项目文件不能使用统一的命令行目录覆盖。
97
+
98
+ `sw edit` 编辑默认文件时会询问项目名称;`sw edit ID` 直接编辑对应项目,保留同文件的其它项目。重启任务和恢复后台均按项目名称读取配置。删除配置中的项目不会删除已注册任务,仍须执行 `sw kill ID`。`sw deploy --once -c 文件名` 依次发布全部项目并分别输出报告,任一服务器失败返回非零退出码。
99
+
100
+ 升级前已运行的后台进程需先用 `sw daemon stop` 停止,再执行 `sw` 加载新版多项目功能;停止后台保留任务记录。
101
+
102
+ ## 单项目的多服务器配置
103
+
104
+ ```json
105
+ {
106
+ "name": "web",
107
+ "directory": "./dist",
108
+ "deployMode": "auto",
109
+ "intervalMs": 1000,
110
+ "debounceMs": 800,
111
+ "retry": { "attempts": 3, "delayMs": 1000 },
112
+ "keepReleases": 5,
113
+ "timeoutMs": 120000,
114
+ "exclude": ["**/*.map", "cache/**"],
115
+ "servers": [
116
+ {
117
+ "name": "linux",
118
+ "platform": "linux",
119
+ "host": "linux.example.com",
120
+ "user": "deploy",
121
+ "port": 22,
122
+ "identityFile": "~/.ssh/id_ed25519",
123
+ "path": "/srv/web"
124
+ },
125
+ {
126
+ "name": "windows",
127
+ "platform": "windows",
128
+ "host": "windows.example.com",
129
+ "user": "deploy",
130
+ "port": 22,
131
+ "path": "C:\\apps\\web"
132
+ }
133
+ ]
134
+ }
135
+ ```
136
+
137
+ | 参数 | 行为 |
138
+ | --- | --- |
139
+ | `name` | 唯一任务名;字母、数字、`_`、`-`,最多 64 字符 |
140
+ | `deployMode` | `auto`(默认):启动及文件变化时自动发布;`manual`:仅通过 `sw deploy <name>` 触发,后台重启后也不自动发布 |
141
+ | `directory` | 相对于配置文件;命令行目录参数相对于当前工作目录 |
142
+ | `intervalMs` | 完整内容扫描间隔,默认 1000 ms,最小 100 ms |
143
+ | `debounceMs` | 检测到变化后等待稳定的时间,默认 800 ms |
144
+ | `retry.attempts` | 每台服务器的总尝试次数,包含首次,默认 3,最多 10 |
145
+ | `retry.delayMs` | 重试初始延时,指数增长,上限 60 秒 |
146
+ | `keepReleases` | 保留版本总数,包含当前版本,默认 5,最少 1 |
147
+ | `timeoutMs` | 单次远程操作的超时,默认 120 秒;大批量上传可适当增大 |
148
+ | `exclude` | 额外排除规则;支持 `*`、`?`、`**`,不支持取反规则 |
149
+ | `servers[].platform` | `linux`(默认)或 `windows` |
150
+ | `servers[].path` | 应用专属绝对目录,例如 `/srv/web`、`C:\apps\web`,不能是磁盘根目录 |
151
+ | `servers[].identityFile` | 可选;相对于配置文件,支持 `~/`;省略时使用 SSH 默认认证配置 |
152
+ | `servers[].password` | 可选;SSH 登录密码;输入密码后采用 password 认证 |
153
+ | `servers[].passwordPrompt` | 可选;`true` 时在启动命令中输入密码,仅保存在内存中 |
154
+ | `servers[].postDeployCommand` | 可选;校验与版本切换成功后,以部署账号在配置的目标目录执行的服务命令 |
155
+
156
+ 默认排除 `.git`、`node_modules`、`.env`、`.env.*`、`*.pem`、`*.key`、`.shipwatch`、`shipwatch.config.json`、`*.shipwatch.config.json`、配置临时文件、旧版 `shipwatch.config` / `*.shipwatch.config`、内部快照目录和清单文件。实际使用的配置文件也会自动排除。无 `/` 的规则匹配任意层级的名称;含 `/` 的规则相对于源目录。排除目录会同时排除其子树。
157
+
158
+ 监听源必须是普通文件/目录树。符号链接、特殊文件、跨平台不安全文件名会使发布失败;Windows 目标还会拒绝大小写冲突。Linux 发布保留文件/目录权限,Windows 使用目标目录继承的 ACL,不映射 POSIX 执行位。
159
+
160
+ ## 命令
161
+
162
+ ```bash
163
+ shipwatch init [directory] [-c config.json]
164
+ shipwatch add
165
+ shipwatch add production
166
+ shipwatch edit production
167
+ shipwatch edit shipwatch.config.json
168
+ shipwatch
169
+ shipwatch production.shipwatch.config.json [--password]
170
+ shipwatch start [directory] [-c config.json]
171
+ shipwatch list --json
172
+ shipwatch info web
173
+ shipwatch status web
174
+ shipwatch stop web
175
+ shipwatch restart web
176
+ shipwatch delete web
177
+ shipwatch deploy web
178
+ shipwatch deploy web --follow
179
+ sw deploy 0 -f
180
+ shipwatch mode web manual
181
+ shipwatch mode 0 auto
182
+ shipwatch logs web --lines 100 --follow
183
+ shipwatch daemon start
184
+ shipwatch daemon stop
185
+ shipwatch kill
186
+ shipwatch kill web
187
+
188
+ # 一次性部署,任一服务器失败则以非零状态退出
189
+ shipwatch deploy --once -c production.shipwatch.config.json --directory ./dist
190
+ ```
191
+
192
+ - `start` 创建后台任务,脱离终端运行;首次稳定扫描会自动部署。重复任务名不会创建第二个实例。
193
+ - `stop` 取消当前传输、重试等待及后续监听,并等待清理完成。
194
+ - `kill` 不带名称时,停止当前 `SHIPWATCH_HOME` 下的全部任务、清空持久化任务记录并关闭守护进程;再次执行 `list` 列表为空。已停止和配置失效的任务也会删除,配置文件、日志和服务器发布文件保留。守护进程未运行时,会临时启动不恢复部署的进程来清理历史记录。
195
+ - 后台探活超过 5 秒无响应,或 `kill` 的停止请求超过 30 秒未完成时,会核对锁文件、进程所有者和后台入口,强制结束该后台及其本地子进程,再清空任务记录。不按进程名批量结束其它 Node.js 程序;身份无法确认时拒绝强制结束。强制结束可能留下未完成的远端发布和锁,恢复方法见“中断与恢复”。
196
+ - `kill <name>` 停止指定任务并从 `list` 和持久化任务记录中删除,其它运行中的任务继续运行;保留配置文件、日志和服务器发布文件。只停止但保留任务记录请用 `stop <name>`。
197
+ - 更新 CLI 文件不会替换已经运行的守护进程。`kill` 检测清空能力;对于不支持清空的旧版本,依次使用已有的 delete/shutdown 命令删除全部任务并关闭进程。删除失败会直接报告,不会跳过失败强行关闭。
198
+ - 同一 `SHIPWATCH_HOME` 内任务名称和源目录均唯一。相同目录的软链接及大小写别名也不能重复注册;已停止任务仍占用目录,须先 `delete/kill <name>` 释放。同一配置再次运行是重启原任务,不会新增重复任务。
199
+ - `restart` 重新读取配置并启动;任务名称应保持不变。更改名称时先删除旧任务,再启动新任务。
200
+ - `deploy <name>` 为运行中的任务排队一次部署;执行期间的多次请求会合并为一次后续部署。
201
+ - `deploy <name|id> --follow`(`-f`)触发发布并实时跟随本次排队的发布轮次,支持名称和 ID。交互终端展示渐变标题、阶段流程、每台服务器独立的文件进度条、已用时间和重试倒计时;文件数表示已送入 SSH 传输流的文件,不是整个发布的完成百分比,传输完毕后仍须校验和发布。画面使用终端备用屏幕,随窗口尺寸调整;服务器过多时保留可见部分并提示,结束后显示全部结果。正常完成、中断或错误均恢复光标与原终端,留下统计表,失败返回非零退出码。
202
+ - 非交互输出按进度变化逐行打印,不输出光标控制符;`-f --json` 等待后仅输出本次报告 JSON。`Ctrl+C` 只退出进度查看,后台发布继续;任务停止、删除或重启会结束查看并提示错误。不带 `-f` 仍立即返回排队结果;`--once` 保留原来的阶段日志。旧后台需要重启才能使用动态进度。
203
+ - `mode <name|id> auto|manual` 修改是否自动部署,例如 `sw mode 0 auto` 开启、`sw mode web manual` 关闭。同步保存对应项目的 `deployMode`,多项目配置中的其它项目保持不变;运行中的任务立即应用,已开始的发布正常完成,已停止的任务不会被启动。后台重启后保留该设置。旧后台需先重启才能使用此命令。
204
+ - `delete` 删除任务记录,保留日志和服务器上的发布文件。
205
+ - `status` 显示状态、目录、时间及每台服务器的结果;`error` 任务仍在监听,`running` 字段说明循环是否运行。
206
+ - `list`、`info/status` 和后台探活不等待停止/重启队列;后台无响应时 5 秒内报错。旧后台需重启才能使用新的查询处理逻辑;读取状态时任务可能仍在停止或清理中。
207
+ - `list` 使用灰色细线边框、青色表头和彩色状态标记(● 监听绿色、◌ 等待黄色、◈ 发布中蓝色、○ 停止灰色、× 异常红色),交互输出增加渐变 SHIPWATCH 标题、任务数量和快捷命令。列顺序为 ID、名称、状态、自动部署(开启/关闭)、自动次数、启动时间,不展示目录或最近发布时间;过窄时按字段换行,重定向默认按 80 列排版。`list --json` 仍保留原始字段。
208
+ - 视觉效果只在交互终端开启:真彩色显示青色→蓝色→紫色渐变,256 色终端使用对应色阶,基础终端回退到标准 ANSI 色;保留用户的终端背景。`NO_COLOR`、`TERM=dumb` 和重定向输出禁用颜色及动态画面,JSON 始终保持原始数据。详情突出发布结果与耗时;交互向导增加分段标题,日志按级别与稳定的项目颜色区分。
209
+ - “自动次数”按自动触发的发布轮次统计:首次自动发布及后续文件变化各计一次,开始制作快照时计数,失败或取消也保留该次;多服务器和失败重试不重复计数,手动 `deploy` 不计数。计数随任务持久化,重启不清零,删除后重新添加从 0 开始;旧任务从 0 开始,不回溯历史日志。`info <name|id>` 可查看最近发布时间和自动次数。
210
+ - `info <name|id>` 查看任务 ID、完整监听目录、配置文件、部署模式、启动时间及最近发布统计;长路径换行显示,不省略中间内容。支持 `sw info <name|id>` 和 `--json`,原有 `status` 命令保留。
211
+ - 每台服务器独立重试。耗尽重试后不会无限重复发布相同内容;自动模式下,下次文件变化、手动 `deploy` 或 `restart` 会再次尝试;手动模式须再次执行 `deploy`。
212
+
213
+ `sw status <name>`、`sw logs <name> -f` 中的发布报告以及 `sw deploy --once` 默认使用中文统计表:项目文件总数(不含目录及排除文件)、每台服务器的待更新/删除文件数、发布状态、开始和结束时间、整个过程耗时、服务器发布耗时及上传耗时。上传耗时累计所有尝试的上传阶段(包含该阶段 SSH 建连),不含校验、切换、服务命令或重试等待;整个过程从创建快照前计时,到最后清理完成为止。新增和修改文件计入更新数,删除单列;各服务器分别统计,不相加冒充项目唯一文件数。
214
+
215
+ 人类可读时间使用本机时区,格式为 `YYYY-MM-DD HH:mm:ss`;常用提示、状态和日志标题为中文,SSH/操作系统及服务程序的诊断保留原文。需要原始字段的脚本请显式使用 `--json`,UTC 时间和状态代码保持结构化格式。新配置自动写入 `deployMode: "auto"`,可手动编辑为 `"manual"`,再执行配置文件应用修改。
216
+
217
+ 自动发布报告另外显示“本地检测时间”“发布成功时间”“检测到发布成功耗时”:从扫描首次发现本轮内容变化开始,到全部服务器发布成功返回为止,包含防抖稳定等待、快照、上传、校验、重试、服务命令及远端清理,不包含最后的本地快照清理。连续变化合并为一轮时,仍从首次检测计时。这里记录的是工具检测时间,不是文件实际修改时间;首次启动的内容扫描也按一次检测计时。失败、取消或部分服务器失败不会显示成功耗时。`sw info <name|id>`、`sw status <name|id>` 和 `sw logs <name|id> -f` 均可查看;旧日志与手动部署没有检测起点,不补造该耗时。
218
+
219
+ 任务 ID 从 0 开始分配,持久保存在当前 `SHIPWATCH_HOME`;删除单个任务不会重排其它 ID,也不会复用已删除 ID,重启后台保留原 ID。执行不带参数的 `sw kill` 清空全部任务后,ID 计数重置,下次新增任务从 0 开始;旧后台及强制清理同样适用。旧任务记录在新版后台启动时自动补齐 ID。列表“启动时间”指当前任务实例最近启动或重启的时间,不是首次创建时间。
220
+
221
+ `kill`、`edit`、`info`、`status`、`stop`、`restart`、`delete`、`deploy`、`logs` 均支持数字 ID,例如 `sw info 0`、`sw edit 0`、`sw kill 0`。纯数字参数优先作为 ID;纯数字名称使用 `name:123` 明确指定,或在 edit 时使用完整命名配置文件名。不传名称的 `sw edit` 仍编辑默认配置。旧版后台没有 ID 能力时会提示重启,不能使用临时列表序号操作。
222
+
223
+ 后台状态保存在 `~/.shipwatch`,可用 `SHIPWATCH_HOME` 指定独立目录。ID 分配计数保存在 `next-task-id.json`,不要手动删除以免复用历史 ID。不带参数的 `sw logs` 合并当前 `SHIPWATCH_HOME` 下所有项目的现有 `.log` 文件,包括已删除任务保留的日志;按时间排序并标明项目名称。`sw logs -f` 实时跟随全部项目,也会发现新项目日志;`--lines N` 按每个项目取最近 N 条,`--lines 0 -f` 只看后续写入。带名称或 ID 时仍只查看指定项目。无需启动后台即可读取全部项目日志。任务日志是 JSON Lines,每个任务保留当前和上一份日志,各约 5 MiB。IPC socket 和状态目录仅限当前用户访问。
224
+
225
+ 任务的运行/停止意图持久化到磁盘。重新启动守护进程时恢复任务;`daemon stop` 不清除运行意图。**不会自动注册系统开机服务,也不会在守护进程被强杀后自行重启**;需要常驻开机服务时,由 systemd/launchd 等启动 `shipwatch daemon start`。单个任务的普通扫描/部署错误会在守护进程内捕获并记录。
226
+
227
+ ## 发布流程与目录
228
+
229
+ 1. 流式计算源文件 SHA-256,同时记录空目录和 POSIX 权限。
230
+ 2. 制作本地快照,对比源目录捕获前、快照、捕获后的摘要;变化中的内容不会发布。
231
+ 3. 经 SSH 安装按内容哈希命名的 Node.js 远程代理到服务器账号的 `~/.shipwatch-agents`。
232
+ 4. 为每台服务器获取目标目录独占锁,创建独立发布版本。与上一版本摘要相同的文件在远端复制,新增/修改文件通过带背压的分块流上传。
233
+ 5. 独立遍历远端版本,验证全部 SHA-256、缺失/多余文件、类型和 Linux 权限。删除操作表现为新版本不再包含该文件。
234
+ 6. 校验通过后同步文件到配置的目标目录,删除上次发布清单中已移除的文件,再切换内部 `current` 版本指针、执行可选的服务命令,清理超出保留数量的旧版本,并释放锁。
235
+
236
+ ```text
237
+ /srv/ 或 C:\apps\
238
+ ├── app/
239
+ │ ├── .shipwatch-owned
240
+ │ ├── .shipwatch-releases
241
+ │ ├── .shipwatch-live.json
242
+ │ ├── current -> ../app-releases/<版本号>
243
+ │ ├── index.html
244
+ │ └── 其它发布文件
245
+ └── app-releases/
246
+ ├── <历史版本号>/
247
+ └── <当前版本号>/
248
+ ├── .shipwatch-manifest.json
249
+ └── 你的发布文件
250
+ ```
251
+
252
+ 配置目标目录为 `/srv/app` 或 `C:\apps\app` 时,版本目录为同级的 `app-releases`(目录名加 `-releases`,不是空格)。部署账号需要目标父目录的创建权限。旧版 `app/releases` 中的当前版本仍可用于增量复制;首次成功发布后 `current` 指向新目录。旧目录保留且不参与新目录的版本保留计数,确认站点正常后可自行归档或清理。之前的同级 `appreleases` 也兼容读取,新的版本写入 `app-releases`,旧目录保留且不计入新目录的版本保留数量。已有的非 Shipwatch 管理的同名目录会拒绝接管。
253
+
254
+ 将 Nginx/IIS/静态文件服务的站点根目录指向配置的目标目录,例如 `C:\apps\web`。发布后该目录直接包含 `index.html` 等文件,无需指向 `current`。`current` 仅记录已发布版本。请在 Web 服务器中禁止访问 `.shipwatch-*` 内部文件、`current` 和遗留 `releases` 目录。需要重启应用时可填写 `postDeployCommand`,例如 Linux 的 `systemctl restart my-app`,或 Windows 的 `powershell -NoProfile -Command "Restart-Service MyApp"`;部署账号须有对应权限。命令使用远端系统 shell 执行,工作目录为配置的目标目录,输出进入部署日志,超时沿用 `timeoutMs`。命令应完成后退出,不要直接启动永不退出的前台服务。
255
+
256
+ 服务命令只在发布成功后执行一次;失败或超时会报告 `phase: postDeploy` 和 `published: true`,当前版本保留,不自动回滚、重新部署或重复执行服务命令。此时需查看日志并处理服务问题。校验失败不会执行服务命令。网络中断时远端命令可能继续运行到自身超时;不要在未确认状态前重复触发有副作用的命令。
257
+
258
+ 目标目录按文件同步:每个变更文件先复制到临时文件并校验 SHA-256,再替换正式文件;整批文件不保证原子切换,同步过程中可能短暂存在新旧文件混合。同步失败会报告错误并按配置重试,不执行服务命令;中断后可重新发布修复。只有上次发布清单管理的文件会随源文件删除,未托管的服务器文件保留。内部 `current` 在 Linux 使用软链接,在 Windows 使用 junction;Windows 指针切换仍可能短暂缺失,多台服务器之间也不提供全局原子事务。
259
+
260
+ 保留版本通过远端复制隔离,不与旧版本建立硬链接。请将运行时上传、日志、数据库及其他可变文件放在发布目录之外;目标目录中同名的发布文件会被覆盖;不要修改 `current` 中的历史版本文件。源目录顶层的 `current`、`releases` 和 `.shipwatch-*` 等内部管理名称保留,不可作为发布内容。
261
+
262
+ 增量粒度是**文件级**,改变的文件会完整上传,传输使用 Base64 帧(约 33% 编码开销),不实现 rsync 的文件内部块级差分。源扫描与远端校验均会读取全部文件内容,适合构建产物/静态站点;对于大量大文件应增大扫描间隔和超时。
263
+
264
+ ## 中断与恢复
265
+
266
+ - 校验失败不会切换当前版本;正常错误和任务停止会尝试删除暂存版本并释放属于本次发布的锁。
267
+ - 主机掉电、进程强杀或网络断开可能遗留 `.shipwatch-lock`。工具不会根据时间自动抢占锁,避免误伤仍在执行的发布。
268
+ - 遇到残留锁,先确认该目标没有正在运行的发布/远端代理,再检查锁内的版本号、`current` 指向及对应的同级 `<目标目录名>releases/<版本号>`。只删除确定不再使用的暂存版本和锁;不要删除 `current` 指向的版本。
269
+ - Windows 若在联接切换期间掉电且 `current` 缺失,检查目标根目录中的 `.previous-<版本号>`,将它重命名回 `current` 后再处理锁。保留的发布目录可用于人工恢复;当前版本不提供独立 rollback 命令。
270
+ - 不会接管已有且没有 `.shipwatch-owned` 标记的旧 `releases/current` 目录或未授权的同级版本目录,也不会删除其他发布者持有的锁。
271
+
272
+ ## 工程与验证
273
+
274
+ ```bash
275
+ npm run check
276
+ npm test
277
+ npm pack --dry-run
278
+ ```
279
+
280
+ 纯 JavaScript,无需构建或第三方依赖。`src/config.js` 校验配置,`snapshot.js` 捕获源树,`deploy.js` 管理传输与重试,`remote-agent.cjs` 执行跨平台发布,`task.js` 管理监听循环,`daemon.js` 管理任务生命周期,`bin/shipwatch.js` 提供 CLI。
281
+
282
+ 测试包括真实文件系统上的增量发布、校验失败阻止切换、版本保留、文件删除、部分失败/重试、任务竞态、子进程超时,以及经过本地 SSH 模拟器的后台 CLI 集成测试。CI 配置覆盖 Linux/macOS CLI 与 Windows 原生远端代理。Windows 的真实 SSH/IIS 环境仍需在目标服务器上执行一次验收。
283
+
284
+ 后台进程实现参考 [Node.js child_process 官方文档](https://nodejs.org/api/child_process.html)。
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /** OpenSSH askpass entry: retrieve one password from the private in-memory bridge.
3
+ * Uses net only; stdout is consumed by SSH, never by the deployment logger.
4
+ */
5
+ const net = require('node:net');
6
+ if (!process.env.SHIPWATCH_AUTH_SOCKET || !process.env.SHIPWATCH_AUTH_TOKEN) process.exit(1);
7
+ const socket = net.createConnection(process.env.SHIPWATCH_AUTH_SOCKET);
8
+ socket.setTimeout(5000, () => { socket.destroy(); process.exitCode = 1; });
9
+ socket.on('error', () => { process.exitCode = 1; });
10
+ socket.on('connect', () => socket.write(process.env.SHIPWATCH_AUTH_TOKEN + '\n'));
11
+ socket.on('data', (chunk) => process.stdout.write(chunk));
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env node
2
+ /** Shipwatch CLI: configuration init, background task commands, one-shot deploy and live logs.
3
+ * Uses built-in argument parsing and delegates business logic to src modules.
4
+ */
5
+ import { parseArgs } from 'node:util';
6
+ import { mkdir, open, readdir } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { setTimeout as delay } from 'node:timers/promises';
9
+ import { StringDecoder } from 'node:string_decoder';
10
+ import { DEFAULT_CONFIG, loadConfig, loadConfigs, validName } from '../src/config.js';
11
+ import { editConfiguration, namedConfig, collectPasswords } from '../src/config-editor.js';
12
+ import { ensureDaemon, request, paths, killDaemon, taskSelector, requestTask } from '../src/client.js';
13
+ import { snapshot } from '../src/snapshot.js';
14
+ import { deploy } from '../src/deploy.js';
15
+ import { formatTaskDetails, formatResult, formatReport, formatTime, messageText } from '../src/presentation.js';
16
+ import { formatTaskOverview as formatTaskList } from '../src/presentation.js';
17
+ import { brand, colorLevel, tint, notice, projectTone } from '../src/terminal.js';
18
+ import { followDeployment } from '../src/progress.js';
19
+
20
+ const HELP = `Shipwatch — Linux 和 Windows Server 增量发布工具
21
+
22
+ shipwatch 启动默认配置 ./shipwatch.config.json
23
+ shipwatch <name>.shipwatch.config.json 启动或重启指定配置
24
+ shipwatch add [name] 无名称时向默认配置追加项目;有名称时创建独立配置
25
+ shipwatch edit [name|id] 编辑配置;不传名称时编辑 shipwatch.config.json
26
+ shipwatch init [directory] [--config file]
27
+ shipwatch start [directory] [--config file]
28
+ shipwatch list [--json]
29
+ shipwatch info <name|id> [--json] 查看任务详情、完整目录和发布统计
30
+ shipwatch status <name|id>
31
+ shipwatch stop|restart|delete <name|id>
32
+ shipwatch deploy <name|id> [-f|--follow] 手动发布;-f 显示动态进度并等待完成
33
+ shipwatch mode <name|id> auto|manual 修改自动/手动部署,保存配置并立即应用
34
+ shipwatch deploy --once [--config file] [--directory path]
35
+ shipwatch logs [name|id] [--lines 100] [--follow]
36
+ shipwatch daemon start|stop
37
+ shipwatch kill 停止并删除全部任务,关闭后台进程
38
+ shipwatch kill <name|id> 停止并删除指定任务
39
+
40
+ 纯数字参数按任务 ID 操作;纯数字名称请使用 name:123。
41
+ 全部命令支持别名 sw;--password 临时输入密码,不保存。
42
+ 默认配置:./shipwatch.config.json;本机和服务器需要 Node >=22。
43
+ SSH 主机密钥须已信任;支持密钥、SSH Agent 或密码认证。
44
+ Windows Server 使用 platform: "windows",路径示例 C:\\apps\\web.
45
+ `;
46
+
47
+ /** Structured log lines support scripts while the human view keeps server attribution. */
48
+ function printLog(line, project) {
49
+ const level = colorLevel();
50
+ const prefix = project ? tint(`[项目:${project}] `, projectTone(project), level) : '';
51
+ try {
52
+ const entry = JSON.parse(line);
53
+ if (entry.message.startsWith('SHIPWATCH_REPORT ')) {
54
+ process.stdout.write(prefix + (prefix ? '\n' : '') + formatReport(JSON.parse(entry.message.slice('SHIPWATCH_REPORT '.length)), process.stdout.columns));
55
+ return;
56
+ }
57
+ const tone = entry.level === 'error' ? 'red' : entry.level === 'warn' ? 'yellow' : 'cyan';
58
+ process.stdout.write(`${prefix}${tint(formatTime(entry.time), 'muted', level)} ${tint(`${level ? entry.level === 'error' ? '✗ ' : entry.level === 'warn' ? '⚠ ' : '· ' : ''}${{ info: '信息', warn: '警告', error: '错误' }[entry.level] ?? entry.level}`, tone, level)}${entry.server ? tint(` [${entry.server}]`, projectTone(entry.server), level) : ''} ${messageText(entry.message)}\n`);
59
+ } catch { process.stdout.write(prefix + line + '\n'); /* Preserve interrupted final log records for diagnosis. */ }
60
+ }
61
+
62
+ /** Merge each task's tail by timestamp; discover new files and track independent rotation/UTF-8 state. */
63
+ async function logs(name, lines, follow) {
64
+ if (name !== undefined && !validName(name)) throw new Error('Invalid task name');
65
+ const directory = paths().logs;
66
+ const states = new Map();
67
+ const controller = new AbortController();
68
+ const stop = () => controller.abort();
69
+ const discover = async () => {
70
+ if (name !== undefined) return [name];
71
+ try {
72
+ return (await readdir(directory, { withFileTypes: true }))
73
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.log') && validName(entry.name.slice(0, -4)))
74
+ .map((entry) => entry.name.slice(0, -4)).sort();
75
+ } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
76
+ };
77
+ const readBatch = async (initial) => {
78
+ const records = [];
79
+ for (const project of await discover()) {
80
+ let handle;
81
+ try {
82
+ handle = await open(path.join(directory, `${project}.log`), 'r');
83
+ const metadata = await handle.stat();
84
+ let state = states.get(project);
85
+ if (!state || state.inode !== metadata.ino || metadata.size < state.offset) {
86
+ state = { inode: metadata.ino, offset: 0, pending: '', decoder: new StringDecoder('utf8') };
87
+ states.set(project, state);
88
+ }
89
+ const buffer = Buffer.alloc(Math.max(0, metadata.size - state.offset));
90
+ const { bytesRead } = buffer.length ? await handle.read(buffer, 0, buffer.length, state.offset) : { bytesRead: 0 };
91
+ state.offset += bytesRead;
92
+ state.pending += state.decoder.write(buffer.subarray(0, bytesRead));
93
+ let entries = state.pending.split('\n');
94
+ state.pending = entries.pop();
95
+ if (initial && !follow && state.pending) { entries.push(state.pending); state.pending = ''; }
96
+ entries = entries.filter(Boolean);
97
+ if (initial) entries = lines ? entries.slice(-lines) : [];
98
+ for (const line of entries) {
99
+ let time = Infinity;
100
+ try { const value = Date.parse(JSON.parse(line).time); if (Number.isFinite(value)) time = value; } catch { /* Keep malformed records visible with their project label. */ }
101
+ records.push({ line, project, time });
102
+ }
103
+ } catch (error) {
104
+ if (error.code !== 'ENOENT') throw error;
105
+ if (initial && name !== undefined) throw new Error(`No logs for task ${name}`);
106
+ } finally { await handle?.close(); }
107
+ }
108
+ records.sort((a, b) => a.time - b.time);
109
+ for (const record of records) printLog(record.line, name === undefined ? record.project : undefined);
110
+ };
111
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
112
+ try {
113
+ await readBatch(true);
114
+ if (!follow) {
115
+ if (name === undefined && !states.size) process.stdout.write('暂无项目日志。\n');
116
+ return;
117
+ }
118
+ while (!controller.signal.aborted) {
119
+ await delay(250, undefined, { signal: controller.signal });
120
+ await readBatch(false);
121
+ }
122
+ } catch (error) { if (!controller.signal.aborted) throw error; }
123
+ finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); }
124
+ }
125
+
126
+ /** Start all projects from one validated file; resident old daemons must be upgraded first. */
127
+ async function launchProjects(configurations, { restartExisting = false, password = false, directory } = {}) {
128
+ const projectPasswords = Object.create(null);
129
+ for (const config of configurations) projectPasswords[config.name] = await collectPasswords(config, password);
130
+ await ensureDaemon();
131
+ if (configurations.length > 1) {
132
+ if (!(await request('ping')).capabilities?.includes('multi-project')) throw new Error('当前后台进程不支持多项目配置,请重启后台进程');
133
+ return request('start-many', { configFile: configurations[0].configFile, directory, restartExisting, projectPasswords });
134
+ }
135
+ const config = configurations[0];
136
+ const existing = restartExisting && (await request('list')).find((task) => task.name === config.name);
137
+ if (existing && (await request('status', { name: config.name })).configFile !== config.configFile) throw new Error(`Task ${config.name} belongs to another configuration`);
138
+ return request(existing ? 'restart' : 'start', { name: config.name, projectName: config.name, configFile: config.configFile, directory, passwords: projectPasswords[config.name] });
139
+ }
140
+
141
+ /** Validate command shape before starting a daemon or creating config files. */
142
+ async function main() {
143
+ const { values, positionals, tokens } = parseArgs({ allowPositionals: true, tokens: true, options: {
144
+ config: { type: 'string', short: 'c' }, directory: { type: 'string', short: 'd' },
145
+ json: { type: 'boolean' }, follow: { type: 'boolean', short: 'f' }, once: { type: 'boolean' },
146
+ lines: { type: 'string', default: '100' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' }, password: { type: 'boolean' },
147
+ } });
148
+ if (values.version) { process.stdout.write('0.1.0\n'); return; }
149
+ if (values.help) { process.stdout.write(brand() + HELP); return; }
150
+ const fileLaunch = !positionals.length || positionals[0] === DEFAULT_CONFIG || positionals[0] === 'shipwatch.config' || positionals[0].endsWith('.shipwatch.config.json') || positionals[0].endsWith('.shipwatch.config');
151
+ if (fileLaunch) {
152
+ if (positionals.length > 1 || tokens.some((token) => token.kind === 'option' && !['password', 'json'].includes(token.name))) throw new Error('Use shipwatch [name.shipwatch.config.json] [--password]');
153
+ const configurations = await loadConfigs(path.resolve(positionals[0] ?? DEFAULT_CONFIG));
154
+ const result = await launchProjects(configurations, { restartExisting: true, password: values.password });
155
+ process.stdout.write(values.json ? JSON.stringify(result, null, 2) + '\n' : Array.isArray(result) ? formatTaskList(result, process.stdout.columns) : formatResult(result, process.stdout.columns));
156
+ return;
157
+ }
158
+ const [command, target, ...extra] = positionals;
159
+ if (extra.length && !(command === 'mode' && extra.length === 1)) throw new Error('Too many arguments');
160
+ const allowed = {
161
+ init: ['config', 'directory'], add: [], edit: [], start: ['config', 'directory', 'password', 'json'], list: ['json'],
162
+ info: ['json'], status: ['json'], stop: ['json'], restart: ['json', 'password'], delete: ['json'],
163
+ deploy: values.once ? ['once', 'config', 'directory', 'json', 'password'] : ['json', 'follow'],
164
+ logs: ['lines', 'follow'], daemon: [], kill: ['json'], mode: ['json'],
165
+ };
166
+ for (const token of tokens) if (token.kind === 'option' && !(allowed[command] ?? []).includes(token.name)) throw new Error(`--${token.name} is not supported for ${command}`);
167
+ if (['init', 'start'].includes(command) && target && values.directory) throw new Error('Provide a positional directory or --directory, not both');
168
+ const configFile = path.resolve(values.config ?? DEFAULT_CONFIG);
169
+ if (command === 'mode') {
170
+ taskSelector(target);
171
+ if (!['auto', 'manual'].includes(extra[0])) throw new Error('用法:sw mode <名称|ID> auto|manual');
172
+ await ensureDaemon();
173
+ if (!(await request('ping')).capabilities?.includes('deploy-mode')) throw new Error('当前后台不支持修改部署模式,请先重启后台进程');
174
+ const result = await requestTask('mode', target, { mode: extra[0] });
175
+ process.stdout.write(values.json ? JSON.stringify(result, null, 2) + '\n' : notice(`项目 ${result.name}:自动部署已${extra[0] === 'auto' ? '开启' : '关闭'},配置已保存`) + '\n');
176
+ return;
177
+ }
178
+ if (command === 'kill') {
179
+ let result;
180
+ if (target) {
181
+ taskSelector(target);
182
+ // Reuse delete's serialized stop-and-remove lifecycle so other tasks keep running.
183
+ await ensureDaemon();
184
+ result = await requestTask('delete', target);
185
+ } else result = await killDaemon();
186
+ process.stdout.write(values.json ? JSON.stringify(result, null, 2) + '\n' : formatResult(result, process.stdout.columns));
187
+ return;
188
+ }
189
+ if (command === 'add' || command === 'edit') {
190
+ if (command === 'add' && target === undefined) {
191
+ await editConfiguration(path.resolve(DEFAULT_CONFIG), { append: true });
192
+ process.stdout.write(notice(`已添加项目到 ${DEFAULT_CONFIG}`) + '\n运行:sw\n');
193
+ return;
194
+ }
195
+ let named;
196
+ if (command === 'edit' && target !== undefined && /^\d+$/.test(target)) {
197
+ taskSelector(target);
198
+ await ensureDaemon();
199
+ const task = await requestTask('status', target);
200
+ named = { filename: task.configFile, projectName: task.name };
201
+ } else named = command === 'edit' && (target === undefined || target === DEFAULT_CONFIG) ? { filename: path.resolve(DEFAULT_CONFIG) } : namedConfig(command === 'edit' && target?.startsWith('name:') ? target.slice(5) : target);
202
+ await editConfiguration(named.filename, { name: named.name, projectName: named.projectName, editing: command === 'edit' });
203
+ process.stdout.write(notice(`已保存 ${named.filename}`) + `\n运行:shipwatch ${path.basename(named.filename)}\n`);
204
+ return;
205
+ }
206
+ if (command === 'init') {
207
+ const directory = path.resolve(target ?? values.directory ?? '.');
208
+ await mkdir(path.dirname(configFile), { recursive: true });
209
+ await editConfiguration(configFile, { directory: path.relative(path.dirname(configFile), directory) || '.' });
210
+ process.stdout.write(notice(`已创建 ${configFile}`) + '\n'); return;
211
+ }
212
+ if (command === 'logs') {
213
+ const count = Number(values.lines);
214
+ if (!Number.isInteger(count) || count < 0 || count > 10000) throw new Error('--lines must be 0..10000');
215
+ if (target === undefined) return logs(undefined, count, values.follow);
216
+ const selector = taskSelector(target);
217
+ if (selector.id !== undefined) {
218
+ await ensureDaemon();
219
+ return logs((await requestTask('status', target)).name, count, values.follow);
220
+ }
221
+ return logs(selector.name, count, values.follow);
222
+ }
223
+ if (command === 'deploy' && values.once) {
224
+ if (target) throw new Error('--once does not accept a task name; use --directory');
225
+ const configurations = await loadConfigs(configFile, values.directory);
226
+ for (const config of configurations) await collectPasswords(config, values.password);
227
+ const controller = new AbortController();
228
+ const stop = () => controller.abort();
229
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
230
+ const reports = [];
231
+ try {
232
+ for (const config of configurations) {
233
+ controller.signal.throwIfAborted();
234
+ const startedAt = new Date().toISOString();
235
+ const started = performance.now();
236
+ const captured = await snapshot(config);
237
+ let results;
238
+ try {
239
+ results = await deploy(config, captured, (level, message, server) => process.stderr.write(`${formatTime(new Date())} [${config.name}/${server}] ${messageText(message)}\n`), controller.signal);
240
+ } finally { await captured.cleanup(); }
241
+ const report = { name: config.name, directory: config.directory, startedAt, endedAt: new Date().toISOString(), durationMs: performance.now() - started, status: results.every((r) => r.status === 'success') ? 'success' : 'failed', results };
242
+ reports.push(report);
243
+ if (!values.json) process.stdout.write(formatReport(report, process.stdout.columns));
244
+ if (report.status !== 'success') process.exitCode = 1;
245
+ }
246
+ if (values.json) process.stdout.write(JSON.stringify(reports.length === 1 ? reports[0].results : reports, null, 2) + '\n');
247
+ } finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); }
248
+ return;
249
+ }
250
+ if (command === 'daemon') {
251
+ if (target === 'start') { await ensureDaemon(); process.stdout.write(notice('后台进程已启动') + '\n'); return; }
252
+ if (target === 'stop') { process.stdout.write(formatResult(await request('shutdown'), process.stdout.columns)); return; }
253
+ throw new Error('Use daemon start|stop');
254
+ }
255
+ if (!['start', 'list', 'info', 'status', 'stop', 'restart', 'delete', 'deploy'].includes(command)) throw new Error(`Unknown command: ${command}`);
256
+ if (!['start', 'list'].includes(command)) taskSelector(target);
257
+ if (command === 'list' && target) throw new Error('list does not accept a task name');
258
+ if (command === 'start') {
259
+ const directory = target ? path.resolve(target) : values.directory ? path.resolve(values.directory) : undefined;
260
+ const configurations = await loadConfigs(configFile, directory);
261
+ const result = await launchProjects(configurations, { directory, password: values.password });
262
+ process.stdout.write(values.json ? JSON.stringify(result, null, 2) + '\n' : Array.isArray(result) ? formatTaskList(result, process.stdout.columns) : formatResult(result, process.stdout.columns));
263
+ return;
264
+ }
265
+ let passwords;
266
+ await ensureDaemon();
267
+ if (command === 'deploy' && values.follow) {
268
+ if (!(await request('ping')).capabilities?.includes('deploy-progress')) throw new Error('当前后台不支持发布进度,请先重启后台进程');
269
+ return followDeployment(await requestTask('deploy', target, { follow: true }), { json: values.json });
270
+ }
271
+ if (command === 'restart' && values.password) {
272
+ const status = await requestTask('status', target);
273
+ passwords = await collectPasswords(await loadConfig(status.configFile, undefined, status.name), true);
274
+ }
275
+ const result = command === 'list' ? await request('list')
276
+ : await requestTask(command === 'info' ? 'status' : command, target, passwords ? { passwords } : {});
277
+ if (command === 'info' && !values.json) {
278
+ process.stdout.write(formatTaskDetails(result, process.stdout.columns));
279
+ } else if (command === 'list' && !values.json) {
280
+ process.stdout.write(formatTaskList(result, process.stdout.columns));
281
+ } else process.stdout.write(values.json ? JSON.stringify(result, null, 2) + '\n' : formatResult(result, process.stdout.columns));
282
+ }
283
+
284
+ main().catch((error) => { process.stderr.write(notice(`shipwatch: ${error.message}`, 'red', process.stderr) + '\n'); process.exitCode = 1; });
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "shipwatch",
3
+ "version": "0.1.0",
4
+ "description": "Background directory watching and verified incremental SSH deployments to Linux and Windows Server",
5
+ "type": "module",
6
+ "bin": { "shipwatch": "bin/shipwatch.js", "sw": "bin/shipwatch.js" },
7
+ "files": ["bin", "src", "README.md", "LICENSE"],
8
+ "engines": { "node": ">=22" },
9
+ "os": ["darwin", "linux"],
10
+ "scripts": {
11
+ "check": "node --check bin/shipwatch.js && node --check bin/askpass.cjs && node --check src/config.js && node --check src/config-editor.js && node --check src/password.js && node --check src/snapshot.js && node --check src/process.js && node --check src/deploy.js && node --check src/task.js && node --check src/task-list.js && node --check src/presentation.js && node --check src/progress.js && node --check src/terminal.js && node --check src/daemon.js && node --check src/client.js && node --check src/remote-agent.cjs",
12
+ "test": "node --test --test-concurrency=1",
13
+ "prepack": "npm run check"
14
+ },
15
+ "license": "MIT"
16
+ }