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 +21 -0
- package/README.md +284 -0
- package/bin/askpass.cjs +11 -0
- package/bin/shipwatch.js +284 -0
- package/package.json +16 -0
- package/src/client.js +196 -0
- package/src/config-editor.js +190 -0
- package/src/config.js +118 -0
- package/src/daemon.js +326 -0
- package/src/deploy.js +152 -0
- package/src/password.js +45 -0
- package/src/presentation.js +56 -0
- package/src/process.js +53 -0
- package/src/progress.js +108 -0
- package/src/remote-agent.cjs +341 -0
- package/src/snapshot.js +88 -0
- package/src/task-list.js +157 -0
- package/src/task.js +142 -0
- package/src/terminal.js +66 -0
package/src/daemon.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/** Daemon entry: persistent task registry, private IPC, bounded log rotation and task lifecycle.
|
|
2
|
+
* Launched by client.js; owns Task instances and serializes state-changing CLI requests.
|
|
3
|
+
*/
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { loadConfig, loadConfigs, validName } from './config.js';
|
|
8
|
+
import { paths } from './client.js';
|
|
9
|
+
import { Task } from './task.js';
|
|
10
|
+
import { updateDeployMode } from './config-editor.js';
|
|
11
|
+
|
|
12
|
+
const locations = paths();
|
|
13
|
+
/** Keep two 5 MiB files per task; process lifetime does not make logs grow without limit. */
|
|
14
|
+
const LOG_LIMIT = 5 * 1024 * 1024;
|
|
15
|
+
const jobs = new Map();
|
|
16
|
+
let closing = false;
|
|
17
|
+
let ownsLock = false;
|
|
18
|
+
let server;
|
|
19
|
+
let mutations = Promise.resolve();
|
|
20
|
+
let nextId = 0;
|
|
21
|
+
const counterFile = path.join(locations.home, 'next-task-id.json');
|
|
22
|
+
|
|
23
|
+
/** Rotation and append are synchronous so log order remains deterministic across deployment events. */
|
|
24
|
+
function logger(name) {
|
|
25
|
+
return (level, message, remote) => {
|
|
26
|
+
const filename = path.join(locations.logs, `${name}.log`);
|
|
27
|
+
try {
|
|
28
|
+
if (fs.existsSync(filename) && fs.statSync(filename).size >= LOG_LIMIT) {
|
|
29
|
+
fs.rmSync(filename + '.1', { force: true });
|
|
30
|
+
fs.renameSync(filename, filename + '.1');
|
|
31
|
+
}
|
|
32
|
+
fs.appendFileSync(filename, JSON.stringify({ time: new Date().toISOString(), level, task: name, server: remote, message: String(message).slice(0, String(message).startsWith('SHIPWATCH_REPORT ') ? 131072 : 16384) }) + '\n', { mode: 0o600 });
|
|
33
|
+
} catch (error) { process.stderr.write(`Cannot write task log: ${error.message}\n`); }
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Save only desired lifecycle state and config locations, never credentials or ephemeral progress. */
|
|
38
|
+
function persist() {
|
|
39
|
+
const temporary = locations.registry + '.tmp';
|
|
40
|
+
fs.writeFileSync(temporary, JSON.stringify([...jobs.values()].map((job) => job.saved), null, 2) + '\n', { mode: 0o600 });
|
|
41
|
+
fs.renameSync(temporary, locations.registry);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reserve IDs durably; single deletions never reuse IDs, while kill-all resets the sequence. */
|
|
45
|
+
function saveCounter() {
|
|
46
|
+
fs.writeFileSync(counterFile + '.tmp', JSON.stringify(nextId), { mode: 0o600 });
|
|
47
|
+
fs.renameSync(counterFile + '.tmp', counterFile);
|
|
48
|
+
}
|
|
49
|
+
function allocateId() {
|
|
50
|
+
if (!Number.isSafeInteger(nextId) || nextId >= Number.MAX_SAFE_INTEGER) throw new Error('任务 ID 已耗尽');
|
|
51
|
+
const id = nextId++;
|
|
52
|
+
saveCounter();
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Identity is attached to every response, including tasks with broken configurations. */
|
|
57
|
+
function jobState(job) {
|
|
58
|
+
return { ...(job.task?.state ?? { name: job.saved.name, directory: job.saved.sourceDirectory ?? job.saved.directory, status: 'error', running: false, lastError: job.error, autoDeployCount: job.saved.autoDeployCount ?? 0 }), id: job.saved.id };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fail closed on a live/unknown PID; only reclaim locks belonging to a definitively dead process. */
|
|
62
|
+
function claimLock() {
|
|
63
|
+
fs.mkdirSync(locations.logs, { recursive: true, mode: 0o700 });
|
|
64
|
+
fs.chmodSync(locations.home, 0o700);
|
|
65
|
+
if (fs.existsSync(locations.lock)) {
|
|
66
|
+
const owner = JSON.parse(fs.readFileSync(locations.lock, 'utf8'));
|
|
67
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0) throw new Error('Invalid daemon lock; inspect SHIPWATCH_HOME');
|
|
68
|
+
try { process.kill(owner.pid, 0); throw new Error('Daemon is already running or starting'); }
|
|
69
|
+
catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
70
|
+
fs.unlinkSync(locations.lock);
|
|
71
|
+
fs.rmSync(locations.socket, { force: true });
|
|
72
|
+
}
|
|
73
|
+
fs.writeFileSync(locations.lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
|
|
74
|
+
ownsLock = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Compare filesystem identities so symlinks and case aliases cannot create duplicate watchers. */
|
|
78
|
+
async function assertUniqueDirectory(config) {
|
|
79
|
+
const identity = fs.statSync(config.directory);
|
|
80
|
+
for (const [name, job] of jobs) {
|
|
81
|
+
if (name === config.name) continue;
|
|
82
|
+
let directory = job.task?.config.directory ?? job.saved.sourceDirectory;
|
|
83
|
+
if (!directory) {
|
|
84
|
+
try { directory = (await loadConfig(job.saved.configFile, job.saved.directory, job.saved.name)).directory; } catch { continue; }
|
|
85
|
+
}
|
|
86
|
+
let other;
|
|
87
|
+
try { other = fs.statSync(directory); } catch { continue; }
|
|
88
|
+
if (identity.dev === other.dev && identity.ino === other.ino) throw new Error(`目录已由任务 ${name} 使用,请先删除原任务`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Re-read config on start/restart so edits require a deliberate lifecycle action. */
|
|
93
|
+
async function makeTask(saved, passwords = {}) {
|
|
94
|
+
const config = await loadConfig(saved.configFile, saved.directory, saved.name);
|
|
95
|
+
await assertUniqueDirectory(config);
|
|
96
|
+
saved.sourceDirectory = config.directory;
|
|
97
|
+
for (const server of config.servers) {
|
|
98
|
+
if (Object.hasOwn(passwords, server.name)) {
|
|
99
|
+
if (typeof passwords[server.name] !== 'string' || !passwords[server.name] || /[\r\n\0]/.test(passwords[server.name])) throw new Error('Invalid runtime password');
|
|
100
|
+
server.password = passwords[server.name];
|
|
101
|
+
}
|
|
102
|
+
if (server.passwordPrompt && !server.password) throw new Error('Password required; run shipwatch <configuration> to enter it again');
|
|
103
|
+
}
|
|
104
|
+
if (config.name !== saved.name) throw new Error('Config name changed; delete the old task and start again');
|
|
105
|
+
const runtimeRelative = path.relative(config.directory, locations.home);
|
|
106
|
+
if (!runtimeRelative.startsWith('..') && !path.isAbsolute(runtimeRelative)) {
|
|
107
|
+
if (!runtimeRelative) throw new Error('Source directory cannot be SHIPWATCH_HOME');
|
|
108
|
+
config.exclude.push(runtimeRelative.split(path.sep).join('/'));
|
|
109
|
+
}
|
|
110
|
+
return new Task(config, logger(config.name), undefined, {
|
|
111
|
+
autoDeployCount: saved.autoDeployCount ?? 0,
|
|
112
|
+
onAutoDeploy(count) {
|
|
113
|
+
const previous = saved.autoDeployCount;
|
|
114
|
+
saved.autoDeployCount = count;
|
|
115
|
+
try { persist(); }
|
|
116
|
+
catch (error) { saved.autoDeployCount = previous; throw error; }
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Load desired tasks after daemon restart; a bad config cannot prevent other tasks from recovering. */
|
|
122
|
+
async function restore() {
|
|
123
|
+
nextId = fs.existsSync(counterFile) ? JSON.parse(fs.readFileSync(counterFile, 'utf8')) : 0;
|
|
124
|
+
if (!Number.isSafeInteger(nextId) || nextId < 0) throw new Error('Invalid task ID counter');
|
|
125
|
+
const savedJobs = fs.existsSync(locations.registry) ? JSON.parse(fs.readFileSync(locations.registry, 'utf8')) : [];
|
|
126
|
+
if (!Array.isArray(savedJobs)) throw new Error('Invalid task registry');
|
|
127
|
+
const ids = new Set();
|
|
128
|
+
for (const saved of savedJobs) {
|
|
129
|
+
if (!validName(saved?.name)) throw new Error('Invalid task registry name');
|
|
130
|
+
if (Number.isSafeInteger(saved.id) && saved.id >= 0) nextId = Math.max(nextId, saved.id + 1);
|
|
131
|
+
}
|
|
132
|
+
for (const saved of savedJobs) {
|
|
133
|
+
if (!Number.isSafeInteger(saved.id) || saved.id < 0 || ids.has(saved.id)) saved.id = allocateId();
|
|
134
|
+
ids.add(saved.id);
|
|
135
|
+
}
|
|
136
|
+
saveCounter();
|
|
137
|
+
// Migrate legacy registries before starting any watchers, so IDs survive immediate shutdown.
|
|
138
|
+
fs.writeFileSync(locations.registry + '.tmp', JSON.stringify(savedJobs, null, 2) + '\n', { mode: 0o600 });
|
|
139
|
+
fs.renameSync(locations.registry + '.tmp', locations.registry);
|
|
140
|
+
for (const saved of savedJobs) {
|
|
141
|
+
if (!validName(saved.name)) throw new Error('Invalid task registry name');
|
|
142
|
+
if (jobs.has(saved.name)) { logger(saved.name)('error', '发现重复任务名称,已跳过重复记录'); continue; }
|
|
143
|
+
const job = { saved };
|
|
144
|
+
jobs.set(saved.name, job);
|
|
145
|
+
try { job.task = await makeTask(saved); }
|
|
146
|
+
catch (error) { job.error = error.message; logger(saved.name)('error', error.message); }
|
|
147
|
+
}
|
|
148
|
+
// Populate every saved job before automatic counters can persist the complete registry.
|
|
149
|
+
if (!process.argv.includes('--no-resume')) for (const job of jobs.values()) if (job.saved.enabled) job.task?.start();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** IPC commands expose state snapshots; task objects never cross the socket boundary. */
|
|
153
|
+
async function handle(request) {
|
|
154
|
+
const { action } = request;
|
|
155
|
+
let { name } = request;
|
|
156
|
+
if (request.id !== undefined) {
|
|
157
|
+
if (!Number.isSafeInteger(request.id) || request.id < 0) throw new Error('无效的任务 ID');
|
|
158
|
+
const match = [...jobs.values()].find((job) => job.saved.id === request.id);
|
|
159
|
+
if (!match) throw new Error(`未找到任务 ID:${request.id}`);
|
|
160
|
+
name = match.saved.name;
|
|
161
|
+
}
|
|
162
|
+
if (action === 'ping') return { pid: process.pid, capabilities: ['kill', 'kill-clear', 'kill-reset-id', 'task-id', 'multi-project', 'deploy-mode', 'deploy-progress'] };
|
|
163
|
+
if (action === 'list') return [...jobs.values()].map(jobState);
|
|
164
|
+
if (action === 'status') {
|
|
165
|
+
if (!validName(name) || !jobs.has(name)) throw new Error(`Unknown task: ${name}`);
|
|
166
|
+
const job = jobs.get(name);
|
|
167
|
+
return { ...jobState(job), configFile: job.saved.configFile };
|
|
168
|
+
}
|
|
169
|
+
if (closing) throw new Error('Daemon is shutting down');
|
|
170
|
+
if (action === 'start-many') {
|
|
171
|
+
const configurations = await loadConfigs(request.configFile, request.directory);
|
|
172
|
+
const prepared = [];
|
|
173
|
+
for (const config of configurations) {
|
|
174
|
+
const previous = jobs.get(config.name);
|
|
175
|
+
if (previous && (!request.restartExisting || previous.saved.configFile !== config.configFile)) throw new Error(`Task ${config.name} already exists; use restart or delete`);
|
|
176
|
+
const saved = { ...(previous?.saved ?? { id: allocateId() }), name: config.name, configFile: config.configFile, directory: request.directory, enabled: true };
|
|
177
|
+
const passwords = request.projectPasswords?.[config.name] ?? previous?.passwords ?? {};
|
|
178
|
+
const task = await makeTask(saved, passwords);
|
|
179
|
+
prepared.push({ saved, task, passwords, previous });
|
|
180
|
+
}
|
|
181
|
+
// Validate all projects before changing the registry or starting any watcher.
|
|
182
|
+
for (const job of prepared) jobs.set(job.saved.name, job);
|
|
183
|
+
try { persist(); }
|
|
184
|
+
catch (error) {
|
|
185
|
+
for (const job of prepared) { if (job.previous) jobs.set(job.saved.name, job.previous); else jobs.delete(job.saved.name); }
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
for (const job of prepared) {
|
|
189
|
+
await job.previous?.task?.stop();
|
|
190
|
+
delete job.previous;
|
|
191
|
+
job.task.start();
|
|
192
|
+
logger(job.saved.name)('info', `项目已启动:${job.task.config.directory}`);
|
|
193
|
+
}
|
|
194
|
+
return prepared.map(jobState);
|
|
195
|
+
}
|
|
196
|
+
if (action === 'start') {
|
|
197
|
+
const config = await loadConfig(request.configFile, request.directory, request.projectName);
|
|
198
|
+
if (jobs.has(config.name)) throw new Error(`Task ${config.name} already exists; use restart or delete`);
|
|
199
|
+
const saved = { id: allocateId(), name: config.name, configFile: config.configFile, directory: request.directory, enabled: true };
|
|
200
|
+
const passwords = request.passwords ?? {};
|
|
201
|
+
const task = await makeTask(saved, passwords);
|
|
202
|
+
jobs.set(config.name, { saved, task, passwords });
|
|
203
|
+
try { persist(); } catch (error) { jobs.delete(config.name); throw error; }
|
|
204
|
+
task.start();
|
|
205
|
+
logger(config.name)('info', config.deployMode === 'manual' ? `手动部署任务就绪:${config.directory}` : `Watching ${config.directory}`);
|
|
206
|
+
return jobState(jobs.get(config.name));
|
|
207
|
+
}
|
|
208
|
+
if (action === 'kill') {
|
|
209
|
+
// Persist stopped intent first so the next list/start cannot resurrect killed tasks.
|
|
210
|
+
const previous = [...jobs.values()].map((job) => [job, job.saved.enabled]);
|
|
211
|
+
for (const [job] of previous) job.saved.enabled = false;
|
|
212
|
+
try { persist(); }
|
|
213
|
+
catch (error) { for (const [job, enabled] of previous) job.saved.enabled = enabled; throw error; }
|
|
214
|
+
await Promise.all(previous.map(([job]) => job.task?.stop()));
|
|
215
|
+
const removed = [...jobs.entries()];
|
|
216
|
+
jobs.clear();
|
|
217
|
+
try { persist(); }
|
|
218
|
+
catch (error) { for (const [name, job] of removed) jobs.set(name, job); throw error; }
|
|
219
|
+
// Reset only after every task is removed; single-task deletion keeps IDs stable.
|
|
220
|
+
const previousId = nextId;
|
|
221
|
+
nextId = 0;
|
|
222
|
+
try { saveCounter(); } catch (error) { nextId = previousId; throw error; }
|
|
223
|
+
closing = true;
|
|
224
|
+
setImmediate(() => shutdown().catch((error) => { process.stderr.write(error.message + '\n'); process.exitCode = 1; }));
|
|
225
|
+
return { pid: process.pid, stoppedTasks: previous.length, message: 'All tasks stopped and removed' };
|
|
226
|
+
}
|
|
227
|
+
if (action === 'shutdown') {
|
|
228
|
+
closing = true;
|
|
229
|
+
setImmediate(() => shutdown().catch((error) => { process.stderr.write(error.message + '\n'); process.exitCode = 1; }));
|
|
230
|
+
return { message: 'Daemon shutting down; desired tasks will resume on next daemon start' };
|
|
231
|
+
}
|
|
232
|
+
if (!validName(name) || !jobs.has(name)) throw new Error(`Unknown task: ${name}`);
|
|
233
|
+
const job = jobs.get(name);
|
|
234
|
+
if (action === 'mode') {
|
|
235
|
+
if (!job.task) throw new Error(job.error ?? '任务配置无效,请先修复配置');
|
|
236
|
+
await updateDeployMode(job.saved.configFile, name, request.mode);
|
|
237
|
+
job.task.setDeployMode(request.mode);
|
|
238
|
+
logger(name)('info', `自动部署已${request.mode === 'auto' ? '开启' : '关闭'}`);
|
|
239
|
+
return jobState(job);
|
|
240
|
+
}
|
|
241
|
+
if (action === 'deploy') {
|
|
242
|
+
if (!job.task) throw new Error(job.error);
|
|
243
|
+
const ticket = job.task.trigger();
|
|
244
|
+
return { message: 'Deployment queued', ...(request.follow ? { ...ticket, name, id: job.saved.id } : {}) };
|
|
245
|
+
}
|
|
246
|
+
if (action === 'stop') {
|
|
247
|
+
const previous = job.saved.enabled;
|
|
248
|
+
job.saved.enabled = false;
|
|
249
|
+
try { persist(); } catch (error) { job.saved.enabled = previous; throw error; }
|
|
250
|
+
await job.task?.stop();
|
|
251
|
+
return { ...jobState(job), status: 'stopped' };
|
|
252
|
+
}
|
|
253
|
+
if (action === 'restart') {
|
|
254
|
+
const passwords = request.passwords ?? job.passwords ?? {};
|
|
255
|
+
const previous = job.saved;
|
|
256
|
+
const saved = { ...previous, ...(request.configFile ? { configFile: request.configFile, directory: request.directory } : {}), enabled: true };
|
|
257
|
+
const task = await makeTask(saved, passwords);
|
|
258
|
+
job.saved = saved;
|
|
259
|
+
try { persist(); } catch (error) { job.saved = previous; throw error; }
|
|
260
|
+
await job.task?.stop();
|
|
261
|
+
job.passwords = passwords;
|
|
262
|
+
job.task = task; job.error = undefined; task.start();
|
|
263
|
+
return jobState(job);
|
|
264
|
+
}
|
|
265
|
+
if (action === 'delete') {
|
|
266
|
+
await job.task?.stop();
|
|
267
|
+
jobs.delete(name);
|
|
268
|
+
try { persist(); } catch (error) { jobs.set(name, job); throw error; }
|
|
269
|
+
return { message: `Deleted task ${name}; deployment files and logs retained` };
|
|
270
|
+
}
|
|
271
|
+
throw new Error(`Unknown command: ${action}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Stop awaits canceled deployments before releasing the daemon socket and lock. */
|
|
275
|
+
async function shutdown() {
|
|
276
|
+
closing = true;
|
|
277
|
+
await Promise.all([...jobs.values()].map((job) => job.task?.stop()));
|
|
278
|
+
if (server) await new Promise((resolve) => server.close(resolve));
|
|
279
|
+
cleanup();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Only the owning process removes daemon files; a losing concurrent starter does nothing. */
|
|
283
|
+
function cleanup() {
|
|
284
|
+
if (!ownsLock) return;
|
|
285
|
+
fs.rmSync(locations.socket, { force: true });
|
|
286
|
+
fs.rmSync(locations.lock, { force: true });
|
|
287
|
+
ownsLock = false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Bound requests and serialize mutations; read-only snapshots bypass pending cleanup. */
|
|
291
|
+
async function main() {
|
|
292
|
+
claimLock();
|
|
293
|
+
await restore();
|
|
294
|
+
server = net.createServer((socket) => {
|
|
295
|
+
let buffer = '';
|
|
296
|
+
let handled = false;
|
|
297
|
+
socket.setEncoding('utf8');
|
|
298
|
+
socket.setTimeout(125000, () => socket.destroy());
|
|
299
|
+
socket.on('error', (error) => { if (error.code !== 'EPIPE' && error.code !== 'ECONNRESET') process.stderr.write(error.message + '\n'); });
|
|
300
|
+
socket.on('data', (chunk) => {
|
|
301
|
+
if (handled) return;
|
|
302
|
+
buffer += chunk;
|
|
303
|
+
if (buffer.length > 65536) { socket.destroy(); return; }
|
|
304
|
+
if (!buffer.includes('\n')) return;
|
|
305
|
+
handled = true;
|
|
306
|
+
let request;
|
|
307
|
+
try { request = JSON.parse(buffer.slice(0, buffer.indexOf('\n'))); }
|
|
308
|
+
catch (error) { socket.end(JSON.stringify({ error: error.message }) + '\n'); return; }
|
|
309
|
+
const respond = async () => {
|
|
310
|
+
try { socket.end(JSON.stringify({ result: await handle(request) }) + '\n'); }
|
|
311
|
+
catch (error) { socket.end(JSON.stringify({ error: error.message }) + '\n'); }
|
|
312
|
+
};
|
|
313
|
+
// Read-only snapshots cannot wait for a stop/restart that is still draining file I/O.
|
|
314
|
+
if (['ping', 'list', 'status'].includes(request?.action)) void respond();
|
|
315
|
+
else mutations = mutations.then(respond);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(locations.socket, resolve); });
|
|
319
|
+
fs.chmodSync(locations.socket, 0o600);
|
|
320
|
+
for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => {
|
|
321
|
+
mutations = mutations.then(shutdown).catch((error) => { process.stderr.write(error.message + '\n'); process.exitCode = 1; });
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
process.on('exit', cleanup);
|
|
326
|
+
main().catch((error) => { process.stderr.write(error.stack + '\n'); cleanup(); process.exit(1); });
|
package/src/deploy.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** Deployment orchestration: SSH agent bootstrap, file-level delta upload and per-server retries.
|
|
2
|
+
* Exports deploy/SSHTransport; depends on immutable snapshots and a portable Node remote agent.
|
|
3
|
+
*/
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { createReadStream } from 'node:fs';
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { run } from './process.js';
|
|
10
|
+
import { withPassword } from './password.js';
|
|
11
|
+
|
|
12
|
+
/** Encode code and parameters so cmd.exe, PowerShell and POSIX shells see only safe characters. */
|
|
13
|
+
const encoded = (value) => Buffer.from(value).toString('base64');
|
|
14
|
+
const nodeCommand = (code) => `node -e "eval(Buffer.from('${encoded(code)}','base64').toString())"`;
|
|
15
|
+
|
|
16
|
+
export class SSHTransport {
|
|
17
|
+
/** A transport is scoped to one server and deployment, with no shared mutable connection state. */
|
|
18
|
+
constructor(server, config, log, signal) {
|
|
19
|
+
this.server = server;
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.log = log;
|
|
22
|
+
this.signal = signal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Strict host verification deliberately relies on the operator's existing known_hosts. */
|
|
26
|
+
async ssh(command, input, { cleanup = false, quiet = false, hook = false } = {}) {
|
|
27
|
+
const args = ['-T', '-o', `BatchMode=${this.server.password ? 'no' : 'yes'}`, '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3', '-p', String(this.server.port)];
|
|
28
|
+
if (this.server.password) args.push('-o', 'PreferredAuthentications=password', '-o', 'NumberOfPasswordPrompts=1');
|
|
29
|
+
if (this.server.identityFile) args.push('-i', this.server.identityFile);
|
|
30
|
+
args.push(`${this.server.user}@${this.server.host}`, command);
|
|
31
|
+
return withPassword(this.server.password, (env) => run('ssh', args, { input, env,
|
|
32
|
+
timeoutMs: cleanup ? Math.min(this.config.timeoutMs, 15000) : this.config.timeoutMs + (hook ? 2000 : 0),
|
|
33
|
+
signal: cleanup ? undefined : this.signal, onOutput: quiet ? undefined : (chunk) => this.log('info', chunk.trim(), this.server.name) }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Install a content-addressed agent atomically; concurrent tasks can share identical agent code. */
|
|
37
|
+
async install() {
|
|
38
|
+
const source = await readFile(new URL('./remote-agent.cjs', import.meta.url), 'utf8');
|
|
39
|
+
const hash = createHash('sha256').update(source).digest('hex');
|
|
40
|
+
this.agentName = `agent-${hash}.cjs`;
|
|
41
|
+
const bootstrap = `const fs=require('node:fs'),p=require('node:path'),c=require('node:crypto');let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',x=>s+=x);process.stdin.on('end',()=>{if(c.createHash('sha256').update(s).digest('hex')!=='${hash}')throw Error('Agent checksum mismatch');const d=p.join(require('node:os').homedir(),'.shipwatch-agents');fs.mkdirSync(d,{recursive:true,mode:448});const f=p.join(d,'${this.agentName}');if(fs.existsSync(f))return;const t=f+'.'+process.pid;fs.writeFileSync(t,s,{mode:384});try{fs.renameSync(t,f)}catch(e){fs.rmSync(t,{force:true});if(!fs.existsSync(f))throw e}});`;
|
|
42
|
+
await this.ssh(nodeCommand(bootstrap), source, { quiet: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Run one remote lifecycle operation; cleanup deliberately survives local task cancellation. */
|
|
46
|
+
async operation(action, version, input, options = {}) {
|
|
47
|
+
const payload = encoded(JSON.stringify({ root: this.server.path, version, platform: this.server.platform, keep: this.config.keepReleases }));
|
|
48
|
+
const code = `process.argv=[process.execPath,'agent','${action}','${payload}'];require(require('node:path').join(require('node:os').homedir(),'.shipwatch-agents','${this.agentName}'));`;
|
|
49
|
+
return this.ssh(nodeCommand(code), input, { ...options, hook: action === 'hook' });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Base64 framing costs bandwidth but needs no rsync, SFTP library, tar, or remote POSIX shell. */
|
|
54
|
+
async function* uploadPackets(directory, wanted, entries, onFileSent) {
|
|
55
|
+
for (const key of wanted) {
|
|
56
|
+
if (!Object.hasOwn(entries, key) || entries[key].type !== 'file') throw new Error('Server requested an unknown file');
|
|
57
|
+
yield JSON.stringify({ begin: key }) + '\n';
|
|
58
|
+
for await (const chunk of createReadStream(path.join(directory, key), { highWaterMark: 48 * 1024 })) yield JSON.stringify({ chunk: chunk.toString('base64') }) + '\n';
|
|
59
|
+
yield '{"end":true}\n';
|
|
60
|
+
onFileSent?.();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Publish one snapshot to all servers. Failed hosts retry without replaying successful hosts. */
|
|
65
|
+
export async function deploy(config, captured, log, signal, transportFactory = (...args) => new SSHTransport(...args), onProgress = () => {}) {
|
|
66
|
+
const manifestText = await readFile(path.join(captured.directory, '.shipwatch-manifest.json'), 'utf8');
|
|
67
|
+
const manifest = JSON.parse(manifestText);
|
|
68
|
+
return Promise.all(config.servers.map(async (server) => {
|
|
69
|
+
const transport = transportFactory(server, config, log, signal);
|
|
70
|
+
let lastError;
|
|
71
|
+
const startedAt = new Date().toISOString();
|
|
72
|
+
const started = performance.now();
|
|
73
|
+
let uploadMs = 0;
|
|
74
|
+
let updatedFiles = null;
|
|
75
|
+
let deletedFiles = null;
|
|
76
|
+
let outcome;
|
|
77
|
+
const finish = (data) => outcome = { ...data, startedAt, endedAt: new Date().toISOString(), durationMs: performance.now() - started, uploadMs, updatedFiles, deletedFiles, totalFiles: Object.values(manifest.entries).filter((entry) => entry.type === 'file').length };
|
|
78
|
+
|
|
79
|
+
for (let attempt = 1; attempt <= config.retry.attempts; attempt++) {
|
|
80
|
+
const version = `${Date.now()}-${randomBytes(8).toString('hex')}`;
|
|
81
|
+
let installed = false;
|
|
82
|
+
let prepared = false;
|
|
83
|
+
let published = false;
|
|
84
|
+
let transferredFiles = 0;
|
|
85
|
+
const progress = (phase, extra = {}) => onProgress({ server: server.name, phase, attempt, attempts: config.retry.attempts, transferredFiles, totalFiles: updatedFiles, ...extra });
|
|
86
|
+
try {
|
|
87
|
+
signal?.throwIfAborted();
|
|
88
|
+
log('info', `Release ${version}, attempt ${attempt}/${config.retry.attempts}`, server.name);
|
|
89
|
+
updatedFiles = null; progress('connecting');
|
|
90
|
+
await transport.install(); installed = true;
|
|
91
|
+
progress('preparing');
|
|
92
|
+
const response = await transport.operation('prepare', version, manifestText, { quiet: true });
|
|
93
|
+
prepared = true;
|
|
94
|
+
const { wanted, deletedFiles: removed = 0 } = JSON.parse(response);
|
|
95
|
+
updatedFiles = wanted?.length ?? null;
|
|
96
|
+
deletedFiles = removed;
|
|
97
|
+
if (!Array.isArray(wanted)) throw new Error('Invalid prepare response');
|
|
98
|
+
log('info', `${wanted.length} changed files to upload`, server.name);
|
|
99
|
+
progress('uploading');
|
|
100
|
+
const uploadStarted = performance.now();
|
|
101
|
+
try { await transport.operation('upload', version, uploadPackets(captured.directory, wanted, manifest.entries, () => { transferredFiles++; progress('uploading'); })); }
|
|
102
|
+
finally { uploadMs += performance.now() - uploadStarted; }
|
|
103
|
+
progress('verifying');
|
|
104
|
+
await transport.operation('verify', version);
|
|
105
|
+
// Cancellation must not start a publication after stop has been requested.
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
progress('activating');
|
|
108
|
+
await transport.operation('activate', version);
|
|
109
|
+
published = true;
|
|
110
|
+
let postDeployError;
|
|
111
|
+
if (server.postDeployCommand) {
|
|
112
|
+
progress('command');
|
|
113
|
+
log('info', 'Running post-deploy service command', server.name);
|
|
114
|
+
try {
|
|
115
|
+
await transport.operation('hook', version, JSON.stringify({ command: server.postDeployCommand, timeoutMs: config.timeoutMs }));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
log('error', `Published, but service command failed: ${error.message}`, server.name);
|
|
118
|
+
postDeployError = error.message;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
progress('pruning');
|
|
122
|
+
try { await transport.operation('prune', version); }
|
|
123
|
+
catch (error) { log('warn', `Published, but retention cleanup failed: ${error.message}`, server.name); }
|
|
124
|
+
if (postDeployError) return finish({ server: server.name, status: 'failed', published: true, version, attempt, phase: 'postDeploy', error: postDeployError });
|
|
125
|
+
return finish({ server: server.name, status: 'success', version, uploadedFiles: wanted.length, attempt });
|
|
126
|
+
} catch (error) {
|
|
127
|
+
lastError = error.message;
|
|
128
|
+
log('error', lastError, server.name);
|
|
129
|
+
} finally {
|
|
130
|
+
progress('cleanup');
|
|
131
|
+
if (installed) {
|
|
132
|
+
try { await transport.operation('cleanup', version, undefined, { cleanup: true, quiet: true }); }
|
|
133
|
+
catch (error) {
|
|
134
|
+
// A failed prepare may mean another publisher holds the lock; never remove its lock.
|
|
135
|
+
if (prepared || published) log('warn', `Cleanup failed; inspect .shipwatch-lock: ${error.message}`, server.name);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (outcome) Object.assign(outcome, { endedAt: new Date().toISOString(), durationMs: performance.now() - started });
|
|
139
|
+
if (outcome) progress(outcome.status);
|
|
140
|
+
}
|
|
141
|
+
if (signal?.aborted) break;
|
|
142
|
+
if (attempt < config.retry.attempts) {
|
|
143
|
+
const retryMs = Math.min(config.retry.delayMs * 2 ** (attempt - 1), 60000);
|
|
144
|
+
progress('retrying', { retryAt: Date.now() + retryMs });
|
|
145
|
+
try { await delay(retryMs, undefined, { signal }); }
|
|
146
|
+
catch { break; /* Cancellation ends backoff without creating another attempt. */ }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
onProgress({ server: server.name, phase: signal?.aborted ? 'canceled' : 'failed' });
|
|
150
|
+
return finish({ server: server.name, status: signal?.aborted ? 'canceled' : 'failed', error: lastError });
|
|
151
|
+
}));
|
|
152
|
+
}
|
package/src/password.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Password bridge: provide SSH_ASKPASS secrets through a private short-lived socket.
|
|
2
|
+
* Exports withPassword; password values never enter argv, environment variables or temporary files.
|
|
3
|
+
*/
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { mkdtemp, chmod, rm } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
/** One bridge per SSH invocation prevents credentials from being reused across target servers. */
|
|
11
|
+
export async function withPassword(password, operation) {
|
|
12
|
+
if (!password) return operation(process.env);
|
|
13
|
+
const directory = await mkdtemp('/tmp/sw-auth-');
|
|
14
|
+
const socketPath = path.join(directory, 'askpass.sock');
|
|
15
|
+
const token = randomBytes(32).toString('hex');
|
|
16
|
+
const clients = new Set();
|
|
17
|
+
const server = net.createServer((socket) => {
|
|
18
|
+
clients.add(socket);
|
|
19
|
+
socket.on('close', () => clients.delete(socket));
|
|
20
|
+
socket.on('error', () => socket.destroy());
|
|
21
|
+
socket.setTimeout(5000, () => socket.destroy());
|
|
22
|
+
let request = '';
|
|
23
|
+
socket.setEncoding('utf8');
|
|
24
|
+
socket.on('data', (chunk) => {
|
|
25
|
+
request += chunk;
|
|
26
|
+
if (request.length > 256) socket.destroy();
|
|
27
|
+
else if (request.includes('\n')) {
|
|
28
|
+
if (request.trim() === token) socket.end(password + '\n');
|
|
29
|
+
else socket.destroy();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
|
|
35
|
+
await chmod(socketPath, 0o600);
|
|
36
|
+
return await operation({ ...process.env,
|
|
37
|
+
SSH_ASKPASS: fileURLToPath(new URL('../bin/askpass.cjs', import.meta.url)), SSH_ASKPASS_REQUIRE: 'force',
|
|
38
|
+
SHIPWATCH_AUTH_SOCKET: socketPath, SHIPWATCH_AUTH_TOKEN: token,
|
|
39
|
+
});
|
|
40
|
+
} finally {
|
|
41
|
+
for (const client of clients) client.destroy();
|
|
42
|
+
if (server.listening) await new Promise((resolve) => server.close(resolve));
|
|
43
|
+
await rm(directory, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Chinese CLI presentation: translate routine messages while retaining original diagnostics and JSON contracts. */
|
|
2
|
+
import { formatReport, formatTable, formatTaskList, formatTime, statusText, fitLine } from './task-list.js';
|
|
3
|
+
import { brand, colorLevel, tint, toneCode, cleanText, stateTone, stateMark } from './terminal.js';
|
|
4
|
+
|
|
5
|
+
/** Translate built-in progress messages; SSH/OS/service diagnostics remain verbatim for troubleshooting. */
|
|
6
|
+
export function messageText(value) {
|
|
7
|
+
return String(value ?? '').replace(/Release (\S+), attempt (\d+)\/(\d+)/g, '版本 $1,第 $2/$3 次尝试')
|
|
8
|
+
.replace(/(\d+) changed files to upload/g, '待上传文件:$1 个').replace(/Uploaded /g, '已上传:')
|
|
9
|
+
.replace(/SHA-256 verification passed/g, 'SHA-256 校验通过').replace(/Activated /g, '已发布版本:')
|
|
10
|
+
.replace(/Synchronized target directory /g, '已同步目标目录:').replace(/Pruned /g, '已清理旧版本:')
|
|
11
|
+
.replace(/Running post-deploy service command/g, '正在执行发布后的服务命令').replace(/Service command completed/g, '服务命令执行完成')
|
|
12
|
+
.replace(/Published, but service command failed:/g, '文件已发布,但服务命令失败:').replace(/Published, but retention cleanup failed:/g, '发布成功,但旧版本清理失败:')
|
|
13
|
+
.replace(/Task loop failed:/g, '任务循环异常:').replace(/Cleanup failed; inspect .shipwatch-lock:/g, '清理失败,请检查 .shipwatch-lock:')
|
|
14
|
+
.replace(/Task stopped/g, '任务已停止').replace(/Watching /g, '正在监听:').replace(/Deployment queued/g, '发布已加入队列')
|
|
15
|
+
.replace(/All tasks stopped and removed/g, '已停止并删除全部任务').replace(/Deleted task (.*); deployment files and logs retained/g, '已删除任务 $1;发布文件和日志保留')
|
|
16
|
+
.replace(/Daemon shutting down; desired tasks will resume on next daemon start/g, '后台进程正在关闭;下次启动时恢复任务');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Render lifecycle results without exposing credentials or raw task objects in the human view. */
|
|
20
|
+
export function formatResult(result, columns) {
|
|
21
|
+
if (result.summary) return formatTaskList([result], columns) + '\n' + formatReport(result.summary, columns);
|
|
22
|
+
if (result.name && result.status) return formatTaskList([result], columns);
|
|
23
|
+
const rows = [];
|
|
24
|
+
if (result.message) rows.push(['结果', messageText(result.message)]);
|
|
25
|
+
if (result.pid) rows.push(['进程编号', result.pid]);
|
|
26
|
+
if (result.stoppedTasks !== undefined) rows.push(['已移除任务数', result.stoppedTasks]);
|
|
27
|
+
const level = colorLevel();
|
|
28
|
+
return formatTable(['项目', '内容'], rows, columns, { color: level > 0, headerColor: toneCode('cyan', level), cellColor: (column) => column === 1 ? toneCode('green', level) : toneCode('muted', level) });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Interactive overview adds the approved wordmark and useful shortcuts around the same table. */
|
|
32
|
+
export function formatTaskOverview(tasks, columns = 80) {
|
|
33
|
+
const level = colorLevel();
|
|
34
|
+
const table = formatTaskList(tasks, columns);
|
|
35
|
+
if (!level) return table;
|
|
36
|
+
const automatic = tasks.filter((task) => task.deployMode === 'auto').length;
|
|
37
|
+
const errors = tasks.filter((task) => task.status === 'error').length;
|
|
38
|
+
return brand(level) + table + '\n' + tint(fitLine(`${tasks.length} 个项目 · ${automatic} 个自动部署 · ${errors} 个异常`, columns), errors ? 'yellow' : 'muted', level) + '\n' + tint(fitLine('详情 sw info <ID> 日志 sw logs -f', columns), 'muted', level) + '\n';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Explicit details keep long directories/config paths complete, even before the first deployment. */
|
|
42
|
+
export function formatTaskDetails(task, columns) {
|
|
43
|
+
const level = colorLevel();
|
|
44
|
+
const rows = [
|
|
45
|
+
['任务 ID', task.id], ['任务名称', task.name], ['任务状态', statusText(task.status)],
|
|
46
|
+
['部署模式', task.deployMode === 'manual' ? '手动' : task.deployMode === 'auto' ? '自动' : '-'],
|
|
47
|
+
['监听目录', task.directory], ['配置文件', task.configFile],
|
|
48
|
+
['启动时间', formatTime(task.startedAt)], ['最近发布', formatTime(task.lastDeployAt)],
|
|
49
|
+
['自动次数', task.autoDeployCount ?? 0],
|
|
50
|
+
...(task.lastError ? [['错误详情', messageText(task.lastError)]] : []),
|
|
51
|
+
];
|
|
52
|
+
const heading = level ? brand(level) + tint(fitLine(`◈ ${cleanText(task.name)} #${task.id} ${stateMark(task.status)} ${statusText(task.status)}`, columns), stateTone(task.status), level, true) + '\n\n' : '';
|
|
53
|
+
return heading + formatTable(['项目', '内容'], rows, columns, { wrapCells: true, color: level > 0, headerColor: toneCode('cyan', level), cellColor: (column) => column === 0 ? toneCode('muted', level) : undefined }) + (task.summary ? '\n' + formatReport(task.summary, columns) : '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { formatReport, formatTime, statusText };
|
package/src/process.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Process adapter: bounded subprocess execution and safe POSIX argument quoting.
|
|
2
|
+
* Exports run/quote; uses spawn without a local shell, with timeout and cancellation.
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
|
|
8
|
+
/** Quote a single remote shell argument, including embedded apostrophes. */
|
|
9
|
+
export const quote = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
10
|
+
|
|
11
|
+
/** Run a command with bounded captured output; onOutput streams chunks to task logs. */
|
|
12
|
+
export function run(command, args, { timeoutMs = 120000, signal, onOutput, input, env = process.env } = {}) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
signal?.throwIfAborted();
|
|
15
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env });
|
|
16
|
+
let output = '';
|
|
17
|
+
let stdout = '';
|
|
18
|
+
let failure;
|
|
19
|
+
let killTimer;
|
|
20
|
+
/** Kill the process group so SSH/rsync children cannot survive a canceled task. */
|
|
21
|
+
const terminate = (reason) => {
|
|
22
|
+
if (failure) return;
|
|
23
|
+
failure = reason;
|
|
24
|
+
const kill = (kind) => { try { process.kill(-child.pid, kind); } catch (error) { if (error.code !== 'ESRCH') child.kill(kind); } };
|
|
25
|
+
if (child.pid) {
|
|
26
|
+
kill('SIGTERM');
|
|
27
|
+
killTimer = setTimeout(() => kill('SIGKILL'), 1000);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const abort = () => terminate(new Error('Operation canceled'));
|
|
31
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
32
|
+
const timer = setTimeout(() => terminate(new Error(`${command} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
33
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
34
|
+
stream.setEncoding('utf8');
|
|
35
|
+
stream.on('data', (chunk) => {
|
|
36
|
+
output = (output + chunk).slice(-65536);
|
|
37
|
+
if (stream === child.stdout) stdout += chunk;
|
|
38
|
+
if (stdout.length > 16 * 1024 * 1024) terminate(new Error('Command output exceeds 16 MiB'));
|
|
39
|
+
onOutput?.(chunk);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const source = typeof input === 'string' || Buffer.isBuffer(input) ? [input] : (input ?? []);
|
|
43
|
+
pipeline(Readable.from(source), child.stdin).catch((error) => { if (error.code !== 'EPIPE' && error.code !== 'ERR_STREAM_PREMATURE_CLOSE') terminate(error); });
|
|
44
|
+
child.on('error', (error) => { failure = error; });
|
|
45
|
+
child.on('close', (code) => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
clearTimeout(killTimer);
|
|
48
|
+
signal?.removeEventListener('abort', abort);
|
|
49
|
+
if (failure || code !== 0) reject(failure ?? new Error(`${command} exited ${code}: ${output.trim()}`));
|
|
50
|
+
else resolve(stdout);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|