@saws/cli 2.0.0-beta.2 → 2.0.0-beta.21

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.
Files changed (83) hide show
  1. package/README.md +206 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/bin/saws.d.ts +0 -1
  4. package/dist/bin/saws.js +32 -40
  5. package/dist/commands/app/deploy.d.ts +4 -0
  6. package/dist/commands/app/deploy.js +20 -0
  7. package/dist/commands/app/files.d.ts +4 -0
  8. package/dist/commands/app/files.js +40 -0
  9. package/dist/commands/app/index.d.ts +2 -0
  10. package/dist/commands/app/index.js +22 -0
  11. package/dist/commands/app/install.d.ts +4 -0
  12. package/dist/commands/app/install.js +35 -0
  13. package/dist/commands/app/list.d.ts +1 -0
  14. package/dist/commands/app/list.js +67 -0
  15. package/dist/commands/app/metadata.d.ts +2 -0
  16. package/dist/commands/app/metadata.js +27 -0
  17. package/dist/commands/app/paths.d.ts +2 -0
  18. package/dist/commands/app/paths.js +11 -0
  19. package/dist/commands/app/process.d.ts +3 -0
  20. package/dist/commands/app/process.js +53 -0
  21. package/dist/commands/app/update.d.ts +1 -0
  22. package/dist/commands/app/update.js +62 -0
  23. package/dist/commands/deploy/command.d.ts +2 -7
  24. package/dist/commands/deploy/command.js +13 -19
  25. package/dist/commands/deploy/index.d.ts +0 -1
  26. package/dist/commands/deploy/index.js +1 -4
  27. package/dist/commands/dev/command.d.ts +1 -7
  28. package/dist/commands/dev/command.js +46 -48
  29. package/dist/commands/dev/index.d.ts +0 -1
  30. package/dist/commands/dev/index.js +1 -6
  31. package/dist/commands/dev/tui/dev-tui.d.ts +18 -3
  32. package/dist/commands/dev/tui/dev-tui.js +263 -29
  33. package/dist/commands/host/command.d.ts +3 -2
  34. package/dist/commands/host/command.js +112 -7
  35. package/dist/commands/host/create.d.ts +9 -0
  36. package/dist/commands/host/create.js +30 -0
  37. package/dist/commands/host/deployment-key.d.ts +2 -0
  38. package/dist/commands/host/deployment-key.js +40 -0
  39. package/dist/commands/host/import-key.d.ts +5 -0
  40. package/dist/commands/host/import-key.js +40 -0
  41. package/dist/commands/host/index.d.ts +0 -1
  42. package/dist/commands/host/index.js +25 -3
  43. package/dist/commands/init/command.d.ts +1 -7
  44. package/dist/commands/init/command.js +24 -15
  45. package/dist/commands/init/index.d.ts +0 -1
  46. package/dist/commands/init/index.js +1 -5
  47. package/dist/commands/init/templates/gitignore.template.d.ts +1 -0
  48. package/dist/commands/init/templates/gitignore.template.js +4 -0
  49. package/dist/commands/init/templates/saws-ts.template.d.ts +3 -0
  50. package/dist/commands/init/templates/saws-ts.template.js +7 -0
  51. package/dist/commands/init/templates/tsconfig-json.template.d.ts +1 -0
  52. package/dist/commands/init/templates/tsconfig-json.template.js +4 -0
  53. package/dist/commands/logs/command.d.ts +5 -0
  54. package/dist/commands/logs/command.js +19 -0
  55. package/dist/commands/logs/index.d.ts +2 -0
  56. package/dist/commands/logs/index.js +7 -0
  57. package/dist/commands/secrets/command.d.ts +2 -2
  58. package/dist/commands/secrets/command.js +31 -9
  59. package/dist/commands/secrets/index.d.ts +0 -1
  60. package/dist/commands/secrets/index.js +7 -5
  61. package/dist/hosts.d.ts +1 -3
  62. package/dist/hosts.js +11 -10
  63. package/docs/global-hosts-and-applications.md +307 -0
  64. package/package.json +13 -20
  65. package/dist/bin/saws.d.ts.map +0 -1
  66. package/dist/commands/deploy/command.d.ts.map +0 -1
  67. package/dist/commands/deploy/index.d.ts.map +0 -1
  68. package/dist/commands/dev/command.d.ts.map +0 -1
  69. package/dist/commands/dev/index.d.ts.map +0 -1
  70. package/dist/commands/dev/tui/dev-tui.d.ts.map +0 -1
  71. package/dist/commands/host/command.d.ts.map +0 -1
  72. package/dist/commands/host/command.test.d.ts +0 -2
  73. package/dist/commands/host/command.test.d.ts.map +0 -1
  74. package/dist/commands/host/command.test.js +0 -13
  75. package/dist/commands/host/index.d.ts.map +0 -1
  76. package/dist/commands/init/command.d.ts.map +0 -1
  77. package/dist/commands/init/command.test.d.ts +0 -2
  78. package/dist/commands/init/command.test.d.ts.map +0 -1
  79. package/dist/commands/init/command.test.js +0 -40
  80. package/dist/commands/init/index.d.ts.map +0 -1
  81. package/dist/commands/secrets/command.d.ts.map +0 -1
  82. package/dist/commands/secrets/index.d.ts.map +0 -1
  83. package/dist/hosts.d.ts.map +0 -1
@@ -1,26 +1,33 @@
1
1
  import readline from "node:readline";
2
2
  const CLEAR_SCREEN = "\x1b[2J";
3
+ const CLEAR_LINE = "\x1b[2K";
3
4
  const CURSOR_HOME = "\x1b[H";
4
5
  const HIDE_CURSOR = "\x1b[?25l";
5
6
  const SHOW_CURSOR = "\x1b[?25h";
6
7
  const ENTER_ALT_SCREEN = "\x1b[?1049h";
7
8
  const EXIT_ALT_SCREEN = "\x1b[?1049l";
9
+ const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1006h";
10
+ const DISABLE_MOUSE = "\x1b[?1000l\x1b[?1006l";
8
11
  const RESET = "\x1b[0m";
9
12
  const INVERSE = "\x1b[7m";
10
13
  const DIM = "\x1b[2m";
11
14
  const CYAN = "\x1b[36m";
15
+ const WHEEL_SCROLL_LINES = 3;
12
16
  export class DevTui {
13
17
  logSink = (entry) => this.addLog(entry);
14
18
  services;
15
19
  logs = new Map();
20
+ logScrollOffsets = new Map();
16
21
  selectedIndex = 0;
17
22
  isStarted = false;
23
+ isSelectionMode = false;
18
24
  previousRawMode = false;
19
25
  maxLines = 2000;
20
26
  constructor(services) {
21
- this.services = services.length === 0 ? ["system"] : services;
27
+ this.services = [...new Set(services)];
22
28
  for (const service of this.services) {
23
29
  this.logs.set(service, { lines: [], partial: "" });
30
+ this.logScrollOffsets.set(service, 0);
24
31
  }
25
32
  }
26
33
  start() {
@@ -30,10 +37,11 @@ export class DevTui {
30
37
  }
31
38
  this.isStarted = true;
32
39
  this.previousRawMode = process.stdin.isRaw;
33
- process.stdout.write(`${ENTER_ALT_SCREEN}${HIDE_CURSOR}`);
40
+ process.stdout.write(`${ENTER_ALT_SCREEN}${HIDE_CURSOR}${ENABLE_MOUSE}`);
34
41
  readline.emitKeypressEvents(process.stdin);
35
42
  process.stdin.setRawMode(true);
36
43
  process.stdin.resume();
44
+ process.stdin.on("data", this.handleInput);
37
45
  process.stdin.on("keypress", this.handleKeypress);
38
46
  process.stdout.on("resize", this.render);
39
47
  this.render();
@@ -41,21 +49,29 @@ export class DevTui {
41
49
  stop() {
42
50
  if (!this.isStarted)
43
51
  return;
52
+ process.stdin.off("data", this.handleInput);
44
53
  process.stdin.off("keypress", this.handleKeypress);
45
54
  process.stdout.off("resize", this.render);
55
+ this.isSelectionMode = false;
46
56
  process.stdin.setRawMode(this.previousRawMode);
47
- process.stdout.write(`${SHOW_CURSOR}${EXIT_ALT_SCREEN}${RESET}`);
57
+ process.stdout.write(`${DISABLE_MOUSE}${SHOW_CURSOR}${EXIT_ALT_SCREEN}${RESET}`);
48
58
  this.isStarted = false;
49
59
  }
50
- writeSystemLog(message, stream = "stdout") {
51
- this.addLog({
52
- serviceName: "system",
53
- stream,
54
- chunk: message.endsWith("\n") ? message : `${message}\n`,
55
- timestamp: new Date(),
56
- });
57
- }
58
60
  handleKeypress = (_input, key) => {
61
+ if (key.ctrl && key.name === "c") {
62
+ process.kill(process.pid, "SIGINT");
63
+ return;
64
+ }
65
+ if (this.isSelectionMode) {
66
+ if (key.name === "escape" || key.name === "c") {
67
+ this.exitSelectionMode();
68
+ }
69
+ return;
70
+ }
71
+ if (key.name === "c") {
72
+ this.enterSelectionMode();
73
+ return;
74
+ }
59
75
  if (key.name === "up" || key.name === "k") {
60
76
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
61
77
  this.render();
@@ -66,45 +82,86 @@ export class DevTui {
66
82
  this.render();
67
83
  return;
68
84
  }
69
- if (key.ctrl && key.name === "c") {
70
- process.kill(process.pid, "SIGINT");
85
+ if (key.name === "pageup" || key.name === "u") {
86
+ this.scrollSelectedLog(this.getBodyHeight());
87
+ return;
88
+ }
89
+ if (key.name === "pagedown" || key.name === "d") {
90
+ this.scrollSelectedLog(-this.getBodyHeight());
91
+ return;
92
+ }
93
+ if (key.name === "home" || key.name === "g") {
94
+ this.scrollSelectedLog(Number.POSITIVE_INFINITY);
95
+ return;
96
+ }
97
+ if (key.name === "end" || (key.shift && key.name === "g")) {
98
+ this.logScrollOffsets.set(this.getSelectedService(), 0);
99
+ this.render();
71
100
  return;
72
101
  }
73
102
  if (key.name === "q") {
74
103
  process.kill(process.pid, "SIGTERM");
75
104
  }
76
105
  };
106
+ handleInput = (chunk) => {
107
+ if (this.isSelectionMode)
108
+ return;
109
+ const input = chunk.toString();
110
+ for (const event of parseMouseEvents(input)) {
111
+ if (event.type === "wheel-up") {
112
+ this.scrollSelectedLog(WHEEL_SCROLL_LINES);
113
+ continue;
114
+ }
115
+ if (event.type === "wheel-down") {
116
+ this.scrollSelectedLog(-WHEEL_SCROLL_LINES);
117
+ continue;
118
+ }
119
+ if (event.type === "press") {
120
+ this.selectServiceAt(event.x, event.y);
121
+ }
122
+ }
123
+ };
77
124
  addLog(entry) {
78
- const serviceName = this.logs.has(entry.serviceName) ? entry.serviceName : "system";
125
+ if (entry.serviceName === "system")
126
+ return;
127
+ const serviceName = entry.serviceName;
128
+ if (!this.logs.has(serviceName)) {
129
+ this.services.push(serviceName);
130
+ this.logs.set(serviceName, { lines: [], partial: "" });
131
+ this.logScrollOffsets.set(serviceName, 0);
132
+ this.selectedIndex = this.services.length - 1;
133
+ }
79
134
  const log = this.logs.get(serviceName) ?? { lines: [], partial: "" };
80
- const pieces = `${log.partial}${entry.chunk}`.split(/\r?\n/);
135
+ const pieces = `${log.partial}${sanitizeLogChunk(entry.chunk)}`.split("\n");
81
136
  log.partial = pieces.pop() ?? "";
82
137
  for (const piece of pieces) {
83
- log.lines.push(`${timestamp(entry.timestamp)} ${piece}`);
138
+ log.lines.push(`${timestamp(entry.timestamp)} ${entry.stream === "stderr" ? "[err] " : ""}${piece}`);
84
139
  }
85
140
  if (log.lines.length > this.maxLines) {
86
141
  log.lines.splice(0, log.lines.length - this.maxLines);
87
142
  }
88
143
  this.logs.set(serviceName, log);
144
+ this.clampLogScrollOffset(serviceName);
89
145
  this.render();
90
146
  }
91
- render = () => {
92
- if (!this.isStarted || !process.stdout.isTTY)
147
+ render = (force = false) => {
148
+ if (!this.isStarted || !process.stdout.isTTY || (this.isSelectionMode && !force))
93
149
  return;
94
150
  const width = process.stdout.columns ?? 80;
95
- const height = process.stdout.rows ?? 24;
96
- const navWidth = Math.min(30, Math.max(18, Math.floor(width * 0.28)));
151
+ const navWidth = getNavWidth(width);
97
152
  const logWidth = Math.max(10, width - navWidth - 1);
98
- const bodyHeight = Math.max(1, height - 3);
99
- const selectedService = this.services[this.selectedIndex] ?? "system";
153
+ const bodyHeight = this.getBodyHeight();
154
+ const selectedService = this.getSelectedService();
100
155
  const log = this.logs.get(selectedService) ?? { lines: [], partial: "" };
101
156
  const visibleLines = [...log.lines, log.partial].filter(Boolean);
102
- const logLines = visibleLines
103
- .flatMap((line) => wrapLine(line, logWidth))
104
- .slice(-bodyHeight);
157
+ const wrappedLogLines = visibleLines.flatMap((line) => wrapLine(line, logWidth));
158
+ const logScrollOffset = this.clampLogScrollOffset(selectedService, wrappedLogLines.length);
159
+ const logEnd = Math.max(bodyHeight, wrappedLogLines.length - logScrollOffset);
160
+ const logStart = Math.max(0, logEnd - bodyHeight);
161
+ const logLines = wrappedLogLines.slice(logStart, logEnd);
105
162
  const rows = [];
106
- rows.push(`${CYAN}SAWS dev${RESET}${DIM} up/down select, q quit${RESET}`.padEnd(width));
107
- rows.push(`${"Services".padEnd(navWidth)} ${`Logs: ${selectedService}`.padEnd(logWidth)}`);
163
+ rows.push(`${CYAN}SAWS dev${RESET}${DIM} ${this.getHelpText()}${RESET}`.padEnd(width));
164
+ rows.push(`${"Services".padEnd(navWidth)} ${logHeading(selectedService, logScrollOffset).padEnd(logWidth)}`);
108
165
  for (let index = 0; index < bodyHeight; index += 1) {
109
166
  const service = this.services[index] ?? "";
110
167
  const isSelected = index === this.selectedIndex;
@@ -113,7 +170,124 @@ export class DevTui {
113
170
  const logLine = truncate(logLines[index] ?? "", logWidth).padEnd(logWidth);
114
171
  rows.push(`${isSelected ? `${INVERSE}${nav}${RESET}` : nav} ${logLine}`);
115
172
  }
116
- process.stdout.write(`${CURSOR_HOME}${CLEAR_SCREEN}${rows.join("\n")}`);
173
+ process.stdout.write(renderRows(rows, width, process.stdout.rows ?? 24));
174
+ };
175
+ enterSelectionMode() {
176
+ this.isSelectionMode = true;
177
+ process.stdout.write(`${DISABLE_MOUSE}${SHOW_CURSOR}`);
178
+ this.render(true);
179
+ }
180
+ exitSelectionMode() {
181
+ this.isSelectionMode = false;
182
+ process.stdout.write(`${HIDE_CURSOR}${ENABLE_MOUSE}`);
183
+ this.render(true);
184
+ }
185
+ getHelpText() {
186
+ if (this.isSelectionMode)
187
+ return "select/copy text with terminal, esc resume";
188
+ return "up/down select, pgup/pgdn scroll, c copy/select, q quit";
189
+ }
190
+ getSelectedService() {
191
+ return this.services[this.selectedIndex] ?? "";
192
+ }
193
+ getBodyHeight() {
194
+ return Math.max(1, (process.stdout.rows ?? 24) - 3);
195
+ }
196
+ scrollSelectedLog(delta) {
197
+ const selectedService = this.getSelectedService();
198
+ const current = this.logScrollOffsets.get(selectedService) ?? 0;
199
+ this.logScrollOffsets.set(selectedService, current + delta);
200
+ this.render();
201
+ }
202
+ selectServiceAt(x, y) {
203
+ const navWidth = getNavWidth(process.stdout.columns ?? 80);
204
+ if (x > navWidth || y < 3)
205
+ return;
206
+ const serviceIndex = y - 3;
207
+ if (serviceIndex < 0 || serviceIndex >= this.services.length)
208
+ return;
209
+ this.selectedIndex = serviceIndex;
210
+ this.render();
211
+ }
212
+ clampLogScrollOffset(serviceName, wrappedLineCount) {
213
+ const width = process.stdout.columns ?? 80;
214
+ const navWidth = getNavWidth(width);
215
+ const logWidth = Math.max(10, width - navWidth - 1);
216
+ const log = this.logs.get(serviceName) ?? { lines: [], partial: "" };
217
+ const lineCount = wrappedLineCount ??
218
+ [...log.lines, log.partial].filter(Boolean).flatMap((line) => wrapLine(line, logWidth))
219
+ .length;
220
+ const maxOffset = Math.max(0, lineCount - this.getBodyHeight());
221
+ const nextOffset = Math.min(maxOffset, Math.max(0, this.logScrollOffsets.get(serviceName) ?? 0));
222
+ this.logScrollOffsets.set(serviceName, nextOffset);
223
+ return nextOffset;
224
+ }
225
+ }
226
+ function logHeading(serviceName, scrollOffset) {
227
+ if (scrollOffset === 0)
228
+ return `Logs: ${serviceName}`;
229
+ return `Logs: ${serviceName} (${scrollOffset} lines from bottom)`;
230
+ }
231
+ function renderRows(rows, width, height) {
232
+ const output = [CURSOR_HOME, CLEAR_SCREEN];
233
+ for (let index = 0; index < height; index += 1) {
234
+ output.push(cursorPosition(index + 1, 1));
235
+ output.push(CLEAR_LINE);
236
+ output.push(truncate(rows[index] ?? "", width).padEnd(width));
237
+ }
238
+ return output.join("");
239
+ }
240
+ function cursorPosition(row, column) {
241
+ return `\x1b[${row};${column}H`;
242
+ }
243
+ function getNavWidth(terminalWidth) {
244
+ return Math.min(30, Math.max(18, Math.floor(terminalWidth * 0.28)));
245
+ }
246
+ function parseMouseEvents(input) {
247
+ const events = [];
248
+ for (let index = 0; index < input.length; index += 1) {
249
+ if (input.charCodeAt(index) !== 27 || input[index + 1] !== "[" || input[index + 2] !== "<") {
250
+ continue;
251
+ }
252
+ const codeResult = readUnsignedNumber(input, index + 3);
253
+ if (codeResult == null || input[codeResult.nextIndex] !== ";")
254
+ continue;
255
+ const xResult = readUnsignedNumber(input, codeResult.nextIndex + 1);
256
+ if (xResult == null || input[xResult.nextIndex] !== ";")
257
+ continue;
258
+ const yResult = readUnsignedNumber(input, xResult.nextIndex + 1);
259
+ if (yResult == null)
260
+ continue;
261
+ const action = input[yResult.nextIndex];
262
+ index = yResult.nextIndex;
263
+ if (action !== "M")
264
+ continue;
265
+ if (codeResult.value >= 64 && codeResult.value <= 95) {
266
+ const wheelDirection = codeResult.value & 3;
267
+ if (wheelDirection === 0) {
268
+ events.push({ type: "wheel-up", x: xResult.value, y: yResult.value });
269
+ }
270
+ else if (wheelDirection === 1) {
271
+ events.push({ type: "wheel-down", x: xResult.value, y: yResult.value });
272
+ }
273
+ continue;
274
+ }
275
+ if ((codeResult.value & 3) === 0) {
276
+ events.push({ type: "press", x: xResult.value, y: yResult.value });
277
+ }
278
+ }
279
+ return events;
280
+ }
281
+ function readUnsignedNumber(value, startIndex) {
282
+ let index = startIndex;
283
+ while (index < value.length && value[index] >= "0" && value[index] <= "9") {
284
+ index += 1;
285
+ }
286
+ if (index === startIndex)
287
+ return null;
288
+ return {
289
+ value: Number(value.slice(startIndex, index)),
290
+ nextIndex: index,
117
291
  };
118
292
  }
119
293
  function timestamp(date) {
@@ -142,5 +316,65 @@ function truncate(value, width) {
142
316
  return `${value.slice(0, width - 3)}...`;
143
317
  }
144
318
  function visibleLength(value) {
145
- return value.replace(/\x1b\[[0-9;]*m/g, "").length;
319
+ return stripAnsiColor(value).length;
320
+ }
321
+ function sanitizeLogChunk(value) {
322
+ return stripAnsiControlSequences(value.replaceAll("\r\n", "\n").replaceAll("\r", "\n"));
323
+ }
324
+ function stripAnsiControlSequences(value) {
325
+ let stripped = "";
326
+ for (let index = 0; index < value.length; index += 1) {
327
+ const code = value.charCodeAt(index);
328
+ if (code === 27) {
329
+ index = skipEscapeSequence(value, index);
330
+ continue;
331
+ }
332
+ if ((code >= 0 && code < 9) || (code > 13 && code < 32) || code === 127) {
333
+ continue;
334
+ }
335
+ stripped += value[index];
336
+ }
337
+ return stripped;
338
+ }
339
+ function skipEscapeSequence(value, startIndex) {
340
+ const next = value[startIndex + 1];
341
+ if (next === "[") {
342
+ let index = startIndex + 2;
343
+ while (index < value.length) {
344
+ const code = value.charCodeAt(index);
345
+ if (code >= 64 && code <= 126)
346
+ return index;
347
+ index += 1;
348
+ }
349
+ return value.length - 1;
350
+ }
351
+ if (next === "]") {
352
+ let index = startIndex + 2;
353
+ while (index < value.length) {
354
+ if (value.charCodeAt(index) === 7)
355
+ return index;
356
+ if (value.charCodeAt(index) === 27 && value[index + 1] === "\\")
357
+ return index + 1;
358
+ index += 1;
359
+ }
360
+ return value.length - 1;
361
+ }
362
+ return Math.min(startIndex + 1, value.length - 1);
363
+ }
364
+ function stripAnsiColor(value) {
365
+ let stripped = "";
366
+ for (let index = 0; index < value.length; index += 1) {
367
+ if (value.charCodeAt(index) !== 27) {
368
+ stripped += value[index];
369
+ continue;
370
+ }
371
+ if (value[index + 1] !== "[") {
372
+ continue;
373
+ }
374
+ index += 2;
375
+ while (index < value.length && /[0-9;]/.test(value[index] ?? "")) {
376
+ index += 1;
377
+ }
378
+ }
379
+ return stripped;
146
380
  }
@@ -1,8 +1,9 @@
1
- import type { Host } from "@saws/host";
1
+ import { Host } from "@saws/core";
2
2
  export interface ConfigureHostCommandOptions {
3
3
  config?: string;
4
4
  dryRun?: boolean;
5
+ global?: boolean;
6
+ user: string;
5
7
  }
6
8
  export declare function configureHostCommand(name: string | undefined, options: ConfigureHostCommandOptions): Promise<void>;
7
9
  export declare function selectHost(hosts: Host[], name?: string): Host;
8
- //# sourceMappingURL=command.d.ts.map
@@ -1,14 +1,53 @@
1
- import { getSawsConfig } from "@saws/core";
1
+ import { chmod, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ParameterNotFoundError, SecretsManager, ServiceDefinition, getGlobalHost, getGlobalHostSecretsManager, getSawsConfigModule, hostSshPublicKeyEnvName, } from "@saws/core";
2
4
  import { findConfiguredHosts } from "../../hosts.js";
5
+ import { derivePublicKey, generatePrivateKey } from "./deployment-key.js";
3
6
  export async function configureHostCommand(name, options) {
4
- const root = await getSawsConfig(options.config);
5
- const hosts = findConfiguredHosts(root);
6
- const host = selectHost(hosts, name);
7
- await host.configure({ dryRun: options.dryRun });
7
+ if (options.global) {
8
+ if (options.config != null) {
9
+ throw new Error("host configure accepts only one of --global or --config <path>");
10
+ }
11
+ if (name == null) {
12
+ throw new Error("host configure --global requires a host name");
13
+ }
14
+ await configureSelectedHost(getGlobalHost(name), getGlobalHostSecretsManager(), options);
15
+ return;
16
+ }
17
+ const config = await getSawsConfigModule(options.config);
18
+ if (!(config.secrets instanceof SecretsManager)) {
19
+ throw new Error('saws.ts must export a SecretsManager instance named "secrets"');
20
+ }
21
+ if (!(config.default instanceof ServiceDefinition)) {
22
+ throw new Error("saws.ts must default-export a ServiceDefinition");
23
+ }
24
+ const host = selectHost(findConfiguredHosts(config.default), name);
25
+ await configureSelectedHost(host, config.secrets, options);
26
+ }
27
+ async function configureSelectedHost(host, manager, options) {
28
+ validatePrivateKeyReference(host, manager);
29
+ if (options.dryRun) {
30
+ await host.configure({
31
+ bootstrapUser: options.user,
32
+ deploymentPublicKey: "[redacted deployment public key]",
33
+ dryRun: true,
34
+ });
35
+ return;
36
+ }
37
+ const { privateKey, created } = await getOrCreateDeploymentKey(manager, host);
38
+ const publicKey = await derivePublicKey(privateKey);
39
+ if (created) {
40
+ await manager.global.set(host.sshPrivateKey.name, privateKey);
41
+ }
42
+ await updatePublicKeyEnvironment(manager.rootDir, hostSshPublicKeyEnvName(host.name), publicKey);
43
+ await host.configure({
44
+ bootstrapUser: options.user,
45
+ deploymentPublicKey: publicKey,
46
+ });
8
47
  }
9
48
  export function selectHost(hosts, name) {
10
49
  if (hosts.length === 0) {
11
- throw new Error("No Docker hosts are configured");
50
+ throw new Error("No hosts are configured");
12
51
  }
13
52
  const host = name == null
14
53
  ? hosts.length === 1
@@ -17,8 +56,74 @@ export function selectHost(hosts, name) {
17
56
  : hosts.find((candidate) => candidate.name === name);
18
57
  if (host != null)
19
58
  return host;
20
- const available = hosts.map((candidate) => candidate.name).sort().join(", ");
59
+ const available = hosts
60
+ .map((candidate) => candidate.name)
61
+ .sort()
62
+ .join(", ");
21
63
  throw new Error(name == null
22
64
  ? `Host name is required. Available hosts: ${available}`
23
65
  : `Host "${name}" was not found. Available hosts: ${available}`);
24
66
  }
67
+ function validatePrivateKeyReference(host, manager) {
68
+ if (host.sshPrivateKey == null || host.sshPrivateKey.scope !== "global") {
69
+ throw new Error(`Host "${host.name}" must configure sshPrivateKey with secrets.global.reference(...)`);
70
+ }
71
+ if (!host.sshPrivateKey.isManagedBy(manager)) {
72
+ throw new Error(`Host "${host.name}" sshPrivateKey must use the SecretsManager exported as "secrets"`);
73
+ }
74
+ }
75
+ async function getOrCreateDeploymentKey(manager, host) {
76
+ try {
77
+ return {
78
+ privateKey: await manager.global.get(host.sshPrivateKey.name),
79
+ created: false,
80
+ };
81
+ }
82
+ catch (error) {
83
+ if (!(error instanceof ParameterNotFoundError))
84
+ throw error;
85
+ }
86
+ return {
87
+ privateKey: await generatePrivateKey(),
88
+ created: true,
89
+ };
90
+ }
91
+ async function updatePublicKeyEnvironment(rootDir, variableName, publicKey) {
92
+ const envPath = path.resolve(rootDir, ".env");
93
+ let contents = "";
94
+ try {
95
+ contents = await readFile(envPath, "utf8");
96
+ }
97
+ catch (error) {
98
+ if (error.code !== "ENOENT")
99
+ throw error;
100
+ }
101
+ const matcher = new RegExp(`^(?:export\\s+)?${escapeRegExp(variableName)}\\s*=`);
102
+ const lines = contents.split(/\r?\n/);
103
+ const replacement = `${variableName}=${publicKey}`;
104
+ let replaced = false;
105
+ const updated = lines.flatMap((line) => {
106
+ if (!matcher.test(line))
107
+ return [line];
108
+ if (replaced)
109
+ return [];
110
+ replaced = true;
111
+ return [replacement];
112
+ });
113
+ while (updated.at(-1) === "")
114
+ updated.pop();
115
+ if (!replaced)
116
+ updated.push(replacement);
117
+ const temporaryPath = `${envPath}.${process.pid}.tmp`;
118
+ try {
119
+ await writeFile(temporaryPath, `${updated.join("\n")}\n`, { mode: 0o600 });
120
+ await rename(temporaryPath, envPath);
121
+ await chmod(envPath, 0o600);
122
+ }
123
+ finally {
124
+ await rm(temporaryPath, { force: true });
125
+ }
126
+ }
127
+ function escapeRegExp(value) {
128
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
129
+ }
@@ -0,0 +1,9 @@
1
+ export interface CreateHostCommandOptions {
2
+ address: string;
3
+ user: string;
4
+ sshPort?: string;
5
+ exposure?: string;
6
+ platform?: string;
7
+ allowedTcpPort?: string[];
8
+ }
9
+ export declare function createHostCommand(name: string, options: CreateHostCommandOptions): Promise<void>;
@@ -0,0 +1,30 @@
1
+ import { createGlobalHost } from "@saws/core";
2
+ export async function createHostCommand(name, options) {
3
+ const host = await createGlobalHost({
4
+ name,
5
+ address: options.address,
6
+ user: options.user,
7
+ ...(options.sshPort == null ? {} : { sshPort: parsePort(options.sshPort, "SSH port") }),
8
+ ...(options.exposure == null ? {} : { exposure: parseExposure(options.exposure) }),
9
+ ...(options.platform == null ? {} : { platform: options.platform }),
10
+ ...(options.allowedTcpPort == null
11
+ ? {}
12
+ : {
13
+ allowedTcpPorts: options.allowedTcpPort.map((port) => parsePort(port, "Allowed TCP port")),
14
+ }),
15
+ });
16
+ console.log(`Created global host "${host.name}" at ${host.address}`);
17
+ }
18
+ function parsePort(value, label) {
19
+ const port = Number(value);
20
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
21
+ throw new Error(`${label} must be a valid TCP port`);
22
+ }
23
+ return port;
24
+ }
25
+ function parseExposure(value) {
26
+ if (value !== "tunnel" && value !== "public") {
27
+ throw new Error('Host exposure must be either "tunnel" or "public"');
28
+ }
29
+ return value;
30
+ }
@@ -0,0 +1,2 @@
1
+ export declare function generatePrivateKey(): Promise<string>;
2
+ export declare function derivePublicKey(privateKey: string): Promise<string>;
@@ -0,0 +1,40 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, readFile, 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
+ export async function generatePrivateKey() {
7
+ return withTemporaryDirectory(async (directory) => {
8
+ const keyPath = path.join(directory, "id_ed25519");
9
+ await execFileAsync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", keyPath]);
10
+ return readFile(keyPath, "utf8");
11
+ });
12
+ }
13
+ export async function derivePublicKey(privateKey) {
14
+ return withTemporaryDirectory(async (directory) => {
15
+ const keyPath = path.join(directory, "id_private");
16
+ await writeFile(keyPath, privateKey, { mode: 0o600 });
17
+ let stdout;
18
+ try {
19
+ ({ stdout } = await execFileAsync("ssh-keygen", ["-y", "-P", "", "-f", keyPath]));
20
+ }
21
+ catch {
22
+ throw new Error("SSH private key is invalid or passphrase-protected; SAWS host keys must not require a passphrase");
23
+ }
24
+ const publicKey = stdout.trim();
25
+ if (publicKey.length === 0) {
26
+ throw new Error("Could not derive the deployment public key");
27
+ }
28
+ return publicKey;
29
+ });
30
+ }
31
+ async function withTemporaryDirectory(callback) {
32
+ const directory = await mkdtemp(path.join(tmpdir(), "saws-key-"));
33
+ try {
34
+ return await callback(directory);
35
+ }
36
+ finally {
37
+ await rm(directory, { recursive: true, force: true });
38
+ }
39
+ }
40
+ const execFileAsync = promisify(execFile);
@@ -0,0 +1,5 @@
1
+ export interface ImportHostKeyCommandOptions {
2
+ file: string;
3
+ force?: boolean;
4
+ }
5
+ export declare function importHostKeyCommand(name: string, options: ImportHostKeyCommandOptions): Promise<void>;
@@ -0,0 +1,40 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ParameterNotFoundError, getGlobalHost, getGlobalHostSecretsManager } from "@saws/core";
4
+ import { derivePublicKey } from "./deployment-key.js";
5
+ export async function importHostKeyCommand(name, options) {
6
+ const host = getGlobalHost(name);
7
+ const keyPath = path.resolve(options.file);
8
+ let privateKey;
9
+ try {
10
+ privateKey = await readFile(keyPath, "utf8");
11
+ }
12
+ catch (error) {
13
+ if (error.code === "ENOENT") {
14
+ throw new Error(`SSH private key file does not exist: ${keyPath}`);
15
+ }
16
+ throw error;
17
+ }
18
+ if (privateKey.trim().length === 0) {
19
+ throw new Error(`SSH private key file is empty: ${keyPath}`);
20
+ }
21
+ await derivePublicKey(privateKey);
22
+ const manager = getGlobalHostSecretsManager();
23
+ const secretName = host.sshPrivateKey.name;
24
+ if (!options.force && (await secretExists(manager, secretName))) {
25
+ throw new Error(`Global host "${host.name}" already has a deployment key; pass --force to replace it`);
26
+ }
27
+ await manager.global.set(secretName, privateKey);
28
+ console.log(`Imported deployment key for global host "${host.name}"`);
29
+ }
30
+ async function secretExists(manager, secretName) {
31
+ try {
32
+ await manager.global.get(secretName);
33
+ return true;
34
+ }
35
+ catch (error) {
36
+ if (error instanceof ParameterNotFoundError)
37
+ return false;
38
+ throw error;
39
+ }
40
+ }
@@ -1,3 +1,2 @@
1
1
  import { Command } from "commander";
2
2
  export declare const createCommand: () => Command;
3
- //# sourceMappingURL=index.d.ts.map