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.
@@ -0,0 +1,108 @@
1
+ /** Live deployment display: poll the queued round, render bounded terminal lines, then show its report.
2
+ * Exports followDeployment/renderProgress; uses daemon requests and the existing report formatter.
3
+ */
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { request } from './client.js';
6
+ import { formatReport } from './presentation.js';
7
+ import { fitLine } from './task-list.js';
8
+ import { colorLevel, tint, gradient, stateTone } from './terminal.js';
9
+
10
+ const phases = { queued: '等待发布', snapshot: '制作快照', connecting: '连接服务器', preparing: '比较文件', uploading: '传输文件', verifying: '校验文件', activating: '同步目标目录', command: '执行服务命令', pruning: '整理版本', cleanup: '清理', retrying: '等待重试', success: '成功', failed: '失败', canceled: '已取消' };
11
+
12
+ /** Percent describes files passed to SSH, never a fabricated percentage of the whole release. */
13
+ export function renderProgress(progress, columns = 80) {
14
+ const servers = Object.values(progress?.servers ?? {});
15
+ const known = servers.length && servers.every((server) => Number.isInteger(server.totalFiles));
16
+ const total = servers.reduce((sum, server) => sum + (server.totalFiles ?? 0), 0);
17
+ const sent = servers.reduce((sum, server) => sum + (server.transferredFiles ?? 0), 0);
18
+ const ratio = known ? total ? Math.min(1, sent / total) : 1 : 0;
19
+ const fill = Math.floor(ratio * 10);
20
+ const bar = '[' + '#'.repeat(fill) + '-'.repeat(10 - fill) + ']';
21
+ const phase = progress?.phase === 'deploying' ? [...new Set(servers.map((server) => phases[server.phase] ?? server.phase))].join('/') : phases[progress?.phase] ?? '等待发布';
22
+ const value = `${phase} ${bar} ${known ? `${sent}/${total}` : '?/?'} 文件`;
23
+ // A conservative two-cell budget handles CJK in narrow terminals without wrapping cursor updates.
24
+ const limit = Math.max(1, Math.floor(((columns || 80) - 1) / 2));
25
+ const chars = Array.from(value);
26
+ return chars.length > limit ? chars.slice(0, Math.max(0, limit - 1)).join('') + '…' : value;
27
+ }
28
+
29
+ /** Render the approved per-server view within the terminal's dimensions; no invented overall percentage. */
30
+ export function renderDeploymentFrame(task, ticket, { columns = 80, rows = 24, elapsed = 0, now = Date.now(), tick = 0, level = colorLevel() } = {}) {
31
+ const progress = task.deploySequence < ticket.sequence ? { phase: 'queued' } : task.progress;
32
+ const limit = Math.max(1, rows - 2);
33
+ if (columns < 40 || limit < 10) return [tint(fitLine(renderProgress(progress, columns), columns), 'cyan', level)];
34
+ const servers = Object.values(progress?.servers ?? {});
35
+ const order = { connecting: 0, preparing: 0, uploading: 1, verifying: 2, activating: 3, command: 3, pruning: 3, cleanup: 3, retrying: 1, failed: 3, canceled: 3, success: 4 };
36
+ const step = progress?.phase === 'deploying' && servers.length ? Math.min(...servers.map((server) => order[server.phase] ?? 0)) : progress?.phase === 'cleanup' ? 3 : 0;
37
+ const spin = Array.from('⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏')[tick % 10];
38
+ const lines = [gradient('SHIPWATCH', level) + tint(' v0.1.0', 'muted', level), '', tint(fitLine(`🚀 ${ticket.name} #${ticket.id} · ${servers.length || '…'} 台服务器`, columns), 'cyan', level, true)];
39
+ const steps = ['准备', '传输', '校验', '发布', '完成'];
40
+ const stageLine = steps.map((label, index) => `${index < step ? '✓ ' : ''}${label}`);
41
+ lines.push(columns < 60 ? tint(fitLine(stageLine.join(' › '), columns), 'cyan', level) : stageLine.map((label, index) => tint(label, index < step ? 'green' : index === step ? 'cyan' : 'muted', level)).join(tint(' › ', 'border', level)));
42
+ lines.push(tint(fitLine(`已用 ${elapsed.toFixed(1)} 秒${task.summary?.totalFiles !== undefined ? ` · ${task.summary.totalFiles} 个项目文件` : ''}`, columns), 'muted', level), '');
43
+ const visible = servers.slice(0, Math.max(1, Math.floor((limit - 9) / 3)));
44
+ for (const server of visible) {
45
+ const known = Number.isInteger(server.totalFiles);
46
+ const count = known ? `${server.transferredFiles ?? 0}/${server.totalFiles}` : '?/?';
47
+ const retry = server.phase === 'retrying' && Number.isFinite(server.retryAt) ? ` · ${Math.max(0, Math.ceil((server.retryAt - now) / 1000))} 秒后重试` : '';
48
+ const attempt = server.attempt > 1 || server.phase === 'retrying' ? ` · 尝试 ${server.attempt}/${server.attempts ?? '?'}` : '';
49
+ const tone = ['success', 'failed', 'canceled'].includes(server.phase) ? stateTone(server.phase) : server.phase === 'retrying' ? 'yellow' : 'cyan';
50
+ lines.push(tint(fitLine(`${server.phase === 'success' ? '✓' : server.phase === 'failed' ? '×' : spin} ${server.server} · ${phases[server.phase] ?? server.phase}${attempt}${retry}`, columns), tone, level));
51
+ const size = Math.max(4, Math.min(32, columns - count.length - 15));
52
+ const ratio = known ? server.totalFiles ? Math.min(1, (server.transferredFiles ?? 0) / server.totalFiles) : 1 : 0;
53
+ const filled = Math.floor(ratio * size);
54
+ lines.push(' ' + gradient('█'.repeat(filled), level) + tint('░'.repeat(size - filled), 'border', level) + ` ${count} 文件`, '');
55
+ }
56
+ if (!servers.length) lines.push(tint(`${spin} ${phases[progress?.phase] ?? '等待发布'}`, 'cyan', level), '');
57
+ if (visible.length < servers.length && lines.length + 2 <= limit) lines.push(tint(fitLine(`另有 ${servers.length - visible.length} 台服务器,完整结果将在结束后显示`, columns), 'muted', level));
58
+ lines.push(tint(fitLine('Ctrl+C 退出查看 · 后台继续发布', columns), 'muted', level));
59
+ return lines.slice(0, limit);
60
+ }
61
+
62
+ /** Ctrl+C detaches the display; it does not cancel a deployment owned by the background daemon. */
63
+ export async function followDeployment(ticket, { json = false } = {}) {
64
+ const controller = new AbortController();
65
+ const stop = () => controller.abort();
66
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
67
+ const level = colorLevel();
68
+ const animated = level > 0 && !json;
69
+ const started = performance.now();
70
+ let tick = 0;
71
+ let screen = false;
72
+ // Alternate screen avoids damaging shell history on resize; always restore it before the final report.
73
+ const leaveScreen = () => {
74
+ if (screen) { process.stdout.write('\u001b[?25h\u001b[?1049l'); screen = false; }
75
+ };
76
+ let previous;
77
+ try {
78
+ while (!controller.signal.aborted) {
79
+ const task = await request('status', { id: ticket.id });
80
+ if (task.instanceId !== ticket.instanceId) throw new Error('任务已重启或替换,已退出进度查看');
81
+ const completed = task.completedReports?.find((item) => item.sequence === ticket.sequence);
82
+ if (completed) {
83
+ leaveScreen();
84
+ process.stdout.write(json ? JSON.stringify(completed.report, null, 2) + '\n' : formatReport(completed.report, process.stdout.columns));
85
+ if (completed.report.status !== 'success') process.exitCode = 1;
86
+ return;
87
+ }
88
+ if (!task.running) throw new Error('任务已停止,发布未完成');
89
+ if (task.deploySequence > ticket.sequence) throw new Error('该轮发布记录已过期,请查看项目日志');
90
+ const line = renderProgress(task.deploySequence < ticket.sequence ? { phase: 'queued' } : task.progress, process.stdout.columns);
91
+ if (animated) {
92
+ if (!screen) { process.stdout.write('\u001b[?1049h\u001b[?25l'); screen = true; }
93
+ const frame = renderDeploymentFrame(task, ticket, { columns: process.stdout.columns || 80, rows: process.stdout.rows || 24, elapsed: (performance.now() - started) / 1000, tick: tick++, level });
94
+ process.stdout.write('\u001b[H\u001b[2J' + frame.join('\n'));
95
+ } else if (!json && line !== previous) process.stdout.write(line + '\n');
96
+ previous = line;
97
+ await delay(250, undefined, { signal: controller.signal });
98
+ }
99
+ } catch (error) { if (!controller.signal.aborted) throw error; }
100
+ finally {
101
+ process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
102
+ leaveScreen();
103
+ if (controller.signal.aborted) {
104
+ if (!json) process.stdout.write('已退出进度查看,后台发布继续运行。\n');
105
+ process.exitCode = 130;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,341 @@
1
+ /** Remote agent for Linux and Windows: stage incremental files, SHA-256 verify, activate and prune.
2
+ * Entry: node agent.cjs ACTION BASE64_JSON. Requires Node >=22 and only builtin modules.
3
+ * This script is installed by content hash over SSH; all user data travels as JSON, never shell code.
4
+ */
5
+ const fs = require('node:fs/promises');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const { createReadStream } = require('node:fs');
9
+ const readline = require('node:readline');
10
+ const { spawn } = require('node:child_process');
11
+
12
+ /** Only Shipwatch-generated version names can address or delete releases. */
13
+ const VERSION = /^\d{13}-[a-f0-9]{16}$/;
14
+
15
+ /** Stream hash validation independently of the uploaded manifest's claims. */
16
+ async function digest(filename) {
17
+ const hash = crypto.createHash('sha256');
18
+ for await (const chunk of createReadStream(filename)) hash.update(chunk);
19
+ return hash.digest('hex');
20
+ }
21
+
22
+ /** Missing files are expected for first deployments; other filesystem errors remain visible. */
23
+ async function exists(filename) {
24
+ try { await fs.lstat(filename); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
25
+ }
26
+
27
+ /** Retention owns release directories and may remove read-only build artifacts without following links. */
28
+ async function removeRelease(directory) {
29
+ if (!(await exists(directory))) return;
30
+ async function unlock(root) {
31
+ const metadata = await fs.lstat(root);
32
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) return;
33
+ await fs.chmod(root, 0o700);
34
+ for (const name of await fs.readdir(root)) await unlock(path.join(root, name));
35
+ }
36
+ await unlock(directory);
37
+ await fs.rm(directory, { recursive: true, force: true });
38
+ }
39
+
40
+ /** Reject traversal and Windows reserved/ambiguous names on every platform. */
41
+ function safeKey(key) {
42
+ if (typeof key !== 'string' || !key || key.split('/').some((part) => !part || part === '.' || part === '..' || /[\\:\x00-\x1f<>"|?*]/.test(part) || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error(`Unsafe cross-platform filename: ${key}`);
43
+ return key;
44
+ }
45
+
46
+ /** Validate manifest schema before it can influence filesystem paths. */
47
+ function validateManifest(manifest) {
48
+ if (manifest?.version !== 1 || !manifest.entries || typeof manifest.entries !== 'object' || Array.isArray(manifest.entries)) throw new Error('Invalid manifest');
49
+ const folded = new Set();
50
+ for (const [key, entry] of Object.entries(manifest.entries)) {
51
+ safeKey(key);
52
+ if (/^(?:current|releases)$/i.test(key.split('/')[0]) || /^\.(?:shipwatch-|next-|previous-|verified-|hook-started-)/i.test(key.split('/')[0])) throw new Error(`Reserved deployment filename: ${key}`);
53
+ const normalized = process.platform === 'win32' ? key.toLowerCase() : key;
54
+ if (folded.has(normalized)) throw new Error(`Case-colliding path: ${key}`);
55
+ folded.add(normalized);
56
+ if (!entry || !['file', 'directory'].includes(entry.type) || !Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 511 || (entry.type === 'file' && !/^[a-f0-9]{64}$/.test(entry.sha256))) throw new Error(`Invalid manifest entry: ${key}`);
57
+ }
58
+ }
59
+
60
+ /** Materialize verified files in the configured directory, preserving files not managed by Shipwatch.
61
+ * Each file is replaced from a verified temporary copy. A whole multi-file update is not atomic.
62
+ */
63
+ async function syncTarget(root, release, manifest, version) {
64
+ // A prior interrupted directory-to-file replacement can leave old child paths beneath a file.
65
+ const targetExists = async (filename) => {
66
+ try { return await exists(filename); } catch (error) { if (error.code === 'ENOTDIR') return false; throw error; }
67
+ };
68
+ const stateFile = path.join(root, '.shipwatch-live.json');
69
+ const previous = await exists(stateFile) ? JSON.parse(await fs.readFile(stateFile, 'utf8')) : { version: 1, entries: {} };
70
+ validateManifest(previous);
71
+ const allKeys = [...new Set([...Object.keys(previous.entries), ...Object.keys(manifest.entries)])];
72
+ // Reject links before making changes; an existing junction must never redirect writes outside root.
73
+ for (const key of allKeys) {
74
+ const parts = key.split('/');
75
+ for (let count = 1; count <= parts.length; count++) {
76
+ const filename = path.join(root, ...parts.slice(0, count));
77
+ if (!(await targetExists(filename))) break;
78
+ const metadata = await fs.lstat(filename);
79
+ if (metadata.isSymbolicLink() || (!metadata.isFile() && !metadata.isDirectory())) throw new Error(`Unsafe target entry: ${key}`);
80
+ if (!metadata.isDirectory()) break;
81
+ }
82
+ }
83
+ const depth = (key) => key.split('/').length;
84
+ // Temporarily unlock managed build directories so read-only artifacts can be updated or removed.
85
+ const originalModes = new Map();
86
+ try {
87
+ for (const key of allKeys.sort((a, b) => depth(a) - depth(b))) {
88
+ const filename = path.join(root, key);
89
+ let metadata;
90
+ try { metadata = await fs.lstat(filename); } catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) continue; throw error; }
91
+ if (metadata.isDirectory() && process.platform !== 'win32') {
92
+ originalModes.set(key, metadata.mode & 0o777);
93
+ await fs.chmod(filename, (metadata.mode & 0o777) | 0o700);
94
+ }
95
+ }
96
+ for (const key of Object.keys(previous.entries).sort((a, b) => depth(b) - depth(a))) {
97
+ if (manifest.entries[key]?.type === previous.entries[key].type) continue;
98
+ const filename = path.join(root, key);
99
+ if (!(await targetExists(filename))) continue;
100
+ const metadata = await fs.lstat(filename);
101
+ const actualType = metadata.isDirectory() ? 'directory' : 'file';
102
+ if (manifest.entries[key]?.type === actualType) continue;
103
+ if (metadata.isDirectory()) {
104
+ try { await fs.rmdir(filename); }
105
+ catch (error) { if (!manifest.entries[key] && ['ENOTEMPTY', 'EEXIST'].includes(error.code)) continue; throw error; }
106
+ } else await fs.unlink(filename);
107
+ }
108
+ for (const key of Object.keys(manifest.entries).sort((a, b) => depth(a) - depth(b))) {
109
+ const entry = manifest.entries[key];
110
+ const filename = path.join(root, key);
111
+ if (entry.type === 'directory') { await fs.mkdir(filename, { recursive: true }); continue; }
112
+ await fs.mkdir(path.dirname(filename), { recursive: true });
113
+ if (await targetExists(filename) && !(await fs.lstat(filename)).isFile()) throw new Error(`Target type conflict: ${key}`);
114
+ if (!(await targetExists(filename)) || await digest(filename) !== entry.sha256) {
115
+ const temporary = path.join(root, `.shipwatch-sync-${version}`);
116
+ try {
117
+ await fs.copyFile(path.join(release, key), temporary, fs.constants.COPYFILE_EXCL);
118
+ if (await digest(temporary) !== entry.sha256) throw new Error(`Target SHA-256 mismatch: ${key}`);
119
+ if (process.platform !== 'win32') await fs.chmod(temporary, entry.mode);
120
+ await fs.rename(temporary, filename);
121
+ } finally { await fs.rm(temporary, { force: true }); }
122
+ } else if (process.platform !== 'win32') await fs.chmod(filename, entry.mode);
123
+ }
124
+ const temporaryState = path.join(root, `.shipwatch-live-${version}`);
125
+ try {
126
+ await fs.writeFile(temporaryState, JSON.stringify(manifest), { flag: 'wx', mode: 0o600 });
127
+ await fs.rename(temporaryState, stateFile);
128
+ } finally { await fs.rm(temporaryState, { force: true }); }
129
+ } finally {
130
+ // Apply directory modes last: a read-only parent must not prevent copying its children.
131
+ if (process.platform !== 'win32') {
132
+ const directories = new Set([...originalModes.keys(), ...Object.keys(manifest.entries).filter((key) => manifest.entries[key].type === 'directory')]);
133
+ for (const key of [...directories].sort((a, b) => depth(b) - depth(a))) {
134
+ const filename = path.join(root, key);
135
+ let metadata;
136
+ try { metadata = await fs.lstat(filename); } catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) continue; throw error; }
137
+ if (metadata.isDirectory()) await fs.chmod(filename, manifest.entries[key]?.mode ?? originalModes.get(key));
138
+ }
139
+ }
140
+ }
141
+ process.stdout.write(`Synchronized target directory ${root}\n`);
142
+ }
143
+
144
+ /** Main operation; the lock token is a version id so one publisher cannot release another's lock. */
145
+ async function main(action, options) {
146
+ const { root: requestedRoot, version, keep, platform } = options;
147
+ if (!VERSION.test(version) || !path.isAbsolute(requestedRoot) || path.resolve(requestedRoot) === path.parse(requestedRoot).root) throw new Error('Unsafe release location');
148
+ if ((process.platform === 'win32') !== (platform === 'windows')) throw new Error('Server platform does not match configuration');
149
+ if (action === 'prepare') await fs.mkdir(requestedRoot, { recursive: true });
150
+ if ((await fs.lstat(requestedRoot)).isSymbolicLink()) throw new Error('Deployment root cannot be a symlink');
151
+ // Canonicalize ancestors too: aliases such as /var -> /private/var must compare identically.
152
+ const root = await fs.realpath(requestedRoot);
153
+ const releases = path.join(path.dirname(root), `${path.basename(root)}-releases`);
154
+ const previousSibling = path.join(path.dirname(root), `${path.basename(root)}releases`);
155
+ const legacyReleases = path.join(root, 'releases');
156
+ // A sibling directory needs its own ownership claim; the old root marker cannot authorize it.
157
+ const releasesMarker = path.join(root, '.shipwatch-releases');
158
+ const ownsReleases = async () => await exists(releasesMarker) && await fs.readFile(releasesMarker, 'utf8') === releases;
159
+ const release = path.join(releases, version);
160
+ const current = path.join(root, 'current');
161
+ const lock = path.join(root, '.shipwatch-lock');
162
+ const marker = path.join(root, '.shipwatch-owned');
163
+ const manifestPath = path.join(release, '.shipwatch-manifest.json');
164
+ const assertLock = async () => {
165
+ if ((await fs.readFile(lock, 'utf8')) !== version) throw new Error('Release lock ownership mismatch');
166
+ };
167
+ const readManifest = async () => {
168
+ const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
169
+ validateManifest(manifest);
170
+ return manifest;
171
+ };
172
+ if (action === 'prepare') {
173
+ let contents = '';
174
+ for await (const chunk of process.stdin) contents += chunk;
175
+ const manifest = JSON.parse(contents);
176
+ validateManifest(manifest);
177
+ await fs.mkdir(root, { recursive: true });
178
+ if ((await fs.lstat(root)).isSymbolicLink()) throw new Error('Deployment root cannot be a symlink');
179
+ await fs.writeFile(lock, version, { flag: 'wx', mode: 0o600 });
180
+ // Do not adopt an existing releases/current tree without an ownership marker.
181
+ if (!(await exists(marker))) {
182
+ if (await exists(releases) || await exists(legacyReleases) || await exists(current)) throw new Error('Unmanaged releases/current exists at target');
183
+ await fs.writeFile(marker, 'shipwatch-v1\n', { flag: 'wx' });
184
+ }
185
+ if ((await fs.readFile(marker, 'utf8')) !== 'shipwatch-v1\n') throw new Error('Invalid target ownership marker');
186
+ if (!(await ownsReleases())) {
187
+ if (await exists(releases)) throw new Error('Unmanaged sibling releases exists at target');
188
+ if (await exists(releasesMarker) && await fs.readFile(releasesMarker, 'utf8') !== previousSibling) throw new Error('Invalid releases ownership marker');
189
+ await fs.mkdir(releases);
190
+ await fs.writeFile(releasesMarker, releases);
191
+ } else await fs.mkdir(releases, { recursive: true });
192
+ if ((await fs.lstat(releases)).isSymbolicLink()) throw new Error('Releases directory cannot be a symlink');
193
+ await fs.mkdir(release);
194
+ let previous = {};
195
+ let previousRoot;
196
+ if (await exists(current)) {
197
+ if (!(await fs.lstat(current)).isSymbolicLink()) throw new Error('current must be a managed link');
198
+ previousRoot = await fs.realpath(current);
199
+ // Existing sites keep serving the old tree until the verified new release is activated.
200
+ const legacyParent = await exists(legacyReleases) && !(await fs.lstat(legacyReleases)).isSymbolicLink()
201
+ ? await fs.realpath(legacyReleases) : undefined;
202
+ if (![await fs.realpath(releases), legacyParent, previousSibling].includes(path.dirname(previousRoot)) || !VERSION.test(path.basename(previousRoot))) throw new Error('current points outside managed releases');
203
+ previous = JSON.parse(await fs.readFile(path.join(previousRoot, '.shipwatch-manifest.json'), 'utf8')).entries;
204
+ }
205
+ const wanted = [];
206
+ for (const [key, entry] of Object.entries(manifest.entries)) {
207
+ const target = path.join(release, key);
208
+ if (entry.type === 'directory') await fs.mkdir(target, { recursive: true });
209
+ else {
210
+ await fs.mkdir(path.dirname(target), { recursive: true });
211
+ if (previousRoot && previous[key]?.type === 'file' && previous[key].sha256 === entry.sha256) {
212
+ // Copy rather than hardlink: chmod or external writes cannot mutate retained releases.
213
+ const previousFile = path.join(previousRoot, key);
214
+ if (!(await fs.lstat(previousFile)).isFile()) throw new Error('Previous release contains a non-file');
215
+ await fs.copyFile(previousFile, target);
216
+ if (process.platform !== 'win32') await fs.chmod(target, entry.mode);
217
+ } else wanted.push(key);
218
+ }
219
+ }
220
+ await fs.writeFile(manifestPath, JSON.stringify(manifest));
221
+ process.stdout.write(JSON.stringify({ wanted, deletedFiles: Object.keys(previous).filter((key) => previous[key].type === 'file' && !manifest.entries[key]).length }));
222
+ return;
223
+ }
224
+ await assertLock();
225
+ if ((await fs.lstat(root)).isSymbolicLink()) throw new Error('Deployment root cannot be a symlink');
226
+ if (await exists(releases) && (await fs.lstat(releases)).isSymbolicLink()) throw new Error('Releases directory cannot be a symlink');
227
+ if (action !== 'cleanup' && !(await ownsReleases())) throw new Error('Unmanaged sibling releases exists at target');
228
+ if (action === 'upload') {
229
+ const manifest = await readManifest();
230
+ let handle;
231
+ let key;
232
+ try {
233
+ for await (const line of readline.createInterface({ input: process.stdin, crlfDelay: Infinity })) {
234
+ const packet = JSON.parse(line);
235
+ if (packet.begin !== undefined) {
236
+ if (handle) throw new Error('Unclosed upload');
237
+ key = safeKey(packet.begin);
238
+ if (manifest.entries[key]?.type !== 'file') throw new Error('Unexpected upload path');
239
+ handle = await fs.open(path.join(release, key), 'w', 0o600);
240
+ } else if (packet.chunk !== undefined) {
241
+ if (!handle || typeof packet.chunk !== 'string' || packet.chunk.length > 100000) throw new Error('Invalid upload chunk');
242
+ await handle.writeFile(Buffer.from(packet.chunk, 'base64'));
243
+ } else if (packet.end === true) {
244
+ if (!handle) throw new Error('Unexpected upload end');
245
+ if (process.platform !== 'win32') await handle.chmod(manifest.entries[key].mode);
246
+ await handle.close(); handle = undefined;
247
+ process.stdout.write(`Uploaded ${key}\n`);
248
+ } else throw new Error('Invalid upload packet');
249
+ }
250
+ if (handle) throw new Error('Truncated upload stream');
251
+ } finally { if (handle) await handle.close(); }
252
+ } else if (action === 'verify') {
253
+ const manifest = await readManifest();
254
+ const found = new Set();
255
+ async function walk(relative = '') {
256
+ for (const name of await fs.readdir(path.join(release, relative))) {
257
+ const key = relative ? `${relative}/${name}` : name;
258
+ if (key === '.shipwatch-manifest.json') continue;
259
+ const entry = manifest.entries[key];
260
+ const filename = path.join(release, key);
261
+ const metadata = await fs.lstat(filename);
262
+ if (!entry || metadata.isSymbolicLink() || (entry.type === 'directory' ? !metadata.isDirectory() : !metadata.isFile())) throw new Error(`Unexpected file/type: ${key}`);
263
+ found.add(key);
264
+ if (entry.type === 'directory') await walk(key);
265
+ else if (await digest(filename) !== entry.sha256) throw new Error(`SHA-256 mismatch: ${key}`);
266
+ if (process.platform !== 'win32') {
267
+ if (entry.type === 'directory') await fs.chmod(filename, entry.mode);
268
+ if (((await fs.stat(filename)).mode & 0o777) !== entry.mode) throw new Error(`Mode mismatch: ${key}`);
269
+ }
270
+ }
271
+ }
272
+ await walk();
273
+ if (found.size !== Object.keys(manifest.entries).length) throw new Error('Missing release files');
274
+ await fs.writeFile(path.join(root, `.verified-${version}`), version);
275
+ process.stdout.write('SHA-256 verification passed\n');
276
+ } else if (action === 'activate') {
277
+ if ((await fs.readFile(path.join(root, `.verified-${version}`), 'utf8')) !== version) throw new Error('Release not verified');
278
+ await syncTarget(root, release, await readManifest(), version);
279
+ const next = path.join(root, `.next-${version}`);
280
+ const backup = path.join(root, `.previous-${version}`);
281
+ await fs.symlink(release, next, process.platform === 'win32' ? 'junction' : 'dir');
282
+ if (process.platform === 'win32' && await exists(current)) {
283
+ // Windows cannot replace an existing junction atomically. Restore old current if step two fails.
284
+ await fs.rename(current, backup);
285
+ try { await fs.rename(next, current); } catch (error) { await fs.rename(backup, current); throw error; }
286
+ await fs.unlink(backup);
287
+ } else await fs.rename(next, current);
288
+ process.stdout.write(`Activated ${version}\n`);
289
+ } else if (action === 'hook') {
290
+ if (await fs.realpath(current) !== release) throw new Error('Service command requires the active release');
291
+ let payload = '';
292
+ for await (const chunk of process.stdin) payload += chunk;
293
+ const { command, timeoutMs } = JSON.parse(payload);
294
+ if (typeof command !== 'string' || !command.trim() || command.length > 16384 || command.includes('\0') || !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 3600000) throw new Error('Invalid service command');
295
+ // Claim before executing: a lost SSH response must not cause the same command to run twice.
296
+ await fs.writeFile(path.join(root, `.hook-started-${version}`), version, { flag: 'wx' });
297
+ await new Promise((resolve, reject) => {
298
+ const child = spawn(command, { shell: true, cwd: root, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
299
+ child.stdout.pipe(process.stdout); child.stderr.pipe(process.stderr);
300
+ let timedOut = false;
301
+ const timer = setTimeout(() => {
302
+ timedOut = true;
303
+ if (process.platform === 'win32') {
304
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
305
+ killer.on('error', () => child.kill());
306
+ } else {
307
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') child.kill('SIGKILL'); }
308
+ }
309
+ }, timeoutMs);
310
+ child.on('error', (error) => { clearTimeout(timer); reject(error); });
311
+ child.on('close', (code) => {
312
+ clearTimeout(timer);
313
+ if (timedOut) reject(new Error('Service command timed out'));
314
+ else if (code !== 0) reject(new Error(`Service command exited ${code}`));
315
+ else resolve();
316
+ });
317
+ });
318
+ process.stdout.write('Service command completed\n');
319
+ } else if (action === 'prune') {
320
+ if (!Number.isInteger(keep) || keep < 1) throw new Error('Invalid retention');
321
+ const active = path.basename(await fs.realpath(current));
322
+ const versions = (await fs.readdir(releases)).filter((name) => VERSION.test(name)).sort().reverse();
323
+ const retained = new Set([active, ...versions.filter((name) => name !== active).slice(0, keep - 1)]);
324
+ for (const name of versions) if (!retained.has(name)) {
325
+ await removeRelease(path.join(releases, name));
326
+ process.stdout.write(`Pruned ${name}\n`);
327
+ }
328
+ } else if (action === 'cleanup') {
329
+ const active = await exists(current) ? await fs.realpath(current) : undefined;
330
+ if (active !== release && await ownsReleases()) await removeRelease(release);
331
+ for (const filename of [`.next-${version}`, `.verified-${version}`, `.hook-started-${version}`]) {
332
+ if (await exists(path.join(root, filename))) await fs.unlink(path.join(root, filename));
333
+ }
334
+ await fs.unlink(lock);
335
+ } else throw new Error(`Unknown remote operation: ${action}`);
336
+ }
337
+
338
+ main(process.argv[2], JSON.parse(Buffer.from(process.argv[3] || '', 'base64').toString())).catch((error) => {
339
+ process.stderr.write(`${error.message}\n`);
340
+ process.exitCode = 1;
341
+ });
@@ -0,0 +1,88 @@
1
+ /** Snapshot service: hash source trees and create immutable, verified staging copies.
2
+ * Exports scan/snapshot; Node crypto/fs only; rejects links and special files.
3
+ */
4
+ import { createHash } from 'node:crypto';
5
+ import { createReadStream } from 'node:fs';
6
+ import { readdir, lstat, mkdir, copyFile, chmod, mkdtemp, writeFile, rm } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+
10
+ /** Match * within a path segment and ** across segments; bare patterns match any basename. */
11
+ function matcher(pattern) {
12
+ const normalized = pattern.replace(/^\//, '').replace(/\/$/, '');
13
+ let source = '';
14
+ for (let index = 0; index < normalized.length; index++) {
15
+ const char = normalized[index];
16
+ if (char === '*' && normalized[index + 1] === '*') {
17
+ index++;
18
+ if (normalized[index + 1] === '/') { index++; source += '(?:.*/)?'; } else source += '.*';
19
+ } else if (char === '*') source += '[^/]*';
20
+ else if (char === '?') source += '[^/]';
21
+ else source += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
+ }
23
+ return new RegExp((normalized.includes('/') ? '^' : '(^|/)') + source + '$');
24
+ }
25
+
26
+ /** Stream hashes to keep memory bounded for large artifacts. */
27
+ async function hashFile(filename) {
28
+ const hash = createHash('sha256');
29
+ for await (const chunk of createReadStream(filename)) hash.update(chunk);
30
+ return hash.digest('hex');
31
+ }
32
+
33
+ /** Restore owner write access before deleting snapshots containing read-only source directories. */
34
+ async function removeSnapshot(directory) {
35
+ async function unlock(root) {
36
+ await chmod(root, 0o700);
37
+ for (const name of await readdir(root)) {
38
+ const child = path.join(root, name);
39
+ if ((await lstat(child)).isDirectory()) await unlock(child);
40
+ }
41
+ }
42
+ await unlock(directory);
43
+ await rm(directory, { recursive: true, force: true });
44
+ }
45
+
46
+ /** Include directory entries and modes so empty directories and chmod changes trigger publication. */
47
+ export async function scan(config, root = config.directory) {
48
+ const patterns = config.exclude.map(matcher);
49
+ const entries = Object.create(null);
50
+ const configRelative = path.relative(root, config.configFile).split(path.sep).join('/');
51
+ async function walk(relative = '') {
52
+ for (const name of (await readdir(path.join(root, relative))).sort()) {
53
+ const key = relative ? `${relative}/${name}` : name;
54
+ if (key === configRelative || patterns.some((pattern) => pattern.test(key))) continue;
55
+ const filename = path.join(root, key);
56
+ const metadata = await lstat(filename);
57
+ if (metadata.isSymbolicLink() || (!metadata.isDirectory() && !metadata.isFile())) throw new Error(`Unsupported symlink or special file: ${key}`);
58
+ const mode = metadata.mode & 0o777;
59
+ entries[key] = metadata.isDirectory() ? { type: 'directory', mode } : { type: 'file', mode, sha256: await hashFile(filename) };
60
+ if (metadata.isDirectory()) await walk(key);
61
+ }
62
+ }
63
+ await walk();
64
+ return { entries, digest: createHash('sha256').update(JSON.stringify(entries)).digest('hex') };
65
+ }
66
+
67
+ /** Compare before/copy/after hashes; never publish a tree that changed during capture. */
68
+ export async function snapshot(config) {
69
+ const directory = await mkdtemp(path.join(os.tmpdir(), 'shipwatch-snapshot-'));
70
+ try {
71
+ const before = await scan(config);
72
+ for (const [key, entry] of Object.entries(before.entries)) {
73
+ const target = path.join(directory, key);
74
+ if (entry.type === 'directory') await mkdir(target, { recursive: true });
75
+ else { await copyFile(path.join(config.directory, key), target); await chmod(target, entry.mode); }
76
+ }
77
+ // Apply directory modes last so read-only source directories can still be assembled.
78
+ for (const [key, entry] of Object.entries(before.entries).reverse()) if (entry.type === 'directory') await chmod(path.join(directory, key), entry.mode);
79
+ const copied = await scan(config, directory);
80
+ const after = await scan(config);
81
+ if (before.digest !== copied.digest || before.digest !== after.digest) throw new Error('Source changed during snapshot; retrying on next stable scan');
82
+ await writeFile(path.join(directory, '.shipwatch-manifest.json'), JSON.stringify({ version: 1, entries: before.entries }));
83
+ return { directory, digest: before.digest, totalFiles: Object.values(before.entries).filter((entry) => entry.type === 'file').length, cleanup: () => removeSnapshot(directory) };
84
+ } catch (error) {
85
+ await removeSnapshot(directory);
86
+ throw error;
87
+ }
88
+ }