@saws/cli 2.0.0-beta.3 → 2.0.0-beta.6
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/README.md +10 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/saws.js +2 -0
- package/dist/commands/dev/command.js +1 -1
- package/dist/commands/logs/command.d.ts +5 -0
- package/dist/commands/logs/command.js +19 -0
- package/dist/commands/logs/index.d.ts +2 -0
- package/dist/commands/logs/index.js +7 -0
- package/package.json +9 -2
- package/src/bin/saws.ts +0 -59
- package/src/commands/deploy/command.ts +0 -20
- package/src/commands/deploy/index.ts +0 -8
- package/src/commands/dev/command.ts +0 -68
- package/src/commands/dev/index.ts +0 -5
- package/src/commands/dev/tui/dev-tui.ts +0 -471
- package/src/commands/execute/command.ts.tmp +0 -43
- package/src/commands/execute/index.ts.tmp +0 -9
- package/src/commands/host/command.ts +0 -188
- package/src/commands/host/index.ts +0 -18
- package/src/commands/init/command.ts +0 -28
- package/src/commands/init/index.ts +0 -8
- package/src/commands/init/templates/gitignore.template.ts +0 -4
- package/src/commands/init/templates/saws-ts.template.ts +0 -8
- package/src/commands/init/templates/tsconfig-json.template.ts +0 -4
- package/src/commands/secrets/command.ts +0 -42
- package/src/commands/secrets/index.ts +0 -13
- package/src/hosts.ts +0 -21
- package/tsconfig.json +0 -9
|
@@ -1,471 +0,0 @@
|
|
|
1
|
-
import readline from "node:readline";
|
|
2
|
-
|
|
3
|
-
const CLEAR_SCREEN = "\x1b[2J";
|
|
4
|
-
const CLEAR_LINE = "\x1b[2K";
|
|
5
|
-
const CURSOR_HOME = "\x1b[H";
|
|
6
|
-
const HIDE_CURSOR = "\x1b[?25l";
|
|
7
|
-
const SHOW_CURSOR = "\x1b[?25h";
|
|
8
|
-
const ENTER_ALT_SCREEN = "\x1b[?1049h";
|
|
9
|
-
const EXIT_ALT_SCREEN = "\x1b[?1049l";
|
|
10
|
-
const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1006h";
|
|
11
|
-
const DISABLE_MOUSE = "\x1b[?1000l\x1b[?1006l";
|
|
12
|
-
const RESET = "\x1b[0m";
|
|
13
|
-
const INVERSE = "\x1b[7m";
|
|
14
|
-
const DIM = "\x1b[2m";
|
|
15
|
-
const CYAN = "\x1b[36m";
|
|
16
|
-
const WHEEL_SCROLL_LINES = 3;
|
|
17
|
-
|
|
18
|
-
export interface RuntimeLogEntry {
|
|
19
|
-
serviceName: string;
|
|
20
|
-
stream: "stdout" | "stderr";
|
|
21
|
-
chunk: string;
|
|
22
|
-
timestamp: Date;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export type RuntimeLogSink = (entry: RuntimeLogEntry) => void;
|
|
26
|
-
|
|
27
|
-
interface ServiceLog {
|
|
28
|
-
lines: string[];
|
|
29
|
-
partial: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export class DevTui {
|
|
33
|
-
readonly logSink: RuntimeLogSink = (entry) => this.addLog(entry);
|
|
34
|
-
|
|
35
|
-
private readonly services: string[];
|
|
36
|
-
private readonly logs = new Map<string, ServiceLog>();
|
|
37
|
-
private readonly logScrollOffsets = new Map<string, number>();
|
|
38
|
-
private selectedIndex = 0;
|
|
39
|
-
private isStarted = false;
|
|
40
|
-
private isSelectionMode = false;
|
|
41
|
-
private previousRawMode = false;
|
|
42
|
-
private readonly maxLines = 2000;
|
|
43
|
-
|
|
44
|
-
constructor(services: string[]) {
|
|
45
|
-
this.services = [...new Set(services)];
|
|
46
|
-
|
|
47
|
-
for (const service of this.services) {
|
|
48
|
-
this.logs.set(service, { lines: [], partial: "" });
|
|
49
|
-
this.logScrollOffsets.set(service, 0);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
start() {
|
|
54
|
-
if (this.isStarted || !process.stdout.isTTY || !process.stdin.isTTY) {
|
|
55
|
-
this.isStarted = process.stdout.isTTY && process.stdin.isTTY;
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
this.isStarted = true;
|
|
60
|
-
this.previousRawMode = process.stdin.isRaw;
|
|
61
|
-
|
|
62
|
-
process.stdout.write(`${ENTER_ALT_SCREEN}${HIDE_CURSOR}${ENABLE_MOUSE}`);
|
|
63
|
-
readline.emitKeypressEvents(process.stdin);
|
|
64
|
-
process.stdin.setRawMode(true);
|
|
65
|
-
process.stdin.resume();
|
|
66
|
-
process.stdin.on("data", this.handleInput);
|
|
67
|
-
process.stdin.on("keypress", this.handleKeypress);
|
|
68
|
-
process.stdout.on("resize", this.render);
|
|
69
|
-
this.render();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
stop() {
|
|
73
|
-
if (!this.isStarted) return;
|
|
74
|
-
|
|
75
|
-
process.stdin.off("data", this.handleInput);
|
|
76
|
-
process.stdin.off("keypress", this.handleKeypress);
|
|
77
|
-
process.stdout.off("resize", this.render);
|
|
78
|
-
this.isSelectionMode = false;
|
|
79
|
-
process.stdin.setRawMode(this.previousRawMode);
|
|
80
|
-
process.stdout.write(`${DISABLE_MOUSE}${SHOW_CURSOR}${EXIT_ALT_SCREEN}${RESET}`);
|
|
81
|
-
this.isStarted = false;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private handleKeypress = (_input: string, key: readline.Key) => {
|
|
85
|
-
if (key.ctrl && key.name === "c") {
|
|
86
|
-
process.kill(process.pid, "SIGINT");
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (this.isSelectionMode) {
|
|
91
|
-
if (key.name === "escape" || key.name === "c") {
|
|
92
|
-
this.exitSelectionMode();
|
|
93
|
-
}
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (key.name === "c") {
|
|
98
|
-
this.enterSelectionMode();
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (key.name === "up" || key.name === "k") {
|
|
103
|
-
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
104
|
-
this.render();
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (key.name === "down" || key.name === "j") {
|
|
109
|
-
this.selectedIndex = Math.min(this.services.length - 1, this.selectedIndex + 1);
|
|
110
|
-
this.render();
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (key.name === "pageup" || key.name === "u") {
|
|
115
|
-
this.scrollSelectedLog(this.getBodyHeight());
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (key.name === "pagedown" || key.name === "d") {
|
|
120
|
-
this.scrollSelectedLog(-this.getBodyHeight());
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (key.name === "home" || key.name === "g") {
|
|
125
|
-
this.scrollSelectedLog(Number.POSITIVE_INFINITY);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (key.name === "end" || (key.shift && key.name === "g")) {
|
|
130
|
-
this.logScrollOffsets.set(this.getSelectedService(), 0);
|
|
131
|
-
this.render();
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (key.name === "q") {
|
|
136
|
-
process.kill(process.pid, "SIGTERM");
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
private handleInput = (chunk: Buffer | string) => {
|
|
141
|
-
if (this.isSelectionMode) return;
|
|
142
|
-
|
|
143
|
-
const input = chunk.toString();
|
|
144
|
-
for (const event of parseMouseEvents(input)) {
|
|
145
|
-
if (event.type === "wheel-up") {
|
|
146
|
-
this.scrollSelectedLog(WHEEL_SCROLL_LINES);
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (event.type === "wheel-down") {
|
|
151
|
-
this.scrollSelectedLog(-WHEEL_SCROLL_LINES);
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (event.type === "press") {
|
|
156
|
-
this.selectServiceAt(event.x, event.y);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
private addLog(entry: RuntimeLogEntry) {
|
|
162
|
-
if (entry.serviceName === "system") return;
|
|
163
|
-
|
|
164
|
-
const serviceName = entry.serviceName;
|
|
165
|
-
if (!this.logs.has(serviceName)) {
|
|
166
|
-
this.services.push(serviceName);
|
|
167
|
-
this.logs.set(serviceName, { lines: [], partial: "" });
|
|
168
|
-
this.logScrollOffsets.set(serviceName, 0);
|
|
169
|
-
this.selectedIndex = this.services.length - 1;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const log = this.logs.get(serviceName) ?? { lines: [], partial: "" };
|
|
173
|
-
const pieces = `${log.partial}${sanitizeLogChunk(entry.chunk)}`.split("\n");
|
|
174
|
-
log.partial = pieces.pop() ?? "";
|
|
175
|
-
|
|
176
|
-
for (const piece of pieces) {
|
|
177
|
-
log.lines.push(
|
|
178
|
-
`${timestamp(entry.timestamp)} ${entry.stream === "stderr" ? "[err] " : ""}${piece}`,
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (log.lines.length > this.maxLines) {
|
|
183
|
-
log.lines.splice(0, log.lines.length - this.maxLines);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
this.logs.set(serviceName, log);
|
|
187
|
-
this.clampLogScrollOffset(serviceName);
|
|
188
|
-
this.render();
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
private render = (force = false) => {
|
|
192
|
-
if (!this.isStarted || !process.stdout.isTTY || (this.isSelectionMode && !force)) return;
|
|
193
|
-
|
|
194
|
-
const width = process.stdout.columns ?? 80;
|
|
195
|
-
const navWidth = getNavWidth(width);
|
|
196
|
-
const logWidth = Math.max(10, width - navWidth - 1);
|
|
197
|
-
const bodyHeight = this.getBodyHeight();
|
|
198
|
-
|
|
199
|
-
const selectedService = this.getSelectedService();
|
|
200
|
-
const log = this.logs.get(selectedService) ?? { lines: [], partial: "" };
|
|
201
|
-
const visibleLines = [...log.lines, log.partial].filter(Boolean);
|
|
202
|
-
const wrappedLogLines = visibleLines.flatMap((line) => wrapLine(line, logWidth));
|
|
203
|
-
const logScrollOffset = this.clampLogScrollOffset(selectedService, wrappedLogLines.length);
|
|
204
|
-
const logEnd = Math.max(bodyHeight, wrappedLogLines.length - logScrollOffset);
|
|
205
|
-
const logStart = Math.max(0, logEnd - bodyHeight);
|
|
206
|
-
const logLines = wrappedLogLines.slice(logStart, logEnd);
|
|
207
|
-
|
|
208
|
-
const rows: string[] = [];
|
|
209
|
-
rows.push(`${CYAN}SAWS dev${RESET}${DIM} ${this.getHelpText()}${RESET}`.padEnd(width));
|
|
210
|
-
rows.push(
|
|
211
|
-
`${"Services".padEnd(navWidth)} ${logHeading(selectedService, logScrollOffset).padEnd(logWidth)}`,
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
for (let index = 0; index < bodyHeight; index += 1) {
|
|
215
|
-
const service = this.services[index] ?? "";
|
|
216
|
-
const isSelected = index === this.selectedIndex;
|
|
217
|
-
const serviceLabel = service === "" ? "" : ` ${service}`;
|
|
218
|
-
const nav = truncate(serviceLabel, navWidth).padEnd(navWidth);
|
|
219
|
-
const logLine = truncate(logLines[index] ?? "", logWidth).padEnd(logWidth);
|
|
220
|
-
rows.push(`${isSelected ? `${INVERSE}${nav}${RESET}` : nav} ${logLine}`);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
process.stdout.write(renderRows(rows, width, process.stdout.rows ?? 24));
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
private enterSelectionMode() {
|
|
227
|
-
this.isSelectionMode = true;
|
|
228
|
-
process.stdout.write(`${DISABLE_MOUSE}${SHOW_CURSOR}`);
|
|
229
|
-
this.render(true);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
private exitSelectionMode() {
|
|
233
|
-
this.isSelectionMode = false;
|
|
234
|
-
process.stdout.write(`${HIDE_CURSOR}${ENABLE_MOUSE}`);
|
|
235
|
-
this.render(true);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
private getHelpText() {
|
|
239
|
-
if (this.isSelectionMode) return "select/copy text with terminal, esc resume";
|
|
240
|
-
return "up/down select, pgup/pgdn scroll, c copy/select, q quit";
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
private getSelectedService() {
|
|
244
|
-
return this.services[this.selectedIndex] ?? "";
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private getBodyHeight() {
|
|
248
|
-
return Math.max(1, (process.stdout.rows ?? 24) - 3);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
private scrollSelectedLog(delta: number) {
|
|
252
|
-
const selectedService = this.getSelectedService();
|
|
253
|
-
const current = this.logScrollOffsets.get(selectedService) ?? 0;
|
|
254
|
-
this.logScrollOffsets.set(selectedService, current + delta);
|
|
255
|
-
this.render();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
private selectServiceAt(x: number, y: number) {
|
|
259
|
-
const navWidth = getNavWidth(process.stdout.columns ?? 80);
|
|
260
|
-
if (x > navWidth || y < 3) return;
|
|
261
|
-
|
|
262
|
-
const serviceIndex = y - 3;
|
|
263
|
-
if (serviceIndex < 0 || serviceIndex >= this.services.length) return;
|
|
264
|
-
|
|
265
|
-
this.selectedIndex = serviceIndex;
|
|
266
|
-
this.render();
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
private clampLogScrollOffset(serviceName: string, wrappedLineCount?: number) {
|
|
270
|
-
const width = process.stdout.columns ?? 80;
|
|
271
|
-
const navWidth = getNavWidth(width);
|
|
272
|
-
const logWidth = Math.max(10, width - navWidth - 1);
|
|
273
|
-
const log = this.logs.get(serviceName) ?? { lines: [], partial: "" };
|
|
274
|
-
const lineCount =
|
|
275
|
-
wrappedLineCount ??
|
|
276
|
-
[...log.lines, log.partial].filter(Boolean).flatMap((line) => wrapLine(line, logWidth))
|
|
277
|
-
.length;
|
|
278
|
-
const maxOffset = Math.max(0, lineCount - this.getBodyHeight());
|
|
279
|
-
const nextOffset = Math.min(
|
|
280
|
-
maxOffset,
|
|
281
|
-
Math.max(0, this.logScrollOffsets.get(serviceName) ?? 0),
|
|
282
|
-
);
|
|
283
|
-
this.logScrollOffsets.set(serviceName, nextOffset);
|
|
284
|
-
return nextOffset;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function logHeading(serviceName: string, scrollOffset: number) {
|
|
289
|
-
if (scrollOffset === 0) return `Logs: ${serviceName}`;
|
|
290
|
-
return `Logs: ${serviceName} (${scrollOffset} lines from bottom)`;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function renderRows(rows: string[], width: number, height: number) {
|
|
294
|
-
const output: string[] = [CURSOR_HOME, CLEAR_SCREEN];
|
|
295
|
-
|
|
296
|
-
for (let index = 0; index < height; index += 1) {
|
|
297
|
-
output.push(cursorPosition(index + 1, 1));
|
|
298
|
-
output.push(CLEAR_LINE);
|
|
299
|
-
output.push(truncate(rows[index] ?? "", width).padEnd(width));
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return output.join("");
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function cursorPosition(row: number, column: number) {
|
|
306
|
-
return `\x1b[${row};${column}H`;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function getNavWidth(terminalWidth: number) {
|
|
310
|
-
return Math.min(30, Math.max(18, Math.floor(terminalWidth * 0.28)));
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
type MouseEvent =
|
|
314
|
-
| { type: "wheel-up"; x: number; y: number }
|
|
315
|
-
| { type: "wheel-down"; x: number; y: number }
|
|
316
|
-
| { type: "press"; x: number; y: number };
|
|
317
|
-
|
|
318
|
-
function parseMouseEvents(input: string): MouseEvent[] {
|
|
319
|
-
const events: MouseEvent[] = [];
|
|
320
|
-
|
|
321
|
-
for (let index = 0; index < input.length; index += 1) {
|
|
322
|
-
if (input.charCodeAt(index) !== 27 || input[index + 1] !== "[" || input[index + 2] !== "<") {
|
|
323
|
-
continue;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const codeResult = readUnsignedNumber(input, index + 3);
|
|
327
|
-
if (codeResult == null || input[codeResult.nextIndex] !== ";") continue;
|
|
328
|
-
|
|
329
|
-
const xResult = readUnsignedNumber(input, codeResult.nextIndex + 1);
|
|
330
|
-
if (xResult == null || input[xResult.nextIndex] !== ";") continue;
|
|
331
|
-
|
|
332
|
-
const yResult = readUnsignedNumber(input, xResult.nextIndex + 1);
|
|
333
|
-
if (yResult == null) continue;
|
|
334
|
-
|
|
335
|
-
const action = input[yResult.nextIndex];
|
|
336
|
-
index = yResult.nextIndex;
|
|
337
|
-
|
|
338
|
-
if (action !== "M") continue;
|
|
339
|
-
|
|
340
|
-
if (codeResult.value >= 64 && codeResult.value <= 95) {
|
|
341
|
-
const wheelDirection = codeResult.value & 3;
|
|
342
|
-
if (wheelDirection === 0) {
|
|
343
|
-
events.push({ type: "wheel-up", x: xResult.value, y: yResult.value });
|
|
344
|
-
} else if (wheelDirection === 1) {
|
|
345
|
-
events.push({ type: "wheel-down", x: xResult.value, y: yResult.value });
|
|
346
|
-
}
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if ((codeResult.value & 3) === 0) {
|
|
351
|
-
events.push({ type: "press", x: xResult.value, y: yResult.value });
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return events;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function readUnsignedNumber(value: string, startIndex: number) {
|
|
359
|
-
let index = startIndex;
|
|
360
|
-
while (index < value.length && value[index] >= "0" && value[index] <= "9") {
|
|
361
|
-
index += 1;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (index === startIndex) return null;
|
|
365
|
-
return {
|
|
366
|
-
value: Number(value.slice(startIndex, index)),
|
|
367
|
-
nextIndex: index,
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function timestamp(date: Date) {
|
|
372
|
-
return date.toLocaleTimeString(undefined, {
|
|
373
|
-
hour12: false,
|
|
374
|
-
hour: "2-digit",
|
|
375
|
-
minute: "2-digit",
|
|
376
|
-
second: "2-digit",
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function wrapLine(line: string, width: number) {
|
|
381
|
-
const chunks: string[] = [];
|
|
382
|
-
let remaining = line;
|
|
383
|
-
|
|
384
|
-
while (visibleLength(remaining) > width) {
|
|
385
|
-
chunks.push(remaining.slice(0, width));
|
|
386
|
-
remaining = remaining.slice(width);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
chunks.push(remaining);
|
|
390
|
-
return chunks;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function truncate(value: string, width: number) {
|
|
394
|
-
if (visibleLength(value) <= width) return value;
|
|
395
|
-
if (width <= 3) return value.slice(0, width);
|
|
396
|
-
return `${value.slice(0, width - 3)}...`;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function visibleLength(value: string) {
|
|
400
|
-
return stripAnsiColor(value).length;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function sanitizeLogChunk(value: string) {
|
|
404
|
-
return stripAnsiControlSequences(value.replaceAll("\r\n", "\n").replaceAll("\r", "\n"));
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function stripAnsiControlSequences(value: string) {
|
|
408
|
-
let stripped = "";
|
|
409
|
-
|
|
410
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
411
|
-
const code = value.charCodeAt(index);
|
|
412
|
-
if (code === 27) {
|
|
413
|
-
index = skipEscapeSequence(value, index);
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if ((code >= 0 && code < 9) || (code > 13 && code < 32) || code === 127) {
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
stripped += value[index];
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
return stripped;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function skipEscapeSequence(value: string, startIndex: number) {
|
|
428
|
-
const next = value[startIndex + 1];
|
|
429
|
-
if (next === "[") {
|
|
430
|
-
let index = startIndex + 2;
|
|
431
|
-
while (index < value.length) {
|
|
432
|
-
const code = value.charCodeAt(index);
|
|
433
|
-
if (code >= 64 && code <= 126) return index;
|
|
434
|
-
index += 1;
|
|
435
|
-
}
|
|
436
|
-
return value.length - 1;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (next === "]") {
|
|
440
|
-
let index = startIndex + 2;
|
|
441
|
-
while (index < value.length) {
|
|
442
|
-
if (value.charCodeAt(index) === 7) return index;
|
|
443
|
-
if (value.charCodeAt(index) === 27 && value[index + 1] === "\\") return index + 1;
|
|
444
|
-
index += 1;
|
|
445
|
-
}
|
|
446
|
-
return value.length - 1;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
return Math.min(startIndex + 1, value.length - 1);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function stripAnsiColor(value: string) {
|
|
453
|
-
let stripped = "";
|
|
454
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
455
|
-
if (value.charCodeAt(index) !== 27) {
|
|
456
|
-
stripped += value[index];
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (value[index + 1] !== "[") {
|
|
461
|
-
continue;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
index += 2;
|
|
465
|
-
while (index < value.length && /[0-9;]/.test(value[index] ?? "")) {
|
|
466
|
-
index += 1;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
return stripped;
|
|
471
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { getSawsConfig } from "@saws/core";
|
|
2
|
-
import { BUILD_DIR } from "@saws/utils/constants";
|
|
3
|
-
import { getStageOutputs } from "@saws/utils/stage-outputs";
|
|
4
|
-
import { fork } from "child_process";
|
|
5
|
-
import esbuild from "esbuild";
|
|
6
|
-
import path from "path";
|
|
7
|
-
|
|
8
|
-
export const executeCommand = async (
|
|
9
|
-
scriptPath: string,
|
|
10
|
-
sawsPath: string,
|
|
11
|
-
{ stage = "local" }: { stage: string }
|
|
12
|
-
) => {
|
|
13
|
-
process.env.STAGE = stage;
|
|
14
|
-
|
|
15
|
-
const serviceDefinition = await getSawsConfig(sawsPath);
|
|
16
|
-
|
|
17
|
-
const stageOutputs = await getStageOutputs(stage);
|
|
18
|
-
const services = serviceDefinition.getAllDependencies()
|
|
19
|
-
let environment: Record<string, string> = {
|
|
20
|
-
NODE_ENV: stage === "local" ? "development" : "production",
|
|
21
|
-
STAGE: stage,
|
|
22
|
-
}
|
|
23
|
-
for (const service of services) {
|
|
24
|
-
await service.setOutputs(stageOutputs[service.name], stage)
|
|
25
|
-
environment = {
|
|
26
|
-
...environment,
|
|
27
|
-
...await(service.getEnvironmentVariables(stage))
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const outFile = path.join(BUILD_DIR, "script.js");
|
|
32
|
-
|
|
33
|
-
await esbuild.build({
|
|
34
|
-
entryPoints: [scriptPath],
|
|
35
|
-
bundle: true,
|
|
36
|
-
outfile: outFile,
|
|
37
|
-
platform: "node",
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
fork(outFile, {
|
|
41
|
-
env: environment,
|
|
42
|
-
})
|
|
43
|
-
};
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import { executeCommand } from "./command.js";
|
|
3
|
-
|
|
4
|
-
export const createCommand = () =>
|
|
5
|
-
new Command("execute")
|
|
6
|
-
.option("--stage <string>", "Stage")
|
|
7
|
-
.argument("<string>", "The path to the script to execute")
|
|
8
|
-
.argument("[string]", "The path to the saws file")
|
|
9
|
-
.action(executeCommand);
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { chmod, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
|
-
import {
|
|
7
|
-
Host,
|
|
8
|
-
ParameterNotFoundError,
|
|
9
|
-
SecretsManager,
|
|
10
|
-
ServiceDefinition,
|
|
11
|
-
getSawsConfigModule,
|
|
12
|
-
hostSshPublicKeyEnvName,
|
|
13
|
-
} from "@saws/core";
|
|
14
|
-
import { findConfiguredHosts } from "../../hosts.js";
|
|
15
|
-
|
|
16
|
-
export interface ConfigureHostCommandOptions {
|
|
17
|
-
config?: string;
|
|
18
|
-
dryRun?: boolean;
|
|
19
|
-
user: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function configureHostCommand(
|
|
23
|
-
name: string | undefined,
|
|
24
|
-
options: ConfigureHostCommandOptions,
|
|
25
|
-
) {
|
|
26
|
-
const config = await getSawsConfigModule(options.config);
|
|
27
|
-
if (!(config.secrets instanceof SecretsManager)) {
|
|
28
|
-
throw new Error('saws.ts must export a SecretsManager instance named "secrets"');
|
|
29
|
-
}
|
|
30
|
-
if (!(config.default instanceof ServiceDefinition)) {
|
|
31
|
-
throw new Error("saws.ts must default-export a ServiceDefinition");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const host = selectHost(findConfiguredHosts(config.default), name);
|
|
35
|
-
validatePrivateKeyReference(host, config.secrets);
|
|
36
|
-
|
|
37
|
-
if (options.dryRun) {
|
|
38
|
-
await host.configure({
|
|
39
|
-
bootstrapUser: options.user,
|
|
40
|
-
deploymentPublicKey: "[redacted deployment public key]",
|
|
41
|
-
dryRun: true,
|
|
42
|
-
});
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const { privateKey, created } = await getOrCreateDeploymentKey(config.secrets, host);
|
|
47
|
-
const publicKey = await derivePublicKey(privateKey);
|
|
48
|
-
|
|
49
|
-
if (created) {
|
|
50
|
-
await config.secrets.global.set(host.sshPrivateKey!.name, privateKey);
|
|
51
|
-
}
|
|
52
|
-
await updatePublicKeyEnvironment(
|
|
53
|
-
config.secrets.rootDir,
|
|
54
|
-
hostSshPublicKeyEnvName(host.name),
|
|
55
|
-
publicKey,
|
|
56
|
-
);
|
|
57
|
-
await host.configure({
|
|
58
|
-
bootstrapUser: options.user,
|
|
59
|
-
deploymentPublicKey: publicKey,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function selectHost(hosts: Host[], name?: string) {
|
|
64
|
-
if (hosts.length === 0) {
|
|
65
|
-
throw new Error("No hosts are configured");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const host =
|
|
69
|
-
name == null
|
|
70
|
-
? hosts.length === 1
|
|
71
|
-
? hosts[0]
|
|
72
|
-
: undefined
|
|
73
|
-
: hosts.find((candidate) => candidate.name === name);
|
|
74
|
-
|
|
75
|
-
if (host != null) return host;
|
|
76
|
-
|
|
77
|
-
const available = hosts
|
|
78
|
-
.map((candidate) => candidate.name)
|
|
79
|
-
.sort()
|
|
80
|
-
.join(", ");
|
|
81
|
-
throw new Error(
|
|
82
|
-
name == null
|
|
83
|
-
? `Host name is required. Available hosts: ${available}`
|
|
84
|
-
: `Host "${name}" was not found. Available hosts: ${available}`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function validatePrivateKeyReference(host: Host, manager: SecretsManager) {
|
|
89
|
-
if (host.sshPrivateKey == null || host.sshPrivateKey.scope !== "global") {
|
|
90
|
-
throw new Error(
|
|
91
|
-
`Host "${host.name}" must configure sshPrivateKey with secrets.global.reference(...)`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (!host.sshPrivateKey.isManagedBy(manager)) {
|
|
96
|
-
throw new Error(
|
|
97
|
-
`Host "${host.name}" sshPrivateKey must use the SecretsManager exported as "secrets"`,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function getOrCreateDeploymentKey(manager: SecretsManager, host: Host) {
|
|
103
|
-
try {
|
|
104
|
-
return {
|
|
105
|
-
privateKey: await manager.global.get(host.sshPrivateKey!.name),
|
|
106
|
-
created: false,
|
|
107
|
-
};
|
|
108
|
-
} catch (error) {
|
|
109
|
-
if (!(error instanceof ParameterNotFoundError)) throw error;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
privateKey: await generatePrivateKey(),
|
|
114
|
-
created: true,
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function generatePrivateKey() {
|
|
119
|
-
return withTemporaryDirectory(async (directory) => {
|
|
120
|
-
const keyPath = path.join(directory, "id_ed25519");
|
|
121
|
-
await execFileAsync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", keyPath]);
|
|
122
|
-
return readFile(keyPath, "utf8");
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function derivePublicKey(privateKey: string) {
|
|
127
|
-
return withTemporaryDirectory(async (directory) => {
|
|
128
|
-
const keyPath = path.join(directory, "id_ed25519");
|
|
129
|
-
await writeFile(keyPath, privateKey, { mode: 0o600 });
|
|
130
|
-
const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", keyPath]);
|
|
131
|
-
const publicKey = stdout.trim();
|
|
132
|
-
if (publicKey.length === 0) {
|
|
133
|
-
throw new Error("Could not derive the deployment public key");
|
|
134
|
-
}
|
|
135
|
-
return publicKey;
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function withTemporaryDirectory<T>(callback: (directory: string) => Promise<T>) {
|
|
140
|
-
const directory = await mkdtemp(path.join(tmpdir(), "saws-key-"));
|
|
141
|
-
try {
|
|
142
|
-
return await callback(directory);
|
|
143
|
-
} finally {
|
|
144
|
-
await rm(directory, { recursive: true, force: true });
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async function updatePublicKeyEnvironment(
|
|
149
|
-
rootDir: string,
|
|
150
|
-
variableName: string,
|
|
151
|
-
publicKey: string,
|
|
152
|
-
) {
|
|
153
|
-
const envPath = path.resolve(rootDir, ".env");
|
|
154
|
-
let contents = "";
|
|
155
|
-
try {
|
|
156
|
-
contents = await readFile(envPath, "utf8");
|
|
157
|
-
} catch (error) {
|
|
158
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const matcher = new RegExp(`^(?:export\\s+)?${escapeRegExp(variableName)}\\s*=`);
|
|
162
|
-
const lines = contents.split(/\r?\n/);
|
|
163
|
-
const replacement = `${variableName}=${publicKey}`;
|
|
164
|
-
let replaced = false;
|
|
165
|
-
const updated = lines.flatMap((line) => {
|
|
166
|
-
if (!matcher.test(line)) return [line];
|
|
167
|
-
if (replaced) return [];
|
|
168
|
-
replaced = true;
|
|
169
|
-
return [replacement];
|
|
170
|
-
});
|
|
171
|
-
while (updated.at(-1) === "") updated.pop();
|
|
172
|
-
if (!replaced) updated.push(replacement);
|
|
173
|
-
|
|
174
|
-
const temporaryPath = `${envPath}.${process.pid}.tmp`;
|
|
175
|
-
try {
|
|
176
|
-
await writeFile(temporaryPath, `${updated.join("\n")}\n`, { mode: 0o600 });
|
|
177
|
-
await rename(temporaryPath, envPath);
|
|
178
|
-
await chmod(envPath, 0o600);
|
|
179
|
-
} finally {
|
|
180
|
-
await rm(temporaryPath, { force: true });
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function escapeRegExp(value: string) {
|
|
185
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const execFileAsync = promisify(execFile);
|