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/task-list.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** Terminal task list renderer: fit columns to available cells and fall back to stacked fields.
|
|
2
|
+
* Exports formatTaskList; uses Intl grapheme segmentation and Node control-sequence stripping.
|
|
3
|
+
*/
|
|
4
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
5
|
+
import { colorLevel, toneCode, tint, gradient, stateTone, stateMark } from './terminal.js';
|
|
6
|
+
|
|
7
|
+
const fields = ['ID', '名称', '状态', '自动部署', '自动次数', '启动时间'];
|
|
8
|
+
const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
9
|
+
|
|
10
|
+
/** Prevent filenames and error-state labels from adding terminal controls or extra rows. */
|
|
11
|
+
function clean(value) {
|
|
12
|
+
return stripVTControlCharacters(String(value ?? '-')).replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Count terminal cells, keeping combining marks and emoji sequences attached to their base. */
|
|
16
|
+
function cells(grapheme) {
|
|
17
|
+
if (/\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(grapheme)) return 2;
|
|
18
|
+
const visible = grapheme.replace(/[\p{Mark}\p{Format}]/gu, '');
|
|
19
|
+
if (!visible) return 0;
|
|
20
|
+
const code = visible.codePointAt(0);
|
|
21
|
+
return (code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a ||
|
|
22
|
+
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) || (code >= 0xac00 && code <= 0xd7a3) ||
|
|
23
|
+
(code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe10 && code <= 0xfe19) ||
|
|
24
|
+
(code >= 0xfe30 && code <= 0xfe6f) || (code >= 0xff00 && code <= 0xff60) ||
|
|
25
|
+
(code >= 0xffe0 && code <= 0xffe6) || (code >= 0x20000 && code <= 0x3fffd))) ? 2 : 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const graphemes = (value) => [...segments.segment(value)].map((item) => item.segment);
|
|
29
|
+
const width = (value) => graphemes(value).reduce((total, item) => total + cells(item), 0);
|
|
30
|
+
|
|
31
|
+
/** Take complete graphemes from one end without splitting CJK/emoji into partial cells. */
|
|
32
|
+
function take(value, available, fromEnd = false) {
|
|
33
|
+
const parts = graphemes(value);
|
|
34
|
+
if (fromEnd) parts.reverse();
|
|
35
|
+
const result = [];
|
|
36
|
+
let used = 0;
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
const size = cells(part);
|
|
39
|
+
if (used + size > available) break;
|
|
40
|
+
result.push(part); used += size;
|
|
41
|
+
}
|
|
42
|
+
return (fromEnd ? result.reverse() : result).join('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Retain both the directory root and final component when a path cannot fit its column. */
|
|
46
|
+
function truncate(value, available, middle = false) {
|
|
47
|
+
if (width(value) <= available) return value;
|
|
48
|
+
if (!middle) return take(value, available - 1) + '…';
|
|
49
|
+
const prefix = Math.floor((available - 1) / 2);
|
|
50
|
+
return take(value, prefix) + '…' + take(value, available - prefix - 1, true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Stacked records preserve fields on terminals too narrow even for the table headings. */
|
|
54
|
+
function wrap(value, available) {
|
|
55
|
+
const lines = [];
|
|
56
|
+
let line = ''; let used = 0;
|
|
57
|
+
for (const part of graphemes(value)) {
|
|
58
|
+
const size = cells(part);
|
|
59
|
+
if (used + size > available && line) { lines.push(line); line = ''; used = 0; }
|
|
60
|
+
if (size > available) { lines.push('…'); continue; }
|
|
61
|
+
line += part; used += size;
|
|
62
|
+
}
|
|
63
|
+
if (line) lines.push(line);
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shared clipping for dynamic terminal frames; measure Unicode cells before applying color. */
|
|
68
|
+
export function fitLine(value, columns = 80) {
|
|
69
|
+
return truncate(clean(value), Math.max(1, (columns || 80) - 1));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Leave the final terminal cell empty to prevent automatic wrapping at the right edge. */
|
|
73
|
+
export function formatTable(fields, inputRows, columns = 80, { wrapCells = false, boxed = false, color = false, cellColor, headerColor = '1;36', borderColor = '90' } = {}) {
|
|
74
|
+
const available = Math.max(1, (Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 80) - 1);
|
|
75
|
+
if (!inputRows.length) return wrap('暂无任务,请使用 shipwatch start 添加。', available).join('\n') + '\n';
|
|
76
|
+
const rows = inputRows.map((row) => row.map(clean));
|
|
77
|
+
const sizes = fields.map(width);
|
|
78
|
+
const separator = ' | ';
|
|
79
|
+
const minimum = sizes.reduce((total, size) => total + size, 0) + separator.length * (fields.length - 1) + (boxed ? 4 : 0);
|
|
80
|
+
if (minimum > available) {
|
|
81
|
+
return rows.map((row) => fields.flatMap((field, index) => wrap(`${field}: ${row[index]}`, available)).join('\n')).join('\n\n') + '\n';
|
|
82
|
+
}
|
|
83
|
+
let remaining = available - minimum;
|
|
84
|
+
// Keep names/status/timestamps readable, then give the path every remaining cell.
|
|
85
|
+
for (const index of fields.length === 4 ? [0, 1, 3, 2] : fields.map((_, index) => index)) {
|
|
86
|
+
const desired = rows.reduce((longest, row) => Math.max(longest, width(row[index])), sizes[index]);
|
|
87
|
+
const extra = Math.min(remaining, desired - sizes[index]);
|
|
88
|
+
sizes[index] += extra; remaining -= extra;
|
|
89
|
+
}
|
|
90
|
+
// PM2 cli-tableau: grey Unicode borders, one-cell side padding, cyan bold headings.
|
|
91
|
+
const paint = (value, code) => color && code ? `\u001b[${code}m${value}\u001b[0m` : value;
|
|
92
|
+
const edge = (value) => paint(value, borderColor);
|
|
93
|
+
const render = (row, rowIndex = -1) => {
|
|
94
|
+
const values = row.map((value, index) => {
|
|
95
|
+
const visible = truncate(value, sizes[index], index === 2);
|
|
96
|
+
const padded = visible + ' '.repeat(sizes[index] - width(visible));
|
|
97
|
+
return paint(padded, rowIndex === -1 ? headerColor : cellColor?.(index, rowIndex));
|
|
98
|
+
});
|
|
99
|
+
return boxed ? edge('│') + ' ' + values.join(' ' + edge('│') + ' ') + ' ' + edge('│') : values.join(separator).trimEnd();
|
|
100
|
+
};
|
|
101
|
+
// Detail views wrap long values so copying a path never loses its middle components.
|
|
102
|
+
const renderedRows = wrapCells ? rows.flatMap((row, rowIndex) => {
|
|
103
|
+
const lines = row.map((value, index) => wrap(value, sizes[index]));
|
|
104
|
+
return Array.from({ length: Math.max(...lines.map((items) => items.length)) }, (_, line) => render(lines.map((items) => items[line] ?? ''), rowIndex));
|
|
105
|
+
}) : rows.map(render);
|
|
106
|
+
if (boxed) {
|
|
107
|
+
const border = (left, middle, right) => edge(left + sizes.map((size) => '─'.repeat(size + 2)).join(middle) + right);
|
|
108
|
+
return [border('┌', '┬', '┐'), render(fields), border('├', '┼', '┤'), ...renderedRows, border('└', '┴', '┘')].join('\n') + '\n';
|
|
109
|
+
}
|
|
110
|
+
return [render(fields), sizes.map((size) => '-'.repeat(size)).join('-+-'), ...renderedRows].join('\n') + '\n';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Human task overview; JSON protocol keeps the original field and status values. */
|
|
114
|
+
export function formatTaskList(tasks, columns = 80, { color = colorLevel() > 0 } = {}) {
|
|
115
|
+
const level = color ? colorLevel() || 1 : 0;
|
|
116
|
+
return formatTable(fields, tasks.map((task) => [task.id, task.name, `${stateMark(task.status)} ${statusText(task.status)}`, task.deployMode === 'auto' ? '开启' : task.deployMode === 'manual' ? '关闭' : '-', task.autoDeployCount ?? 0, formatTime(task.startedAt)]), columns, {
|
|
117
|
+
boxed: true, color,
|
|
118
|
+
headerColor: toneCode('cyan', level, true), borderColor: toneCode('border', level),
|
|
119
|
+
cellColor: (column, row) => column === 0 ? toneCode('cyan', level, true) : column === 2 ? toneCode(stateTone(tasks[row]?.status), level, true) : column === 3 ? toneCode(tasks[row]?.deployMode === 'auto' ? 'green' : 'muted', level) : column === 5 ? toneCode('muted', level) : undefined,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Display local time without changing stored UTC timestamps. */
|
|
124
|
+
export function formatTime(value) {
|
|
125
|
+
if (!value) return '-';
|
|
126
|
+
const date = new Date(value);
|
|
127
|
+
if (!Number.isFinite(date.getTime())) return '-';
|
|
128
|
+
const pad = (number) => String(number).padStart(2, '0');
|
|
129
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function statusText(value) {
|
|
133
|
+
return ({ watching: '监听中', idle: '等待手动发布', deploying: '发布中', stopped: '已停止', error: '异常', success: '成功', failed: '失败', canceled: '已取消' })[value] ?? value ?? '-';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A vertical table keeps all timing fields readable on ordinary 80-column terminals. */
|
|
137
|
+
export function formatReport(report, columns = 80) {
|
|
138
|
+
const level = colorLevel();
|
|
139
|
+
const style = { color: level > 0, wrapCells: true, headerColor: toneCode('cyan', level, true), borderColor: toneCode('border', level), cellColor: (column) => column === 0 ? toneCode('muted', level) : undefined };
|
|
140
|
+
const seconds = (value) => Number.isFinite(value) ? `${(value / 1000).toFixed(3)} 秒` : '-';
|
|
141
|
+
let output = formatTable(['项目', '内容'], [
|
|
142
|
+
['任务名称', report.name ?? '-'], ['本地目录', report.directory ?? '-'], ['文件总数', report.totalFiles ?? report.results?.[0]?.totalFiles], ['发布状态', statusText(report.status)],
|
|
143
|
+
...(report.detectedAt ? [['本地检测时间', formatTime(report.detectedAt)], ['发布成功时间', formatTime(report.publishedAt)], ['检测到发布成功耗时', seconds(report.detectionToPublishMs)]] : []),
|
|
144
|
+
['开始时间', formatTime(report.startedAt)], ['结束时间', formatTime(report.endedAt)], ['整个过程耗时', seconds(report.durationMs)], ...(report.error ? [['错误详情', report.error]] : []),
|
|
145
|
+
], columns, style);
|
|
146
|
+
if (level) {
|
|
147
|
+
const title = `${report.status === 'success' ? '✅' : report.status === 'failed' ? '⚠' : '◈'} 发布报告 · ${report.name ?? '-'} · ${statusText(report.status)}`;
|
|
148
|
+
const total = report.detectionToPublishMs ?? report.durationMs;
|
|
149
|
+
output = tint(fitLine(title, columns), stateTone(report.status), level, true) + '\n' + (Number.isFinite(total) ? tint(report.detectionToPublishMs !== undefined ? '检测 → 发布成功 ' : '发布过程耗时 ', 'muted', level) + gradient(seconds(total), level) + '\n' : '') + '\n' + output;
|
|
150
|
+
}
|
|
151
|
+
for (const result of report.results ?? []) output += '\n' + formatTable(['服务器', result.server ?? '-'], [
|
|
152
|
+
['发布状态', statusText(result.status)], ['文件总数', result.totalFiles], ['更新文件数', result.updatedFiles], ['删除文件数', result.deletedFiles],
|
|
153
|
+
['开始时间', formatTime(result.startedAt)], ['结束时间', formatTime(result.endedAt)], ['服务器发布耗时', seconds(result.durationMs)], ['上传耗时(含重试)', seconds(result.uploadMs)],
|
|
154
|
+
...(result.error ? [['错误详情', result.error]] : []),
|
|
155
|
+
], columns, { ...style, cellColor: (column, row) => column === 0 ? toneCode('muted', level) : row === 0 ? toneCode(stateTone(result.status), level, true) : undefined });
|
|
156
|
+
return output;
|
|
157
|
+
}
|
package/src/task.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** Task runtime: serialized polling, debounce, deployment state and cancellation.
|
|
2
|
+
* Exports Task; depends on snapshot/deploy and timers; filesystem polling handles nested trees.
|
|
3
|
+
*/
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { scan, snapshot } from './snapshot.js';
|
|
6
|
+
import { deploy } from './deploy.js';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
export class Task {
|
|
10
|
+
/** Inject services for deterministic failure and lifecycle tests. */
|
|
11
|
+
constructor(config, log, services = { scan, snapshot, deploy }, { autoDeployCount = 0, onAutoDeploy } = {}) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.log = (level, message, server) => log(level, message, server);
|
|
14
|
+
this.services = services;
|
|
15
|
+
this.state = { name: config.name, directory: config.directory, status: 'stopped', running: false, results: [], deployMode: config.deployMode ?? 'auto' };
|
|
16
|
+
this.state.autoDeployCount = autoDeployCount;
|
|
17
|
+
this.state.instanceId = randomUUID();
|
|
18
|
+
this.state.deploySequence = 0;
|
|
19
|
+
this.state.completedReports = [];
|
|
20
|
+
this.onAutoDeploy = onAutoDeploy;
|
|
21
|
+
this.force = false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Start once; automatic mode publishes stable changes, manual mode waits for trigger(). */
|
|
25
|
+
start() {
|
|
26
|
+
if (this.state.running) return;
|
|
27
|
+
this.controller = new AbortController();
|
|
28
|
+
this.state.running = true;
|
|
29
|
+
this.state.status = this.config.deployMode === 'manual' ? 'idle' : 'watching';
|
|
30
|
+
this.state.startedAt = new Date().toISOString();
|
|
31
|
+
this.loopPromise = this.loop().catch((error) => {
|
|
32
|
+
this.state.status = 'error';
|
|
33
|
+
this.log('error', `Task loop failed: ${error.message}`);
|
|
34
|
+
}).finally(() => { this.state.running = false; });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Cancel transfers and backoff, then wait until remote cleanup has finished. */
|
|
38
|
+
async stop() {
|
|
39
|
+
this.controller?.abort();
|
|
40
|
+
await this.loopPromise;
|
|
41
|
+
this.state.running = false;
|
|
42
|
+
this.state.status = 'stopped';
|
|
43
|
+
this.log('info', 'Task stopped');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Queue a publication; concurrent requests coalesce into one follow-up deployment. */
|
|
47
|
+
trigger() {
|
|
48
|
+
if (!this.state.running) throw new Error('Task is stopped; restart it first');
|
|
49
|
+
this.force = true;
|
|
50
|
+
return { sequence: this.state.deploySequence + 1, instanceId: this.state.instanceId };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Apply persisted mode without restarting or interrupting a publication already in progress. */
|
|
54
|
+
setDeployMode(mode) {
|
|
55
|
+
this.config.deployMode = mode;
|
|
56
|
+
this.state.deployMode = mode;
|
|
57
|
+
if (this.state.running && ['watching', 'idle'].includes(this.state.status)) this.state.status = mode === 'manual' ? 'idle' : 'watching';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One loop per task prevents overlapping snapshots, publications and source scan races. */
|
|
61
|
+
async loop() {
|
|
62
|
+
const signal = this.controller.signal;
|
|
63
|
+
let observed;
|
|
64
|
+
let changedAt = Date.now();
|
|
65
|
+
let pendingChange;
|
|
66
|
+
let attempted;
|
|
67
|
+
while (!signal.aborted) {
|
|
68
|
+
try {
|
|
69
|
+
if (this.config.deployMode === 'manual' && !this.force) {
|
|
70
|
+
await delay(this.config.intervalMs, undefined, { signal });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const current = await this.services.scan(this.config);
|
|
74
|
+
if (signal.aborted) break;
|
|
75
|
+
if (observed !== current.digest) {
|
|
76
|
+
observed = current.digest; changedAt = Date.now();
|
|
77
|
+
// Keep the first detection in a debounced batch rather than restarting its elapsed time.
|
|
78
|
+
pendingChange ??= { detectedAt: new Date().toISOString(), clock: performance.now() };
|
|
79
|
+
}
|
|
80
|
+
if (((this.config.deployMode !== 'manual' && attempted !== observed) || this.force) && Date.now() - changedAt >= this.config.debounceMs) {
|
|
81
|
+
// Count automatic rounds once before snapshotting; manual triggers and server retries are excluded.
|
|
82
|
+
if (!this.force) {
|
|
83
|
+
const count = this.state.autoDeployCount + 1;
|
|
84
|
+
this.onAutoDeploy?.(count);
|
|
85
|
+
this.state.autoDeployCount = count;
|
|
86
|
+
}
|
|
87
|
+
const detected = this.force ? undefined : pendingChange;
|
|
88
|
+
this.force = false;
|
|
89
|
+
let captured;
|
|
90
|
+
const startedAt = new Date().toISOString();
|
|
91
|
+
const started = performance.now();
|
|
92
|
+
const sequence = ++this.state.deploySequence;
|
|
93
|
+
this.state.progress = { phase: 'snapshot', servers: {} };
|
|
94
|
+
this.state.results = [];
|
|
95
|
+
this.state.summary = { name: this.config.name, directory: this.config.directory, startedAt, detectedAt: detected?.detectedAt, status: 'deploying' };
|
|
96
|
+
let failure;
|
|
97
|
+
let publishedAt;
|
|
98
|
+
let detectionToPublishMs;
|
|
99
|
+
this.state.status = 'deploying';
|
|
100
|
+
try {
|
|
101
|
+
captured = await this.services.snapshot(this.config);
|
|
102
|
+
this.state.summary.totalFiles = captured.totalFiles;
|
|
103
|
+
signal.throwIfAborted();
|
|
104
|
+
this.state.results = await this.services.deploy(this.config, captured, this.log, signal, undefined, (progress) => {
|
|
105
|
+
this.state.progress.phase = 'deploying';
|
|
106
|
+
this.state.progress.servers[progress.server] = progress;
|
|
107
|
+
});
|
|
108
|
+
if (this.state.results.length && this.state.results.every((result) => result.status === 'success')) {
|
|
109
|
+
publishedAt = new Date().toISOString();
|
|
110
|
+
if (detected) detectionToPublishMs = performance.now() - detected.clock;
|
|
111
|
+
}
|
|
112
|
+
pendingChange = undefined;
|
|
113
|
+
attempted = captured.digest;
|
|
114
|
+
this.state.lastDeployAt = new Date().toISOString();
|
|
115
|
+
this.state.status = this.state.results.every((result) => result.status === 'success') ? 'watching' : 'error';
|
|
116
|
+
if (this.state.status === 'watching') {
|
|
117
|
+
delete this.state.lastError;
|
|
118
|
+
if (this.config.deployMode === 'manual') this.state.status = 'idle';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
} catch (error) { failure = error.message; throw error; }
|
|
122
|
+
finally {
|
|
123
|
+
this.state.progress.phase = 'cleanup';
|
|
124
|
+
try { await captured?.cleanup(); }
|
|
125
|
+
catch (error) { failure ??= error.message; this.state.status = 'error'; this.state.lastError = error.message; }
|
|
126
|
+
this.state.summary = { name: this.config.name, directory: this.config.directory, startedAt, detectedAt: detected?.detectedAt, publishedAt, detectionToPublishMs, totalFiles: captured?.totalFiles ?? this.state.results[0]?.totalFiles, error: failure, endedAt: new Date().toISOString(), durationMs: performance.now() - started, status: this.state.results.length && this.state.results.every((r) => r.status === 'success') ? 'success' : signal.aborted ? 'canceled' : 'failed', results: this.state.results };
|
|
127
|
+
if (failure) this.state.summary.status = signal.aborted ? 'canceled' : 'failed';
|
|
128
|
+
this.log(this.state.summary.status === 'success' ? 'info' : 'error', `SHIPWATCH_REPORT ${JSON.stringify(this.state.summary)}`);
|
|
129
|
+
this.state.progress.phase = this.state.summary.status;
|
|
130
|
+
// A follower must observe its own queued round even if another publication starts quickly.
|
|
131
|
+
this.state.completedReports.push({ sequence, report: this.state.summary });
|
|
132
|
+
this.state.completedReports = this.state.completedReports.slice(-10);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (!signal.aborted) { this.state.status = 'error'; this.state.lastError = error.message; this.log('error', error.message); }
|
|
137
|
+
}
|
|
138
|
+
try { await delay(this.config.intervalMs, undefined, { signal }); }
|
|
139
|
+
catch { break; /* stop() interrupts the polling sleep. */ }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/terminal.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Shared terminal theme: approved cyan/blue/purple accents, ANSI fallbacks and plain-text output.
|
|
2
|
+
* Exports color helpers and notifications; never writes backgrounds or changes JSON output.
|
|
3
|
+
*/
|
|
4
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const palette = { cyan: [101, 220, 233], blue: [122, 171, 255], purple: [191, 155, 250], green: [139, 214, 165], yellow: [232, 199, 126], red: [239, 140, 157], muted: [135, 149, 170], border: [83, 97, 116] };
|
|
7
|
+
const basic = { cyan: 36, blue: 34, purple: 35, green: 32, yellow: 33, red: 31, muted: 90, border: 90 };
|
|
8
|
+
|
|
9
|
+
/** Respect output capabilities; redirected/dumb/NO_COLOR sessions get no decoration. */
|
|
10
|
+
export function colorLevel(stream = process.stdout, env = process.env) {
|
|
11
|
+
if (!stream.isTTY || Object.hasOwn(env, 'NO_COLOR') || env.TERM === 'dumb') return 0;
|
|
12
|
+
if (/truecolor|24bit/i.test(env.COLORTERM ?? '')) return 3;
|
|
13
|
+
return /256color/i.test(env.TERM ?? '') ? 2 : 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function cleanText(value) {
|
|
17
|
+
return stripVTControlCharacters(String(value ?? '')).replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rgbCode(rgb, level) {
|
|
21
|
+
if (level === 3) return `38;2;${rgb.join(';')}`;
|
|
22
|
+
const [r, g, b] = rgb.map((value) => Math.round(value / 255 * 5));
|
|
23
|
+
return `38;5;${16 + r * 36 + g * 6 + b}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Return a style code separately so tables can measure unstyled cell contents first. */
|
|
27
|
+
export function toneCode(tone, level = colorLevel(), bold = false) {
|
|
28
|
+
if (!level) return undefined;
|
|
29
|
+
return (bold ? '1;' : '') + (level > 1 ? rgbCode(palette[tone] ?? palette.cyan, level) : basic[tone] ?? 36);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function tint(value, tone = 'cyan', level = colorLevel(), bold = false) {
|
|
33
|
+
const code = toneCode(tone, level, bold);
|
|
34
|
+
return code ? `\u001b[${code}m${value}\u001b[0m` : String(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Gradient is foreground-only, matching the preview while respecting each user's terminal background. */
|
|
38
|
+
export function gradient(value, level = colorLevel()) {
|
|
39
|
+
const text = cleanText(value);
|
|
40
|
+
if (level < 2) return tint(text, 'cyan', level, true);
|
|
41
|
+
const chars = Array.from(text);
|
|
42
|
+
return chars.map((char, index) => {
|
|
43
|
+
const fraction = index / Math.max(1, chars.length - 1) * 2;
|
|
44
|
+
const from = fraction < 1 ? palette.cyan : palette.blue;
|
|
45
|
+
const to = fraction < 1 ? palette.blue : palette.purple;
|
|
46
|
+
const amount = fraction < 1 ? fraction : fraction - 1;
|
|
47
|
+
return `\u001b[${rgbCode(from.map((value, i) => Math.round(value + (to[i] - value) * amount)), level)}m${char}`;
|
|
48
|
+
}).join('') + '\u001b[0m';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const stateTone = (status) => ({ watching: 'green', idle: 'yellow', deploying: 'blue', success: 'green', stopped: 'muted', error: 'red', failed: 'red', canceled: 'yellow' })[status] ?? 'muted';
|
|
52
|
+
export const stateMark = (status) => ({ watching: '●', idle: '◌', deploying: '◈', success: '✓', stopped: '○', error: '×', failed: '×', canceled: '−' })[status] ?? '·';
|
|
53
|
+
export const brand = (level = colorLevel()) => level ? gradient('SHIPWATCH', level) + ' ' + tint('v0.1.0', 'muted', level) + '\n\n' : '';
|
|
54
|
+
|
|
55
|
+
/** Preserve message wording in pipes; interactive confirmations use only one status marker. */
|
|
56
|
+
export function notice(message, tone = 'green', stream = process.stdout) {
|
|
57
|
+
const level = colorLevel(stream);
|
|
58
|
+
return level ? tint(`${tone === 'red' ? '✗' : tone === 'yellow' ? '⚠' : '✓'} ${message}`, tone, level) : message;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stable project colors make mixed logs recognizable without storing any UI state. */
|
|
62
|
+
export function projectTone(name) {
|
|
63
|
+
let hash = 0;
|
|
64
|
+
for (const char of String(name)) hash = (hash * 31 + char.codePointAt(0)) >>> 0;
|
|
65
|
+
return ['cyan', 'blue', 'purple'][hash % 3];
|
|
66
|
+
}
|