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/src/client.js ADDED
@@ -0,0 +1,196 @@
1
+ /** Local daemon client: private Unix socket requests and detached bootstrap.
2
+ * Exports paths/request/ensureDaemon. Supported CLI hosts: macOS and Linux (Windows via WSL).
3
+ */
4
+ import net from 'node:net';
5
+ import { validName } from './config.js';
6
+ import { spawn, execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { mkdir, open, readFile, writeFile, rename, unlink } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { setTimeout as delay } from 'node:timers/promises';
13
+
14
+ /** SHIPWATCH_HOME isolates registries/logs for separate users and test runs. */
15
+ export function paths() {
16
+ const home = path.resolve(process.env.SHIPWATCH_HOME || path.join(os.homedir(), '.shipwatch'));
17
+ return { home, socket: path.join(home, 'daemon.sock'), registry: path.join(home, 'tasks.json'), lock: path.join(home, 'daemon.lock'), logs: path.join(home, 'logs') };
18
+ }
19
+
20
+ /** Exactly one JSON response per connection, capped to avoid unbounded local IPC buffers. */
21
+ export function request(action, params = {}, { timeoutMs } = {}) {
22
+ return new Promise((resolve, reject) => {
23
+ const socket = net.createConnection(paths().socket);
24
+ let response = '';
25
+ const fail = (error) => { socket.destroy(); reject(error); };
26
+ // Queries must fail promptly even when an older daemon queues them behind a stalled stop.
27
+ const query = ['ping', 'list', 'status'].includes(action);
28
+ socket.setTimeout(timeoutMs ?? (query ? 5000 : 120000), () => fail(Object.assign(new Error(query ? '后台进程 5 秒内未响应,可能正在阻塞;请检查后台日志并重启后台进程' : 'Daemon request timed out'), { code: 'ETIMEDOUT' })));
29
+ socket.on('error', fail);
30
+ socket.on('connect', () => socket.write(JSON.stringify({ action, ...params }) + '\n'));
31
+ socket.setEncoding('utf8');
32
+ socket.on('data', (chunk) => {
33
+ response += chunk;
34
+ if (response.length > 16 * 1024 * 1024) return fail(new Error('Daemon response too large'));
35
+ if (response.includes('\n')) {
36
+ socket.end();
37
+ try {
38
+ const reply = JSON.parse(response.slice(0, response.indexOf('\n')));
39
+ if (reply.error) reject(new Error(reply.error)); else resolve(reply.result);
40
+ } catch (error) { reject(error); }
41
+ }
42
+ });
43
+ socket.on('end', () => { if (!response.includes('\n')) fail(Object.assign(new Error('Daemon closed without a response'), { code: 'ECONNRESET' })); });
44
+ });
45
+ }
46
+
47
+ /** Numeric targets are IDs; name:123 preserves access to existing numeric task names. */
48
+ export function taskSelector(target) {
49
+ if (typeof target === 'string' && /^\d+$/.test(target)) {
50
+ const id = Number(target);
51
+ if (!Number.isSafeInteger(id)) throw new Error('无效的任务 ID');
52
+ return { id };
53
+ }
54
+ const name = typeof target === 'string' && target.startsWith('name:') ? target.slice(5) : target;
55
+ if (!validName(name)) throw new Error('A valid task name is required(请输入任务名称或 ID)');
56
+ return { name };
57
+ }
58
+
59
+ /** Guard resident older daemons rather than interpreting row indexes as task identities. */
60
+ export async function requestTask(action, target, params = {}) {
61
+ const selector = taskSelector(target);
62
+ if (selector.id !== undefined && !(await request('ping')).capabilities?.includes('task-id')) throw new Error('当前后台进程不支持任务 ID,请重启后台进程后再试');
63
+ return request(action, { ...params, ...selector });
64
+ }
65
+
66
+ /** Clear all tasks, loading an offline registry without resuming deployments; wait for lock release. */
67
+ export async function killDaemon() {
68
+ let result;
69
+ let daemon;
70
+ try { daemon = await request('ping'); }
71
+ catch (error) {
72
+ if (['ETIMEDOUT', 'ECONNRESET'].includes(error.code)) return recoverKill();
73
+ if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error;
74
+ // Use the daemon lock and persistence path even offline, without restarting saved tasks.
75
+ await ensureDaemon({ resume: false });
76
+ daemon = await request('ping');
77
+ }
78
+ if (!Number.isInteger(daemon.pid) || daemon.pid <= 0) throw new Error('Invalid daemon identity');
79
+ try {
80
+ if (Array.isArray(daemon.capabilities) && daemon.capabilities.includes('kill-clear')) result = await request('kill', {}, { timeoutMs: 30000 });
81
+ else {
82
+ // Updating files cannot update a resident process. Older daemons understand delete/shutdown,
83
+ // but route unknown kill requests through their required-name validation.
84
+ const tasks = await request('list');
85
+ for (const task of tasks) await request('delete', { name: task.name }, { timeoutMs: 30000 });
86
+ await request('shutdown', {}, { timeoutMs: 30000 });
87
+ result = { pid: daemon.pid, stoppedTasks: tasks.length, message: 'All tasks stopped and removed', legacyDaemon: true };
88
+ }
89
+ } catch (error) {
90
+ if (['ETIMEDOUT', 'ECONNRESET'].includes(error.code)) return recoverKill(daemon.pid);
91
+ throw error;
92
+ }
93
+ const deadline = Date.now() + 60000;
94
+ while (Date.now() < deadline) {
95
+ try {
96
+ const owner = JSON.parse(await readFile(paths().lock, 'utf8'));
97
+ if (owner.pid !== result.pid) return result;
98
+ } catch (error) {
99
+ if (error.code !== 'ENOENT') throw error;
100
+ if (!daemon.capabilities?.includes('kill-reset-id')) await resetLegacyCounter();
101
+ return result;
102
+ }
103
+ await delay(50);
104
+ }
105
+ return recoverKill(daemon.pid);
106
+ }
107
+
108
+ /** Older daemons clear tasks but retain IDs; reserve their lock before resetting the empty registry's counter. */
109
+ async function resetLegacyCounter() {
110
+ const locations = paths();
111
+ const lock = await open(locations.lock, 'wx', 0o600);
112
+ const counter = path.join(locations.home, 'next-task-id.json');
113
+ try {
114
+ await lock.writeFile(JSON.stringify({ pid: process.pid }));
115
+ const tasks = JSON.parse(await readFile(locations.registry, 'utf8'));
116
+ if (!Array.isArray(tasks) || tasks.length) throw new Error('任务列表未清空,不能重置 ID');
117
+ await writeFile(counter + '.tmp', '0', { mode: 0o600 });
118
+ await rename(counter + '.tmp', counter);
119
+ } finally { await lock.close(); await unlink(locations.lock); }
120
+ }
121
+
122
+ /** Read process ownership and parentage without matching or killing unrelated Node programs. */
123
+ async function processTable() {
124
+ const { stdout } = await promisify(execFile)('ps', ['-axo', 'pid=,ppid=,uid=,stat=,command='], { timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
125
+ return stdout.trim().split('\n').map((line) => {
126
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
127
+ return match && { pid: Number(match[1]), parent: Number(match[2]), uid: Number(match[3]), state: match[4], command: match[5] };
128
+ }).filter(Boolean);
129
+ }
130
+
131
+ /** Force only the lock-owning daemon and its descendants, then clear records without resuming tasks. */
132
+ async function recoverKill(expectedPid) {
133
+ const owner = JSON.parse(await readFile(paths().lock, 'utf8'));
134
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1 || owner.pid === process.pid || (expectedPid !== undefined && expectedPid !== owner.pid)) throw new Error('后台进程身份已变化,已取消强制结束');
135
+ const daemonFile = fileURLToPath(new URL('./daemon.js', import.meta.url));
136
+ const rows = await processTable();
137
+ const root = rows.find((row) => row.pid === owner.pid);
138
+ if (root) {
139
+ if (root.uid !== process.getuid() || ![`${process.execPath} ${daemonFile}`, `${process.execPath} ${daemonFile} --no-resume`].includes(root.command)) throw new Error('无法确认后台进程身份,拒绝结束其它进程');
140
+ process.stderr.write('后台停止超时,正在结束全部监听进程并清空任务列表…\n');
141
+ const frozen = new Map();
142
+ try {
143
+ // Freeze parents before discovery so SSH children cannot be orphaned by killing the daemon first.
144
+ process.kill(root.pid, 'SIGSTOP'); frozen.set(root.pid, root);
145
+ for (let pass = 0; pass < 20; pass++) {
146
+ const current = await processTable();
147
+ const children = current.filter((row) => row.uid === root.uid && frozen.has(row.parent) && !frozen.has(row.pid));
148
+ if (!children.length) break;
149
+ for (const child of children) {
150
+ try { process.kill(child.pid, 'SIGSTOP'); frozen.set(child.pid, child); }
151
+ catch (error) { if (error.code !== 'ESRCH') throw error; }
152
+ }
153
+ if (pass === 19) throw new Error('后台子进程层级异常,取消强制结束');
154
+ }
155
+ if (JSON.parse(await readFile(paths().lock, 'utf8')).pid !== owner.pid) throw new Error('后台进程身份已变化');
156
+ const targets = [...frozen.values()].reverse();
157
+ for (const proc of targets) {
158
+ try { process.kill(proc.pid, 'SIGKILL'); }
159
+ catch (error) { if (error.code !== 'ESRCH') throw error; }
160
+ frozen.delete(proc.pid);
161
+ }
162
+ await delay(100);
163
+ const alive = (await processTable()).filter((row) => targets.some((target) => target.pid === row.pid && target.command === row.command) && !row.state.startsWith('Z'));
164
+ if (alive.length) throw new Error('后台进程尚未退出,任务记录保留');
165
+ } finally {
166
+ for (const proc of frozen.values()) {
167
+ try { process.kill(proc.pid, 'SIGCONT'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
168
+ }
169
+ }
170
+ }
171
+ await ensureDaemon({ resume: false });
172
+ return { ...await killDaemon(), forced: true };
173
+ }
174
+
175
+ /** Start a detached daemon only when no responsive one exists; concurrent starts converge on its lock. */
176
+ export async function ensureDaemon({ resume = true } = {}) {
177
+ try { await request('ping'); return; } catch (error) { if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error; }
178
+ const locations = paths();
179
+ if (Buffer.byteLength(locations.socket) > 100) throw new Error('SHIPWATCH_HOME is too long for a Unix socket; choose a shorter path');
180
+ await mkdir(locations.home, { recursive: true, mode: 0o700 });
181
+ const logfile = await open(path.join(locations.home, 'daemon.log'), 'a', 0o600);
182
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./daemon.js', import.meta.url)), ...(resume ? [] : ['--no-resume'])], {
183
+ detached: true, stdio: ['ignore', logfile.fd, logfile.fd], env: process.env,
184
+ });
185
+ let spawnError;
186
+ child.on('error', (error) => { spawnError = error; });
187
+ child.unref();
188
+ await logfile.close();
189
+ for (let count = 0; count < 50; count++) {
190
+ if (spawnError) throw spawnError;
191
+ await delay(100);
192
+ try { await request('ping'); return; } catch (error) { if (!['ENOENT', 'ECONNREFUSED'].includes(error.code)) throw error; }
193
+ }
194
+ const log = await readFile(path.join(locations.home, 'daemon.log'), 'utf8');
195
+ throw new Error(`Daemon failed to start: ${log.slice(-2000)}`);
196
+ }
@@ -0,0 +1,190 @@
1
+ /** Interactive configuration editor: named files, validated fields, masked secrets and atomic saves.
2
+ * Exports editConfiguration/updateDeployMode/openPrompts/collectPasswords; uses readline and the shared validator.
3
+ */
4
+ import { createInterface } from 'node:readline';
5
+ import { Writable } from 'node:stream';
6
+ import { readFile, writeFile, rename, unlink, lstat, realpath, stat } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { defaultConfig, DEFAULT_CONFIG, validName, validateConfig, projectDefinitions, validateConfiguration } from './config.js';
10
+ import { colorLevel, tint, gradient, notice } from './terminal.js';
11
+
12
+ /** Named configs never alias the default file and cannot escape the current directory. */
13
+ export function namedConfig(name) {
14
+ const stem = name?.endsWith('.shipwatch.config.json') ? name.slice(0, -'.shipwatch.config.json'.length) : name;
15
+ if (!validName(stem) || name === DEFAULT_CONFIG || stem === 'shipwatch') throw new Error('Use a distinct configuration name, e.g. shipwatch add production');
16
+ return { filename: path.resolve(`${stem}.shipwatch.config.json`), name: stem };
17
+ }
18
+
19
+ /** Queue readline input so pasted/piped answers are not lost; hide secret echoes on a TTY. */
20
+ export function openPrompts(input = process.stdin, output = process.stdout) {
21
+ let muted = false;
22
+ const terminal = Boolean(input.isTTY);
23
+ const level = colorLevel(output);
24
+ const writer = new Writable({ write(chunk, encoding, done) { if (!muted) output.write(chunk, encoding); done(); } });
25
+ const reader = createInterface({ input, output: writer, terminal, historySize: 0 });
26
+ const answers = reader[Symbol.asyncIterator]();
27
+ reader.on('SIGINT', () => reader.close());
28
+ return {
29
+ async ask(label, { secret = false } = {}) {
30
+ muted = secret;
31
+ output.write(tint((level ? '❯ ' : '') + label, secret ? 'yellow' : 'cyan', level) + ': ');
32
+ const answer = await answers.next();
33
+ muted = false;
34
+ if (secret && terminal) output.write('\n');
35
+ if (answer.done) throw new Error('Input canceled or ended; configuration was not saved');
36
+ return answer.value;
37
+ },
38
+ section(message) { output.write(level ? '\n' + gradient(`◈ ${message}`, level) + '\n\n' : ` ${message}\n`); },
39
+ error(message) { output.write(` ${notice(message, 'yellow', output)}\n`); },
40
+ close() { reader.close(); },
41
+ };
42
+ }
43
+
44
+ /** Required fields retry in place, and Enter retains a displayed valid default during editing. */
45
+ async function field(prompts, label, previous, check) {
46
+ while (true) {
47
+ const value = (await prompts.ask(`${label}${previous !== undefined ? ` [${previous}]` : ''}`)).trim() || previous;
48
+ try {
49
+ if (value === undefined || value === '') throw new Error('此项必填');
50
+ await check?.(value);
51
+ return value;
52
+ } catch (error) { prompts.error(error.message); }
53
+ }
54
+ }
55
+
56
+ /** Configure new or existing servers without discarding retry, exclusion or other advanced settings. */
57
+ export async function editConfiguration(filename, { name, editing = false, append = false, projectName, directory, prompts = openPrompts() } = {}) {
58
+ let original;
59
+ let raw;
60
+ let document;
61
+ let projects;
62
+ try {
63
+ if (editing || append) {
64
+ try {
65
+ const metadata = await lstat(filename);
66
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error('Configuration must be a regular file');
67
+ original = await readFile(filename, 'utf8');
68
+ } catch (error) { if (!append || error.code !== 'ENOENT') throw error; }
69
+ if (original !== undefined) {
70
+ document = JSON.parse(original);
71
+ projects = projectDefinitions(document);
72
+ } else { document = { projects: [] }; projects = document.projects; }
73
+ if (append) {
74
+ if (projects.length >= 100) throw new Error('配置最多支持 100 个项目');
75
+ if (!Object.hasOwn(document, 'projects')) document = { projects: [document] };
76
+ projects = document.projects;
77
+ raw = defaultConfig(directory ?? '.');
78
+ projects.push(raw);
79
+ } else {
80
+ const selected = projectName ?? (projects.length === 1 ? projects[0].name : await field(prompts, `选择要编辑的项目(${projects.map((item) => item.name).join('、')})`, undefined, (value) => {
81
+ if (!projects.some((item) => item.name === value)) throw new Error('请输入配置中的项目名称');
82
+ }));
83
+ raw = projects.find((item) => item.name === selected);
84
+ if (!raw) throw new Error(`配置中未找到项目 ${selected}`);
85
+ if (!Array.isArray(raw.servers) || !raw.servers.length) throw new Error('Invalid existing configuration');
86
+ }
87
+ } else {
88
+ try { await lstat(filename); throw new Error('Configuration already exists; use edit'); }
89
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
90
+ raw = defaultConfig(directory ?? '.'); raw.name = name ?? 'app';
91
+ document = raw; projects = [raw];
92
+ }
93
+ prompts.section?.('项目设置');
94
+ if (!editing && name === undefined) {
95
+ raw.name = await field(prompts, '任务名称 name(必填)', undefined, (value) => {
96
+ if (!validName(value)) throw new Error('名称需以字母或数字开头,仅含字母、数字、_、-,最多 64 字符');
97
+ if (projects.some((item) => item !== raw && item.name === value)) throw new Error('项目名称不能重复');
98
+ });
99
+ }
100
+ raw.directory = await field(prompts, '本地监听目录(必填,相对于配置文件)', raw.directory, async (value) => {
101
+ if (!(await stat(await realpath(path.resolve(path.dirname(filename), value)))).isDirectory()) throw new Error('请选择存在的目录');
102
+ });
103
+ for (let index = 0; index < raw.servers.length; index++) {
104
+ const server = raw.servers[index];
105
+ (prompts.section ?? prompts.error).call(prompts, `服务器 ${index + 1}: ${server.name}`);
106
+ server.host = await field(prompts, '服务器地址(必填)', server.host === 'CHANGE_ME' ? undefined : server.host, (value) => {
107
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(value) || value === 'CHANGE_ME') throw new Error('请输入有效主机名或 IPv4 地址');
108
+ });
109
+ server.user = await field(prompts, 'SSH 用户名(必填)', server.user, (value) => {
110
+ if (!/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(value)) throw new Error('SSH 用户名格式无效');
111
+ });
112
+ server.port = Number(await field(prompts, 'SSH 端口(必填)', String(server.port ?? 22), (value) => {
113
+ if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new Error('端口必须为 1..65535 的整数');
114
+ }));
115
+ server.platform = await field(prompts, '服务器系统 linux/windows(必填)', server.platform ?? 'linux', (value) => {
116
+ if (!['linux', 'windows'].includes(value)) throw new Error('请输入 linux 或 windows');
117
+ });
118
+ // Reuse full target validation while isolating this server from other unfinished form entries.
119
+ server.path = await field(prompts, '服务器目标目录(必填)', editing ? server.path : undefined, async (value) => {
120
+ await validateConfig({ ...raw, servers: [{ ...server, path: value }] }, filename);
121
+ });
122
+ const password = await prompts.ask(`密码(可选${server.password ? ';留空保留已保存密码' : server.passwordPrompt ? ';留空保留启动时输入' : ';留空使用密钥/Agent'};:prompt 启动时输入;:clear 清除;输入新密码会保存到配置)`, { secret: true });
123
+ if (password === ':clear') { delete server.password; delete server.passwordPrompt; }
124
+ else if (password === ':prompt') { delete server.password; server.passwordPrompt = true; }
125
+ else if (password) {
126
+ if (/[\r\n\0]/.test(password)) throw new Error('密码不能包含换行或空字符');
127
+ server.password = password; delete server.passwordPrompt;
128
+ }
129
+ const command = await prompts.ask(`更新成功后执行的服务命令(可选;留空${server.postDeployCommand ? `保留 ${server.postDeployCommand}` : '跳过'};:clear 清除)`);
130
+ if (command === ':clear') delete server.postDeployCommand;
131
+ else if (command.trim()) server.postDeployCommand = command.trim();
132
+ if (index === raw.servers.length - 1) {
133
+ const more = await field(prompts, '继续添加服务器?y/n', 'n', (value) => { if (!['y', 'n'].includes(value.toLowerCase())) throw new Error('请输入 y 或 n'); });
134
+ if (more.toLowerCase() === 'y') {
135
+ const names = new Set(raw.servers.map((item) => item.name));
136
+ let next = raw.servers.length + 1;
137
+ while (names.has(`server-${next}`)) next++;
138
+ raw.servers.push({ name: `server-${next}`, host: 'CHANGE_ME', user: 'deploy', port: 22, platform: 'linux' });
139
+ }
140
+ }
141
+ }
142
+ await validateConfiguration(document, filename);
143
+ const content = JSON.stringify(document, null, 2) + '\n';
144
+ if (original === undefined) await writeFile(filename, content, { flag: 'wx', mode: 0o600 });
145
+ else {
146
+ if (await readFile(filename, 'utf8') !== original) throw new Error('Configuration changed while editing; reopen it');
147
+ const temporary = `${filename}.${randomBytes(8).toString('hex')}.tmp`;
148
+ await writeFile(temporary, content, { flag: 'wx', mode: 0o600 });
149
+ try { await rename(temporary, filename); }
150
+ finally { await unlink(temporary).catch((error) => { if (error.code !== 'ENOENT') throw error; }); }
151
+ }
152
+ return filename;
153
+ } finally { prompts.close(); }
154
+ }
155
+
156
+ /** Persist only the selected project's mode, preserving sibling projects and credentials. */
157
+ export async function updateDeployMode(filename, projectName, mode) {
158
+ if (!['auto', 'manual'].includes(mode)) throw new Error('部署模式必须为 auto 或 manual');
159
+ const metadata = await lstat(filename);
160
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error('Configuration must be a regular file');
161
+ const original = await readFile(filename, 'utf8');
162
+ const document = JSON.parse(original);
163
+ const project = projectDefinitions(document).find((item) => item.name === projectName);
164
+ if (!project) throw new Error(`配置中未找到项目 ${projectName}`);
165
+ project.deployMode = mode;
166
+ const temporary = `${filename}.${randomBytes(8).toString('hex')}.tmp`;
167
+ await writeFile(temporary, JSON.stringify(document, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
168
+ try {
169
+ if (await readFile(filename, 'utf8') !== original) throw new Error('Configuration changed while editing; reopen it');
170
+ await rename(temporary, filename);
171
+ } finally { await unlink(temporary).catch((error) => { if (error.code !== 'ENOENT') throw error; }); }
172
+ }
173
+
174
+ /** Runtime passwords stay in CLI/daemon memory and never enter the persistent task registry. */
175
+ export async function collectPasswords(config, force = false, promptsFactory = openPrompts) {
176
+ const passwords = Object.create(null);
177
+ const servers = config.servers.filter((server) => force || server.passwordPrompt);
178
+ if (!servers.length) return passwords;
179
+ const prompts = promptsFactory();
180
+ try {
181
+ for (const server of servers) {
182
+ let password;
183
+ do { password = await prompts.ask(`${config.name} / ${server.name} (${server.user}@${server.host}) 密码`, { secret: true }); }
184
+ while (!password || /[\r\n\0]/.test(password));
185
+ passwords[server.name] = password;
186
+ server.password = password;
187
+ }
188
+ } finally { prompts.close(); }
189
+ return passwords;
190
+ }
package/src/config.js ADDED
@@ -0,0 +1,118 @@
1
+ /** Configuration boundary: parse JSON, normalize paths and reject unsafe deployment targets.
2
+ * Exports loadConfig/defaultConfig; depends only on Node filesystem/path APIs.
3
+ */
4
+ import { readFile, realpath, stat } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ /** Defaults favor avoiding accidental secret publication; user exclusions are additive. */
9
+ export const DEFAULT_CONFIG = 'shipwatch.config.json';
10
+ export const DEFAULT_EXCLUDES = ['.git', 'node_modules', '.env', '.env.*', '*.pem', '*.key', '.shipwatch', 'shipwatch-snapshot-*', '.shipwatch-manifest.json', 'shipwatch.config.json', 'shipwatch.config', '*.shipwatch.config', '*.shipwatch.config.*', 'shipwatch.config.*'];
11
+ export const validName = (name) => typeof name === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name);
12
+
13
+ /** Create an editable template; host is deliberately invalid until the operator configures it. */
14
+ export function defaultConfig(directory = '.') {
15
+ return { name: 'app', directory, deployMode: 'auto', intervalMs: 1000, debounceMs: 800, retry: { attempts: 3, delayMs: 1000 },
16
+ keepReleases: 5, timeoutMs: 120000, exclude: [],
17
+ servers: [{ name: 'production', host: 'CHANGE_ME', user: 'deploy', port: 22, platform: 'linux', path: '/srv/app' }] };
18
+ }
19
+
20
+ /** Integer settings are bounded so malformed config cannot create infinite retries or busy loops. */
21
+ function integer(value, fallback, min, max, field) {
22
+ const result = value ?? fallback;
23
+ if (!Number.isInteger(result) || result < min || result > max) throw new Error(`${field} must be ${min}..${max}`);
24
+ return result;
25
+ }
26
+
27
+ /** Accept legacy single-project objects and explicit projects collections. */
28
+ export function projectDefinitions(raw) {
29
+ const projects = raw && Object.hasOwn(raw, 'projects') ? raw.projects : [raw];
30
+ if (!Array.isArray(projects) || !projects.length || projects.length > 100) throw new Error('配置必须包含 1..100 个项目');
31
+ const names = new Set();
32
+ for (const project of projects) {
33
+ if (!validName(project?.name) || names.has(project.name)) throw new Error('项目名称必须有效且不能重复');
34
+ names.add(project.name);
35
+ }
36
+ return projects;
37
+ }
38
+
39
+ /** Validate the complete collection before any project can be started or a file saved. */
40
+ export async function validateConfiguration(raw, configFile, directoryOverride) {
41
+ const projects = projectDefinitions(raw);
42
+ if (projects.length > 1 && directoryOverride !== undefined) throw new Error('多项目配置不能使用统一目录覆盖,请分别修改各项目 directory');
43
+ const configurations = [];
44
+ const sources = new Set();
45
+ for (const project of projects) {
46
+ const config = await validateConfig(project, configFile, directoryOverride);
47
+ const metadata = await stat(config.directory);
48
+ const identity = `${metadata.dev}:${metadata.ino}`;
49
+ if (sources.has(identity)) throw new Error('同一监听目录不能重复配置');
50
+ sources.add(identity); configurations.push(config);
51
+ }
52
+ return configurations;
53
+ }
54
+
55
+ /** Read one configuration file and normalize all of its projects. */
56
+ export async function loadConfigs(filename, directoryOverride) {
57
+ const configFile = await realpath(filename);
58
+ return validateConfiguration(JSON.parse(await readFile(configFile, 'utf8')), configFile, directoryOverride);
59
+ }
60
+
61
+ /** Resolve a registered task by project name so sibling edits cannot switch its identity. */
62
+ export async function loadConfig(filename, directoryOverride, projectName) {
63
+ const configFile = await realpath(filename);
64
+ const projects = projectDefinitions(JSON.parse(await readFile(configFile, 'utf8')));
65
+ if (projectName !== undefined) {
66
+ const project = projects.find((item) => item.name === projectName);
67
+ if (!project) throw new Error(`配置中未找到项目 ${projectName}`);
68
+ return validateConfig(project, configFile, directoryOverride);
69
+ }
70
+ if (projects.length !== 1) throw new Error('多项目配置需要指定项目名称');
71
+ return validateConfig(projects[0], configFile, directoryOverride);
72
+ }
73
+
74
+ /** Validate wizard drafts before writing them; the destination file need not exist yet. */
75
+ export async function validateConfig(raw, configFile, directoryOverride) {
76
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Config must be an object');
77
+ if (!validName(raw.name)) throw new Error('Invalid task name (use letters, numbers, _ and -; max 64)');
78
+ if (typeof (raw.directory ?? '.') !== 'string') throw new Error('directory must be a string');
79
+ const directory = await realpath(directoryOverride ? path.resolve(directoryOverride) : path.resolve(path.dirname(configFile), raw.directory ?? '.'));
80
+ if (!(await stat(directory)).isDirectory()) throw new Error('Watch source must be a directory');
81
+ if (!Array.isArray(raw.servers) || raw.servers.length === 0 || raw.servers.length > 32) throw new Error('Configure 1..32 servers');
82
+ if (raw.exclude !== undefined && (!Array.isArray(raw.exclude) || raw.exclude.some((item) => typeof item !== 'string' || !item || item.includes('..') || item.includes('\\')))) throw new Error('exclude must contain relative glob strings');
83
+ if (raw.deployMode !== undefined && !['auto', 'manual'].includes(raw.deployMode)) throw new Error('deployMode 必须为 auto(自动)或 manual(手动)');
84
+ const names = new Set();
85
+ const targets = new Set();
86
+ const servers = raw.servers.map((server) => {
87
+ if (!server || !validName(server.name) || names.has(server.name)) throw new Error('Server names must be valid and unique');
88
+ names.add(server.name);
89
+ if (typeof server.host !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(server.host) || server.host === 'CHANGE_ME') throw new Error(`Configure a valid SSH host for ${server.name}`);
90
+ if (typeof server.user !== 'string' || !/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(server.user)) throw new Error(`Invalid SSH user for ${server.name}`);
91
+ const platform = server.platform ?? 'linux';
92
+ if (!['linux', 'windows'].includes(platform)) throw new Error('Server platform must be linux or windows');
93
+ if (typeof server.path !== 'string' || /[\x00-\x1f]/.test(server.path) || server.path.split(/[\\/]/).includes('..')) throw new Error('Invalid server path');
94
+ const paths = platform === 'windows' ? path.win32 : path.posix;
95
+ if (!paths.isAbsolute(server.path) || (platform === 'windows' && !/^[a-zA-Z]:[\\/]/.test(server.path))) throw new Error('Server path must be absolute (Windows requires a drive letter)');
96
+ const remotePath = paths.normalize(server.path);
97
+ if (remotePath.slice(paths.parse(remotePath).root.length).split(/[\\/]/).filter(Boolean).length < 2) throw new Error('Use an application subdirectory, e.g. /srv/app or C:\\apps\\web');
98
+ const port = integer(server.port, 22, 1, 65535, 'port');
99
+ const target = `${server.user}@${server.host}:${port}:${remotePath}`;
100
+ if (targets.has(target)) throw new Error('Duplicate server target');
101
+ targets.add(target);
102
+ if (server.identityFile !== undefined && typeof server.identityFile !== 'string') throw new Error('identityFile must be a string');
103
+ if (server.password !== undefined && (typeof server.password !== 'string' || /[\r\n\0]/.test(server.password))) throw new Error('password must be a single-line string');
104
+ if (server.passwordPrompt !== undefined && typeof server.passwordPrompt !== 'boolean') throw new Error('passwordPrompt must be boolean');
105
+ if (server.postDeployCommand !== undefined && (typeof server.postDeployCommand !== 'string' || /\0/.test(server.postDeployCommand) || server.postDeployCommand.length > 16384)) throw new Error('Invalid postDeployCommand');
106
+ return { name: server.name, host: server.host, user: server.user, path: remotePath, platform, port,
107
+ password: server.password || undefined, passwordPrompt: server.passwordPrompt ?? false,
108
+ postDeployCommand: server.postDeployCommand?.trim() || undefined,
109
+ identityFile: server.identityFile ? path.resolve(path.dirname(configFile), server.identityFile.replace(/^~\//, `${os.homedir()}/`)) : undefined };
110
+ });
111
+ return { name: raw.name, directory, configFile, servers, deployMode: raw.deployMode ?? 'auto',
112
+ exclude: [...DEFAULT_EXCLUDES, ...(raw.exclude ?? [])],
113
+ intervalMs: integer(raw.intervalMs, 1000, 100, 3600000, 'intervalMs'),
114
+ debounceMs: integer(raw.debounceMs, 800, 0, 60000, 'debounceMs'),
115
+ keepReleases: integer(raw.keepReleases, 5, 1, 1000, 'keepReleases'),
116
+ timeoutMs: integer(raw.timeoutMs, 120000, 100, 3600000, 'timeoutMs'),
117
+ retry: { attempts: integer(raw.retry?.attempts, 3, 1, 10, 'retry.attempts'), delayMs: integer(raw.retry?.delayMs, 1000, 10, 60000, 'retry.delayMs') } };
118
+ }