nterminal 1.2.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.
Files changed (197) hide show
  1. package/.env.example +12 -0
  2. package/LICENSE +674 -0
  3. package/README.md +181 -0
  4. package/assets/brand/app-icon-1024.png +0 -0
  5. package/assets/brand/app-icon-384.png +0 -0
  6. package/assets/brand/apple-touch-icon-360.png +0 -0
  7. package/assets/brand/favicon-32.png +0 -0
  8. package/assets/brand/favicon-64.png +0 -0
  9. package/assets/brand/favicon-96.png +0 -0
  10. package/assets/brand/favicon.svg +4 -0
  11. package/assets/brand/nterminal-mark-64.png +0 -0
  12. package/assets/brand/nterminal-mark.svg +4 -0
  13. package/assets/brand/nterminal-wordmark-486x68.png +0 -0
  14. package/assets/brand/nterminal-wordmark.svg +3 -0
  15. package/assets/screenshot/scr.png +0 -0
  16. package/bin/nterminal.js +114 -0
  17. package/dist/client/apple-touch-icon.png +0 -0
  18. package/dist/client/assets/MarkdownPreview-BeDi-V7k.js +29 -0
  19. package/dist/client/assets/MesloLGS-NF-Bold-Italic-DwFsXcwX.ttf +0 -0
  20. package/dist/client/assets/MesloLGS-NF-Bold-kN-HYz-g.ttf +0 -0
  21. package/dist/client/assets/MesloLGS-NF-Italic-CMg1T6-G.ttf +0 -0
  22. package/dist/client/assets/MesloLGS-NF-Regular-Cxr8pvCI.ttf +0 -0
  23. package/dist/client/assets/index-BQkKYjXb.js +33 -0
  24. package/dist/client/assets/index-WqeS39wU.css +1 -0
  25. package/dist/client/assets/notifications/character-2258.mp4 +0 -0
  26. package/dist/client/assets/notifications/character-2260.mp4 +0 -0
  27. package/dist/client/assets/notifications/character-2272.mp4 +0 -0
  28. package/dist/client/brand/nterminal-mark-64.png +0 -0
  29. package/dist/client/brand/nterminal-mark.svg +4 -0
  30. package/dist/client/brand/nterminal-wordmark-486x68.png +0 -0
  31. package/dist/client/brand/nterminal-wordmark.svg +3 -0
  32. package/dist/client/icons/app-icon-1024.png +0 -0
  33. package/dist/client/icons/app-icon-384.png +0 -0
  34. package/dist/client/icons/favicon-32.png +0 -0
  35. package/dist/client/icons/favicon-64.png +0 -0
  36. package/dist/client/icons/favicon-96.png +0 -0
  37. package/dist/client/icons/favicon.svg +4 -0
  38. package/dist/client/index.html +21 -0
  39. package/dist/client/manifest.webmanifest +24 -0
  40. package/dist/scripts/generate-secrets.js +3 -0
  41. package/dist/scripts/generate-secrets.js.map +1 -0
  42. package/dist/scripts/onboarding.js +814 -0
  43. package/dist/scripts/onboarding.js.map +1 -0
  44. package/dist/scripts/proxySetup.js +1007 -0
  45. package/dist/scripts/proxySetup.js.map +1 -0
  46. package/dist/server/agent/agentAuth.d.ts +6 -0
  47. package/dist/server/agent/agentAuth.js +35 -0
  48. package/dist/server/agent/agentAuth.js.map +1 -0
  49. package/dist/server/agent/agentProxy.d.ts +5 -0
  50. package/dist/server/agent/agentProxy.js +63 -0
  51. package/dist/server/agent/agentProxy.js.map +1 -0
  52. package/dist/server/agent/agentRoutes.d.ts +9 -0
  53. package/dist/server/agent/agentRoutes.js +327 -0
  54. package/dist/server/agent/agentRoutes.js.map +1 -0
  55. package/dist/server/agent/agentWebSocketProxy.d.ts +3 -0
  56. package/dist/server/agent/agentWebSocketProxy.js +65 -0
  57. package/dist/server/agent/agentWebSocketProxy.js.map +1 -0
  58. package/dist/server/auth/authService.d.ts +100 -0
  59. package/dist/server/auth/authService.js +415 -0
  60. package/dist/server/auth/authService.js.map +1 -0
  61. package/dist/server/auth/cookies.d.ts +11 -0
  62. package/dist/server/auth/cookies.js +39 -0
  63. package/dist/server/auth/cookies.js.map +1 -0
  64. package/dist/server/auth/ipMatch.d.ts +14 -0
  65. package/dist/server/auth/ipMatch.js +103 -0
  66. package/dist/server/auth/ipMatch.js.map +1 -0
  67. package/dist/server/auth/rateLimit.d.ts +17 -0
  68. package/dist/server/auth/rateLimit.js +25 -0
  69. package/dist/server/auth/rateLimit.js.map +1 -0
  70. package/dist/server/auth/totpService.d.ts +10 -0
  71. package/dist/server/auth/totpService.js +37 -0
  72. package/dist/server/auth/totpService.js.map +1 -0
  73. package/dist/server/config.d.ts +27 -0
  74. package/dist/server/config.js +138 -0
  75. package/dist/server/config.js.map +1 -0
  76. package/dist/server/files/fileExplorerService.d.ts +38 -0
  77. package/dist/server/files/fileExplorerService.js +551 -0
  78. package/dist/server/files/fileExplorerService.js.map +1 -0
  79. package/dist/server/files/rootToken.d.ts +51 -0
  80. package/dist/server/files/rootToken.js +139 -0
  81. package/dist/server/files/rootToken.js.map +1 -0
  82. package/dist/server/http.d.ts +13 -0
  83. package/dist/server/http.js +69 -0
  84. package/dist/server/http.js.map +1 -0
  85. package/dist/server/index.d.ts +1 -0
  86. package/dist/server/index.js +45 -0
  87. package/dist/server/index.js.map +1 -0
  88. package/dist/server/routes/agentManagementRoutes.d.ts +9 -0
  89. package/dist/server/routes/agentManagementRoutes.js +304 -0
  90. package/dist/server/routes/agentManagementRoutes.js.map +1 -0
  91. package/dist/server/routes/authRoutes.d.ts +10 -0
  92. package/dist/server/routes/authRoutes.js +95 -0
  93. package/dist/server/routes/authRoutes.js.map +1 -0
  94. package/dist/server/routes/fileRoutes.d.ts +11 -0
  95. package/dist/server/routes/fileRoutes.js +185 -0
  96. package/dist/server/routes/fileRoutes.js.map +1 -0
  97. package/dist/server/routes/notificationAssetRoutes.d.ts +9 -0
  98. package/dist/server/routes/notificationAssetRoutes.js +280 -0
  99. package/dist/server/routes/notificationAssetRoutes.js.map +1 -0
  100. package/dist/server/routes/securityRoutes.d.ts +7 -0
  101. package/dist/server/routes/securityRoutes.js +53 -0
  102. package/dist/server/routes/securityRoutes.js.map +1 -0
  103. package/dist/server/routes/socketBackpressure.d.ts +26 -0
  104. package/dist/server/routes/socketBackpressure.js +63 -0
  105. package/dist/server/routes/socketBackpressure.js.map +1 -0
  106. package/dist/server/routes/terminalLayoutRoutes.d.ts +9 -0
  107. package/dist/server/routes/terminalLayoutRoutes.js +108 -0
  108. package/dist/server/routes/terminalLayoutRoutes.js.map +1 -0
  109. package/dist/server/routes/terminalRoutes.d.ts +14 -0
  110. package/dist/server/routes/terminalRoutes.js +177 -0
  111. package/dist/server/routes/terminalRoutes.js.map +1 -0
  112. package/dist/server/routes/terminalWebSocket.d.ts +9 -0
  113. package/dist/server/routes/terminalWebSocket.js +129 -0
  114. package/dist/server/routes/terminalWebSocket.js.map +1 -0
  115. package/dist/server/routes/totpRoutes.d.ts +7 -0
  116. package/dist/server/routes/totpRoutes.js +46 -0
  117. package/dist/server/routes/totpRoutes.js.map +1 -0
  118. package/dist/server/routes/updateRoutes.d.ts +7 -0
  119. package/dist/server/routes/updateRoutes.js +24 -0
  120. package/dist/server/routes/updateRoutes.js.map +1 -0
  121. package/dist/server/routes/uploadRoutes.d.ts +9 -0
  122. package/dist/server/routes/uploadRoutes.js +95 -0
  123. package/dist/server/routes/uploadRoutes.js.map +1 -0
  124. package/dist/server/storage/fileStore.d.ts +90 -0
  125. package/dist/server/storage/fileStore.js +275 -0
  126. package/dist/server/storage/fileStore.js.map +1 -0
  127. package/dist/server/system/stats.d.ts +2 -0
  128. package/dist/server/system/stats.js +37 -0
  129. package/dist/server/system/stats.js.map +1 -0
  130. package/dist/server/terminal/NodePtyAdapter.d.ts +4 -0
  131. package/dist/server/terminal/NodePtyAdapter.js +14 -0
  132. package/dist/server/terminal/NodePtyAdapter.js.map +1 -0
  133. package/dist/server/terminal/PtyAdapter.d.ts +57 -0
  134. package/dist/server/terminal/PtyAdapter.js +2 -0
  135. package/dist/server/terminal/PtyAdapter.js.map +1 -0
  136. package/dist/server/terminal/TerminalManager.d.ts +74 -0
  137. package/dist/server/terminal/TerminalManager.js +561 -0
  138. package/dist/server/terminal/TerminalManager.js.map +1 -0
  139. package/dist/server/terminal/TmuxPtyAdapter.d.ts +25 -0
  140. package/dist/server/terminal/TmuxPtyAdapter.js +543 -0
  141. package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -0
  142. package/dist/server/terminal/codexTranscriptSource.d.ts +9 -0
  143. package/dist/server/terminal/codexTranscriptSource.js +144 -0
  144. package/dist/server/terminal/codexTranscriptSource.js.map +1 -0
  145. package/dist/server/terminal/cwdResolver.d.ts +8 -0
  146. package/dist/server/terminal/cwdResolver.js +37 -0
  147. package/dist/server/terminal/cwdResolver.js.map +1 -0
  148. package/dist/server/terminal/outputBuffer.d.ts +7 -0
  149. package/dist/server/terminal/outputBuffer.js +17 -0
  150. package/dist/server/terminal/outputBuffer.js.map +1 -0
  151. package/dist/server/terminal/transcriptHistory.d.ts +7 -0
  152. package/dist/server/terminal/transcriptHistory.js +315 -0
  153. package/dist/server/terminal/transcriptHistory.js.map +1 -0
  154. package/dist/server/update/gitUpdate.d.ts +27 -0
  155. package/dist/server/update/gitUpdate.js +241 -0
  156. package/dist/server/update/gitUpdate.js.map +1 -0
  157. package/dist/server/uploads/uploadPaths.d.ts +18 -0
  158. package/dist/server/uploads/uploadPaths.js +116 -0
  159. package/dist/server/uploads/uploadPaths.js.map +1 -0
  160. package/dist/server/uploads/uploadService.d.ts +21 -0
  161. package/dist/server/uploads/uploadService.js +230 -0
  162. package/dist/server/uploads/uploadService.js.map +1 -0
  163. package/dist/shared/layoutState.d.ts +6 -0
  164. package/dist/shared/layoutState.js +115 -0
  165. package/dist/shared/layoutState.js.map +1 -0
  166. package/dist/shared/notificationAssets.d.ts +9 -0
  167. package/dist/shared/notificationAssets.js +27 -0
  168. package/dist/shared/notificationAssets.js.map +1 -0
  169. package/dist/shared/protocol.d.ts +308 -0
  170. package/dist/shared/protocol.js +29 -0
  171. package/dist/shared/protocol.js.map +1 -0
  172. package/dist/shared/types.d.ts +56 -0
  173. package/dist/shared/types.js +2 -0
  174. package/dist/shared/types.js.map +1 -0
  175. package/docs/assets/nterminal-workspace.png +0 -0
  176. package/docs/configuration.md +97 -0
  177. package/docs/features.md +126 -0
  178. package/docs/onboarding.md +122 -0
  179. package/docs/operations.md +112 -0
  180. package/docs/terminal-history.md +54 -0
  181. package/package.json +85 -0
  182. package/public/apple-touch-icon.png +0 -0
  183. package/public/assets/notifications/character-2258.mp4 +0 -0
  184. package/public/assets/notifications/character-2260.mp4 +0 -0
  185. package/public/assets/notifications/character-2272.mp4 +0 -0
  186. package/public/brand/nterminal-mark-64.png +0 -0
  187. package/public/brand/nterminal-mark.svg +4 -0
  188. package/public/brand/nterminal-wordmark-486x68.png +0 -0
  189. package/public/brand/nterminal-wordmark.svg +3 -0
  190. package/public/icons/app-icon-1024.png +0 -0
  191. package/public/icons/app-icon-384.png +0 -0
  192. package/public/icons/favicon-32.png +0 -0
  193. package/public/icons/favicon-64.png +0 -0
  194. package/public/icons/favicon-96.png +0 -0
  195. package/public/icons/favicon.svg +4 -0
  196. package/public/manifest.webmanifest +24 -0
  197. package/scripts/nterminalctl +588 -0
@@ -0,0 +1,814 @@
1
+ import crypto from 'node:crypto';
2
+ import net from 'node:net';
3
+ import os from 'node:os';
4
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+ import * as readline from 'node:readline';
9
+ import QRCode from 'qrcode';
10
+ import { ensureFirewallAllowsPort, pickSudoMode, setupNginxProxy, validateDeploymentUrl } from './proxySetup.js';
11
+ // ---------------------------------------------------------------------------
12
+ // Pure helpers (unit-tested)
13
+ // ---------------------------------------------------------------------------
14
+ export function generateSessionSecret() {
15
+ return crypto.randomBytes(48).toString('base64url');
16
+ }
17
+ export function generateAgentToken() {
18
+ return crypto.randomBytes(32).toString('base64url');
19
+ }
20
+ export function originOf(url) {
21
+ return new URL(url).origin;
22
+ }
23
+ // First non-internal IPv4 we can find, or 127.0.0.1 if none. Used to pick a
24
+ // sensible bind address for main and to default the URL a secondary advertises
25
+ // to its main.
26
+ export function detectBindHost(interfaces) {
27
+ for (const list of Object.values(interfaces)) {
28
+ for (const entry of list ?? []) {
29
+ // node >=18 reports family as 'IPv4' (string) or 4 (number) on older builds
30
+ const isV4 = entry.family === 'IPv4' || entry.family === 4;
31
+ if (isV4 && !entry.internal) {
32
+ return entry.address;
33
+ }
34
+ }
35
+ }
36
+ return '127.0.0.1';
37
+ }
38
+ export function detectAgentUrl(interfaces, port, protocol = 'http') {
39
+ return `${protocol}://${detectBindHost(interfaces)}:${port}`;
40
+ }
41
+ // Try to bind a server on `host:port` — resolve true when the bind succeeds
42
+ // (port is free) and false when it errors. Used by pickFreePort to decide
43
+ // whether the preferred port is usable without asking the operator.
44
+ export function isPortAvailable(port, host = '127.0.0.1') {
45
+ return new Promise((resolve) => {
46
+ const server = net.createServer();
47
+ const finish = (free) => {
48
+ server.removeAllListeners();
49
+ server.close(() => resolve(free));
50
+ };
51
+ server.once('error', () => finish(false));
52
+ server.once('listening', () => finish(true));
53
+ try {
54
+ server.listen(port, host);
55
+ }
56
+ catch {
57
+ resolve(false);
58
+ }
59
+ });
60
+ }
61
+ // Pick a usable port without asking. Prefer the documented default so the
62
+ // README's instructions ("open http://...:3107") keep working when nothing
63
+ // else is on the host; fall back to a kernel-assigned ephemeral port when
64
+ // the preferred one is taken. A non-positive `preferred` skips the bind probe
65
+ // (since port 0 means "any" to the OS) and goes straight to ephemeral.
66
+ export async function pickFreePort(host = '127.0.0.1', preferred = 3107) {
67
+ if (preferred > 0 && (await isPortAvailable(preferred, host))) {
68
+ return preferred;
69
+ }
70
+ return new Promise((resolve, reject) => {
71
+ const server = net.createServer();
72
+ server.once('error', reject);
73
+ server.listen(0, host, () => {
74
+ const address = server.address();
75
+ const assigned = address && typeof address === 'object' ? address.port : 0;
76
+ server.close((err) => (err ? reject(err) : resolve(assigned)));
77
+ });
78
+ });
79
+ }
80
+ export function renderEnvFile(map) {
81
+ return `${Object.entries(map)
82
+ .map(([key, value]) => `${key}=${value}`)
83
+ .join('\n')}\n`;
84
+ }
85
+ /** Replace targeted keys in an existing .env, preserving comments, blanks, and unrelated keys. Appends new keys. */
86
+ export function mergeEnv(existing, updates) {
87
+ const applied = new Set();
88
+ // Allow optional whitespace around `=` to match loadDotEnvFile()'s parsing; rewrite canonically as KEY=value.
89
+ const lines = existing.split(/\r?\n/).map((line) => {
90
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
91
+ const key = match?.[1];
92
+ if (key && Object.prototype.hasOwnProperty.call(updates, key)) {
93
+ applied.add(key);
94
+ return `${key}=${updates[key]}`;
95
+ }
96
+ return line;
97
+ });
98
+ for (const [key, value] of Object.entries(updates)) {
99
+ if (!applied.has(key)) {
100
+ lines.push(`${key}=${value}`);
101
+ }
102
+ }
103
+ return `${lines.join('\n').replace(/\n+$/, '')}\n`;
104
+ }
105
+ export function buildAgentRegisterPayload(name, url, token) {
106
+ return { name: name.trim(), url: url.trim().replace(/\/$/, ''), token: token.trim() };
107
+ }
108
+ // Resolve the bind host: existing .env value wins so a re-run never silently
109
+ // overwrites an operator's manual override, otherwise call the detector
110
+ // (main: first non-loopback IPv4; secondary: hardcoded 0.0.0.0).
111
+ export function resolveBindHost(envValue, detect) {
112
+ const trimmed = envValue?.trim();
113
+ if (trimmed) {
114
+ return { value: trimmed, origin: 'env' };
115
+ }
116
+ return { value: detect(), origin: 'detected' };
117
+ }
118
+ // Resolve the bind port. Existing .env value wins as long as it parses as
119
+ // a positive integer, otherwise call `pickFreePort` to choose without
120
+ // asking the operator. Returns the chosen port plus how we got it.
121
+ export async function resolveBindPort(envValue, host, preferred = 3107) {
122
+ const trimmed = envValue?.trim();
123
+ if (trimmed && /^[1-9]\d*$/.test(trimmed)) {
124
+ return { value: trimmed, origin: 'env' };
125
+ }
126
+ const port = await pickFreePort(host, preferred);
127
+ return { value: String(port), origin: port === preferred ? 'default' : 'ephemeral' };
128
+ }
129
+ /** Read a single KEY=value from an .env file's contents (last occurrence wins, quotes stripped). */
130
+ export function parseEnvValue(content, key) {
131
+ // loadDotEnvFile() uses the FIRST occurrence and tolerates spaces around `=`; match that here.
132
+ for (const line of content.split(/\r?\n/)) {
133
+ const match = new RegExp(`^\\s*${key}\\s*=(.*)$`).exec(line);
134
+ if (match) {
135
+ let value = (match[1] ?? '').trim();
136
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
137
+ value = value.slice(1, -1);
138
+ }
139
+ return value;
140
+ }
141
+ }
142
+ return undefined;
143
+ }
144
+ /** Default access URL derived from bind host/port so it stays consistent with the chosen port. */
145
+ export function defaultPublicUrl(host, port, https) {
146
+ let reachableHost = host === '0.0.0.0' || host === '::' || host === '' ? '127.0.0.1' : host;
147
+ // Bracket IPv6 literals so the URL stays valid (e.g. http://[::1]:3107).
148
+ if (reachableHost.includes(':') && !reachableHost.startsWith('[')) {
149
+ reachableHost = `[${reachableHost}]`;
150
+ }
151
+ return `${https ? 'https' : 'http'}://${reachableHost}:${port}`;
152
+ }
153
+ export function groupSecret(secret) {
154
+ return secret.replace(/(.{4})/g, '$1 ').trim();
155
+ }
156
+ export function buildOtpAuthUri(secret, issuer = 'NTerminal', account = 'nterminal') {
157
+ // Label is "Issuer:Account" with a literal colon (per the otpauth URI convention).
158
+ // Only secret + issuer are emitted — every authenticator app defaults period/digits/algorithm
159
+ // to 30/6/SHA1, so encoding them just inflates the QR for no behavioral change.
160
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
161
+ const params = new URLSearchParams({ secret, issuer });
162
+ return `otpauth://totp/${label}?${params.toString()}`;
163
+ }
164
+ export async function renderOtpAuthQr(uri) {
165
+ // `small: true` uses half-block characters to halve vertical size — confirmed scannable
166
+ // on a phone camera even when the host terminal renders the wider blank frame.
167
+ return QRCode.toString(uri, { type: 'terminal', small: true });
168
+ }
169
+ // Print a single-color box around a labeled summary + a numbered "Next steps"
170
+ // section. ANSI escapes are stripped before measuring width so the right
171
+ // border lines up even when the title carries a green ✓.
172
+ export function printSummaryBox(title, rows, nextSteps) {
173
+ const visibleLen = (s) => stripAnsi(s).length;
174
+ const labelWidth = rows.reduce((max, row) => Math.max(max, row.label.length), 0);
175
+ const contentLines = [];
176
+ contentLines.push(title);
177
+ contentLines.push('');
178
+ for (const row of rows) {
179
+ contentLines.push(` ${row.label.padEnd(labelWidth)} ${row.value}`);
180
+ }
181
+ if (nextSteps.length > 0) {
182
+ contentLines.push('');
183
+ contentLines.push('Next steps');
184
+ nextSteps.forEach((step, idx) => {
185
+ contentLines.push(` ${idx + 1}. ${step}`);
186
+ });
187
+ }
188
+ const inner = Math.max(...contentLines.map((line) => visibleLen(line)));
189
+ const width = inner + 4; // 2-space padding on each side
190
+ const top = '┌' + '─'.repeat(width - 2) + '┐';
191
+ const bottom = '└' + '─'.repeat(width - 2) + '┘';
192
+ console.log('');
193
+ console.log(top);
194
+ for (const line of contentLines) {
195
+ const pad = ' '.repeat(inner - visibleLen(line));
196
+ console.log(`│ ${line}${pad} │`);
197
+ }
198
+ console.log(bottom);
199
+ }
200
+ function stripAnsi(s) {
201
+ // Minimal CSI stripper, enough for our `\x1b[..m` color codes.
202
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
203
+ }
204
+ export function isPubliclyBoundHost(host) {
205
+ // Loopback / link-local / "unset" binds never need a firewall hole because
206
+ // the bind itself already excludes external traffic. Anything else
207
+ // (0.0.0.0, ::, a routable address) is the case we have to worry about.
208
+ const normalized = host.trim().toLowerCase();
209
+ if (!normalized || normalized === '127.0.0.1' || normalized === '::1' || normalized === 'localhost') {
210
+ return false;
211
+ }
212
+ return true;
213
+ }
214
+ // ---------------------------------------------------------------------------
215
+ // Interactive flow
216
+ // ---------------------------------------------------------------------------
217
+ function resolveAppDir() {
218
+ if (process.env.NTERMINAL_APP_DIR) {
219
+ return path.resolve(process.env.NTERMINAL_APP_DIR);
220
+ }
221
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
222
+ const sourceRoot = path.resolve(scriptDir, '..');
223
+ if (existsSync(path.join(sourceRoot, 'package.json'))) {
224
+ return sourceRoot;
225
+ }
226
+ const distRoot = path.resolve(scriptDir, '..', '..');
227
+ if (existsSync(path.join(distRoot, 'package.json'))) {
228
+ return distRoot;
229
+ }
230
+ return sourceRoot;
231
+ }
232
+ const APP_DIR = resolveAppDir();
233
+ const ENV_PATH = process.env.NTERMINAL_ENV_FILE
234
+ ? path.resolve(process.env.NTERMINAL_ENV_FILE)
235
+ : path.join(APP_DIR, '.env');
236
+ function serverModuleUrl(modulePath) {
237
+ const built = path.join(APP_DIR, 'dist/server', `${modulePath}.js`);
238
+ if (existsSync(built)) {
239
+ return pathToFileURL(built).href;
240
+ }
241
+ const source = path.join(APP_DIR, 'src/server', `${modulePath}.ts`);
242
+ if (existsSync(source)) {
243
+ return pathToFileURL(source).href;
244
+ }
245
+ return pathToFileURL(path.join(APP_DIR, 'src/server', `${modulePath}.js`)).href;
246
+ }
247
+ // Arrow-key + Enter list selector for a TTY. The caller is responsible for
248
+ // falling back to text input when stdin is not a TTY — see chooseRole().
249
+ async function pickFromList(prompt, options, initialIndex = 0) {
250
+ const stdout = process.stdout;
251
+ let index = Math.max(0, Math.min(initialIndex, options.length - 1));
252
+ const lines = options.length + 1;
253
+ let rendered = false;
254
+ const render = () => {
255
+ if (rendered) {
256
+ // Move the cursor back to the prompt line so we can rewrite the block.
257
+ readline.moveCursor(stdout, 0, -lines);
258
+ }
259
+ stdout.write(`? ${prompt} (↑/↓ then Enter)\n`);
260
+ for (const [i, opt] of options.entries()) {
261
+ const marker = i === index ? '›' : ' ';
262
+ const label = i === index ? `${opt.label}` : opt.label;
263
+ stdout.write(`${marker} ${label}\n`);
264
+ }
265
+ rendered = true;
266
+ };
267
+ readline.emitKeypressEvents(process.stdin);
268
+ const wasRaw = Boolean(process.stdin.isRaw);
269
+ process.stdin.setRawMode(true);
270
+ process.stdin.resume();
271
+ return new Promise((resolve) => {
272
+ const cleanup = () => {
273
+ process.stdin.removeListener('keypress', onKeypress);
274
+ if (!wasRaw) {
275
+ process.stdin.setRawMode(false);
276
+ }
277
+ process.stdin.pause();
278
+ };
279
+ const onKeypress = (_str, key) => {
280
+ if (!key)
281
+ return;
282
+ if (key.ctrl && key.name === 'c') {
283
+ cleanup();
284
+ stdout.write('\n');
285
+ process.exit(130);
286
+ }
287
+ if (key.name === 'up' || key.name === 'k') {
288
+ index = (index - 1 + options.length) % options.length;
289
+ render();
290
+ }
291
+ else if (key.name === 'down' || key.name === 'j') {
292
+ index = (index + 1) % options.length;
293
+ render();
294
+ }
295
+ else if (key.name === 'return' || key.name === 'enter') {
296
+ cleanup();
297
+ resolve(options[index].value);
298
+ }
299
+ };
300
+ process.stdin.on('keypress', onKeypress);
301
+ render();
302
+ });
303
+ }
304
+ function createPrompter() {
305
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
306
+ const rlAny = rl;
307
+ const originalWrite = rlAny._writeToOutput.bind(rl);
308
+ let muted = false;
309
+ rlAny._writeToOutput = (stringToWrite) => {
310
+ if (muted) {
311
+ return;
312
+ }
313
+ originalWrite(stringToWrite);
314
+ };
315
+ return {
316
+ ask(question, fallback) {
317
+ const suffix = fallback ? ` [${fallback}]` : '';
318
+ return new Promise((resolve) => {
319
+ rl.question(`${question}${suffix}: `, (answer) => {
320
+ const value = answer.trim();
321
+ resolve(value || fallback || '');
322
+ });
323
+ });
324
+ },
325
+ askSecret(question) {
326
+ // Masking via output muting only works on a real TTY; in piped/automation contexts fall back to a plain read.
327
+ if (!process.stdin.isTTY) {
328
+ return new Promise((resolve) => rl.question(`${question}: `, (answer) => resolve(answer)));
329
+ }
330
+ return new Promise((resolve) => {
331
+ rl.question(`${question}: `, (answer) => {
332
+ muted = false;
333
+ rlAny.output.write('\n');
334
+ resolve(answer);
335
+ });
336
+ muted = true;
337
+ });
338
+ },
339
+ async confirm(question, defaultYes) {
340
+ const hint = defaultYes ? 'Y/n' : 'y/N';
341
+ const answer = (await this.ask(`${question} (${hint})`)).toLowerCase();
342
+ if (!answer) {
343
+ return defaultYes;
344
+ }
345
+ return answer === 'y' || answer === 'yes';
346
+ },
347
+ close() {
348
+ rl.close();
349
+ }
350
+ };
351
+ }
352
+ function writeEnv(updates) {
353
+ const content = existsSync(ENV_PATH) ? mergeEnv(readFileSync(ENV_PATH, 'utf8'), updates) : renderEnvFile(updates);
354
+ writeFileSync(ENV_PATH, content, { mode: 0o600 });
355
+ chmodSync(ENV_PATH, 0o600);
356
+ }
357
+ /** Return an existing .env value so re-running onboarding never rotates a secret already in use. */
358
+ function existingEnvValue(key) {
359
+ if (!existsSync(ENV_PATH)) {
360
+ return undefined;
361
+ }
362
+ return parseEnvValue(readFileSync(ENV_PATH, 'utf8'), key);
363
+ }
364
+ async function askUrl(p, question, fallback) {
365
+ for (;;) {
366
+ const value = (await p.ask(question, fallback)).replace(/\/$/, '');
367
+ let parsed;
368
+ try {
369
+ parsed = new URL(value);
370
+ }
371
+ catch {
372
+ console.log('Invalid URL. Try again.');
373
+ continue;
374
+ }
375
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
376
+ console.log('URL must start with http:// or https://. Try again.');
377
+ continue;
378
+ }
379
+ return value;
380
+ }
381
+ }
382
+ // Normalize a typed URL-or-domain. Accepts bare `cli.example.com` and
383
+ // prepends https:// (the only accepted scheme), leaves any explicit scheme
384
+ // alone so the validator can reject non-https with a clear reason. Trims
385
+ // surrounding whitespace and a trailing slash so neither rejects the input
386
+ // for spurious reasons.
387
+ export function normalizeDeploymentUrlInput(raw) {
388
+ const trimmed = raw.trim().replace(/\/$/, '');
389
+ if (!trimmed)
390
+ return trimmed;
391
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
392
+ }
393
+ // Stricter version for the main's public URL: forces https:// + a real
394
+ // domain because the security model requires Let's Encrypt-issued TLS in
395
+ // front (no raw IPs, no localhost, no .local). Reasons are echoed back so
396
+ // the operator knows why their value was rejected.
397
+ async function askDeploymentUrl(p, question, fallback) {
398
+ for (;;) {
399
+ const value = normalizeDeploymentUrlInput(await p.ask(question, fallback));
400
+ if (!value) {
401
+ console.log('A domain is required. Try again.');
402
+ continue;
403
+ }
404
+ const result = validateDeploymentUrl(value);
405
+ if (result.ok) {
406
+ return value;
407
+ }
408
+ console.log(`Rejected: ${result.reason}. Try again.`);
409
+ }
410
+ }
411
+ // Best-effort default for the deployment URL: if the machine's hostname
412
+ // already passes the same validation we apply to the typed answer (FQDN,
413
+ // not localhost/.local, not a raw IP, …), suggest it as a starting point —
414
+ // otherwise show an obvious placeholder the operator must replace.
415
+ // Running it through validateDeploymentUrl rather than re-implementing the
416
+ // rules keeps the suggestion in lock-step with what we'd accept.
417
+ export function suggestDeploymentUrl(hostname = os.hostname()) {
418
+ const candidate = `https://${hostname.trim().toLowerCase()}`;
419
+ if (validateDeploymentUrl(candidate).ok) {
420
+ return candidate;
421
+ }
422
+ return 'https://your-domain.example.com';
423
+ }
424
+ function runDeploy() {
425
+ console.log('\nBuilding and starting (scripts/nterminalctl deploy)...\n');
426
+ const result = spawnSync('bash', [path.join(APP_DIR, 'scripts/nterminalctl'), 'deploy'], {
427
+ cwd: APP_DIR,
428
+ stdio: 'inherit'
429
+ });
430
+ return result.status === 0;
431
+ }
432
+ function printResolvedBindHost(resolved, detectedNote) {
433
+ const note = resolved.origin === 'env' ? 'preserved from .env' : detectedNote;
434
+ console.log(`Bind host: ${resolved.value} \x1b[2m(${note})\x1b[0m`);
435
+ }
436
+ function printResolvedBindPort(resolved) {
437
+ const note = resolved.origin === 'env'
438
+ ? 'preserved from .env'
439
+ : resolved.origin === 'default'
440
+ ? 'default 3107 is free'
441
+ : 'default 3107 was busy; picked an ephemeral port';
442
+ console.log(`Port: ${resolved.value} \x1b[2m(${note})\x1b[0m`);
443
+ }
444
+ async function onboardMain(p) {
445
+ console.log('\n=== Main server setup ===\n');
446
+ // Main MUST bind to 127.0.0.1 — the nginx reverse proxy set up in
447
+ // `setupNginxProxy` below hard-codes 127.0.0.1 as its upstream, and
448
+ // binding wider would let clients bypass TLS by hitting the server's
449
+ // LAN/public IP directly. We do NOT preserve a previous NTERMINAL_HOST
450
+ // here even when one exists in .env: pre-loopback onboarding versions
451
+ // wrote the detected LAN IP, and silently honoring that stale value
452
+ // would make the new nginx proxy 502 with a confusing "config loaded but
453
+ // upstream unreachable" error. Tell the operator we're overriding so the
454
+ // change isn't a surprise, and remind them to restart NTerminal after
455
+ // since the running process is still on the old bind until then. Port
456
+ // still preserves any explicit value, otherwise picks the first free
457
+ // port (preferring 3107) — the port itself has no security implication
458
+ // once the bind is loopback.
459
+ const existingHost = existingEnvValue('NTERMINAL_HOST');
460
+ if (existingHost && existingHost !== '127.0.0.1') {
461
+ console.log(`Overriding NTERMINAL_HOST=${existingHost} from .env → 127.0.0.1 (loopback is required behind the reverse proxy). The running process keeps the old bind until you redeploy below.`);
462
+ }
463
+ const host = { value: '127.0.0.1', origin: existingHost === '127.0.0.1' ? 'env' : 'detected' };
464
+ printResolvedBindHost(host, 'loopback only — public traffic is fronted by the nginx proxy below');
465
+ const port = await resolveBindPort(existingEnvValue('NTERMINAL_PORT'), host.value);
466
+ printResolvedBindPort(port);
467
+ // One deployment-shape question: the public https://<domain> the main
468
+ // will live at. Forced to https + a real domain because the security
469
+ // model is "TLS in front via Let's Encrypt + reverse proxy" — raw IPs
470
+ // can't get a public cert, and unencrypted HTTP exposes the login
471
+ // password + session cookies on whatever network the browser uses.
472
+ // NTERMINAL_COOKIE_SECURE and NTERMINAL_TRUST_PROXY are therefore both
473
+ // set to true unconditionally.
474
+ const publicUrl = await askDeploymentUrl(p, 'Public domain or URL for this main (https:// is added if you omit it)', suggestDeploymentUrl());
475
+ // Preserve an existing session secret: the password hash is peppered with it, so rotating
476
+ // it on a re-run would lock out the already-stored password.
477
+ const sessionSecret = existingEnvValue('NTERMINAL_SESSION_SECRET') ?? generateSessionSecret();
478
+ const reusedSecret = Boolean(existingEnvValue('NTERMINAL_SESSION_SECRET'));
479
+ writeEnv({
480
+ NTERMINAL_SESSION_SECRET: sessionSecret,
481
+ NTERMINAL_HOST: host.value,
482
+ NTERMINAL_PORT: port.value,
483
+ NTERMINAL_ALLOWED_ORIGINS: originOf(publicUrl),
484
+ NTERMINAL_COOKIE_SECURE: 'true',
485
+ NTERMINAL_TRUST_PROXY: 'true'
486
+ });
487
+ console.log(`\nWrote ${ENV_PATH} (session secret ${reusedSecret ? 'preserved' : 'generated'}).`);
488
+ // Seed password + TOTP directly into the state file (in-process; no server needed).
489
+ const { loadConfig, loadDotEnvFile } = await import(serverModuleUrl('config'));
490
+ const { FileStore } = await import(serverModuleUrl('storage/fileStore'));
491
+ const { AuthService } = await import(serverModuleUrl('auth/authService'));
492
+ const { TotpService } = await import(serverModuleUrl('auth/totpService'));
493
+ // File-only: ignore the surrounding shell env so a stray NTERMINAL_SESSION_SECRET /
494
+ // NTERMINAL_STATE_PATH can't make us seed into the wrong secret or state file.
495
+ const config = loadConfig(loadDotEnvFile(ENV_PATH, {}));
496
+ const store = new FileStore(config.statePath);
497
+ await store.init();
498
+ const auth = new AuthService(config, store, { totpService: new TotpService('NTerminal') });
499
+ const existing = await store.read();
500
+ if (existing.passwordCredential) {
501
+ console.log('\nA password is already set for this server — skipping password/2FA setup.');
502
+ }
503
+ else {
504
+ let sessionId = '';
505
+ for (;;) {
506
+ const pw = await p.askSecret('Set login password (min 8 chars)');
507
+ const confirmPw = await p.askSecret('Confirm password');
508
+ if (pw !== confirmPw) {
509
+ console.log('Passwords do not match. Try again.');
510
+ continue;
511
+ }
512
+ try {
513
+ const result = await auth.setupPassword(pw);
514
+ sessionId = result.session.sessionId;
515
+ break;
516
+ }
517
+ catch (error) {
518
+ console.log(`Password rejected: ${errorText(error)}`);
519
+ }
520
+ }
521
+ const setup = await auth.setupTotp(sessionId);
522
+ console.log('\nAdd NTerminal to your authenticator app (Google Authenticator, Authy, 1Password, ...).');
523
+ console.log('Scan this QR with the app, or type the secret below by hand:\n');
524
+ process.stdout.write(await renderOtpAuthQr(buildOtpAuthUri(setup.secret)));
525
+ console.log(`\n Secret : ${groupSecret(setup.secret)}`);
526
+ console.log(' NTerminal · nterminal · 6 digits · 30s\n');
527
+ for (;;) {
528
+ const code = (await p.ask('Enter the 6-digit code from your app')).replace(/\D/g, '');
529
+ try {
530
+ await auth.activateTotp(sessionId, setup.secret, code);
531
+ console.log('Two-factor authentication enabled.');
532
+ break;
533
+ }
534
+ catch (error) {
535
+ console.log(`Code rejected (${errorText(error)}). Try again.`);
536
+ }
537
+ }
538
+ // setupPassword issued an in-process trusted-device token that nobody holds.
539
+ // Clear it so the operator's first real browser login establishes the trusted device.
540
+ await store.update((current) => ({ ...current, trustedDevices: [] }));
541
+ }
542
+ const deploy = await p.confirm('\nBuild and start the server now?', true);
543
+ const deployed = deploy ? runDeploy() : false;
544
+ if (deploy && !deployed) {
545
+ p.close();
546
+ console.error('\nDeploy failed. Fix the issue and run: scripts/nterminalctl deploy');
547
+ process.exitCode = 1;
548
+ return;
549
+ }
550
+ // Proxy setup is independent of the deploy step — on a re-run where NTerminal
551
+ // is already running, the operator typically wants to skip the redeploy but
552
+ // still finish (or verify) the nginx + TLS half. If NTerminal happens to be
553
+ // stopped, the curl verification inside setupNginxProxy will surface a 502
554
+ // and tell them to run nterminalctl start.
555
+ const proxyResult = await setupNginxProxy({
556
+ domain: new URL(publicUrl).hostname,
557
+ port: Number(port.value),
558
+ prompter: p
559
+ });
560
+ p.close();
561
+ // Order matters: httpsReady is the strongest signal — when nginx + cert
562
+ // verify passed end-to-end, NTerminal must be running (otherwise the proxy
563
+ // would have 502'd), so the "did the operator say yes to deploy this time"
564
+ // distinction stops mattering. Previously we checked `!deploy` first and
565
+ // printed "Config written but not deployed" alongside a successful
566
+ // ✓ TLS-ready line, which was contradictory on re-runs where NTerminal was
567
+ // already up from an earlier session.
568
+ if (proxyResult.httpsReady) {
569
+ printSummaryBox('\x1b[32m✓\x1b[0m Main is ready', [
570
+ { label: 'Public URL', value: publicUrl },
571
+ { label: 'Internal', value: `${host.value}:${port.value}` }
572
+ ], [
573
+ `Open ${publicUrl} in your browser`,
574
+ 'Log in with your password + 6-digit authenticator code',
575
+ 'iPad Safari: Share → Add to Home Screen (optional)'
576
+ ]);
577
+ return;
578
+ }
579
+ if (!deploy) {
580
+ console.log(`\nConfig written but not deployed, and the proxy step couldn't verify NTerminal is reachable on 127.0.0.1:${port.value}. Run \`scripts/nterminalctl deploy\` (or restart if already running) and re-run onboarding.`);
581
+ return;
582
+ }
583
+ // deploy succeeded but httpsReady is false — proxy or cert step was
584
+ // skipped or failed verification.
585
+ console.log(`\n\x1b[33m⚠\x1b[0m NTerminal itself is running on 127.0.0.1:${port.value}, but the public URL is not verified yet — the reverse-proxy or TLS step above was skipped or did not pass its check.`);
586
+ console.log(`Finish the remaining commands shown above, then re-run \`npm run onboarding\` to re-verify ${publicUrl}.`);
587
+ }
588
+ async function onboardSecondary(p) {
589
+ console.log('\n=== Secondary (agent) server setup ===\n');
590
+ // Secondary listens on all interfaces by default so the main can reach it
591
+ // across hosts. Existing .env value wins — same preservation rule as main.
592
+ const host = resolveBindHost(existingEnvValue('NTERMINAL_HOST'), () => '0.0.0.0');
593
+ printResolvedBindHost(host, 'listens on all interfaces so the main can reach this server');
594
+ const port = await resolveBindPort(existingEnvValue('NTERMINAL_PORT'), host.value);
595
+ printResolvedBindPort(port);
596
+ const suggestedUrl = detectAgentUrl(os.networkInterfaces(), Number(port.value));
597
+ const selfUrl = await p.ask('URL the MAIN will use to reach THIS server', suggestedUrl);
598
+ const name = await p.ask('Name shown in the main', os.hostname());
599
+ // Preserve existing secret/token on re-run so a re-registration keeps the same agent identity.
600
+ const sessionSecret = existingEnvValue('NTERMINAL_SESSION_SECRET') ?? generateSessionSecret();
601
+ const agentToken = existingEnvValue('NTERMINAL_AGENT_TOKEN') ?? generateAgentToken();
602
+ const reused = Boolean(existingEnvValue('NTERMINAL_SESSION_SECRET'));
603
+ writeEnv({
604
+ NTERMINAL_SESSION_SECRET: sessionSecret,
605
+ NTERMINAL_AGENT_TOKEN: agentToken,
606
+ NTERMINAL_HOST: host.value,
607
+ NTERMINAL_PORT: port.value
608
+ });
609
+ console.log(`\nWrote ${ENV_PATH} (session secret + agent token ${reused ? 'preserved' : 'generated'}).`);
610
+ // Firewall: when the agent is publicly bound, the main can only reach it
611
+ // if the host firewall lets the bind port in. Loopback / private binds
612
+ // never need this so we skip the prompt entirely there. Onboarding used
613
+ // to leave this manual and the symptom was always the same — main says
614
+ // "unreachable", the operator forgets ufw is even on, and debugging
615
+ // burns a half hour before someone runs `ufw status`.
616
+ if (isPubliclyBoundHost(host.value)) {
617
+ const portNumber = Number(port.value);
618
+ if (Number.isFinite(portNumber)) {
619
+ const sudoMode = await pickSudoMode(p);
620
+ await ensureFirewallAllowsPort(portNumber, p, sudoMode);
621
+ }
622
+ }
623
+ const wantRegister = await p.confirm('\nRegister this server with a main now?', true);
624
+ if (wantRegister) {
625
+ await registerWithMain(p, { name, selfUrl, agentToken });
626
+ }
627
+ else {
628
+ console.log('Skipped registration. Add it later from the main sidebar (URL + the agent token in this .env).');
629
+ }
630
+ const deploy = await p.confirm('\nBuild and start this server now?', true);
631
+ p.close();
632
+ if (deploy && !runDeploy()) {
633
+ console.error('\nDeploy failed. Fix the issue and run: scripts/nterminalctl deploy');
634
+ process.exitCode = 1;
635
+ return;
636
+ }
637
+ printSummaryBox(`\x1b[32m✓\x1b[0m Secondary "${name}" is ready`, [
638
+ { label: 'Reachable at', value: selfUrl },
639
+ { label: 'Bind', value: `${host.value}:${port.value}` },
640
+ { label: 'Registered', value: wantRegister ? 'yes (logged into the main)' : 'no — paste the agent token in the main sidebar later' }
641
+ ], wantRegister
642
+ ? [
643
+ 'On the main, the Servers list should now show this agent',
644
+ 'Re-login on the main in your browser (CLI login revoked any active session)'
645
+ ]
646
+ : [
647
+ `In the main sidebar, add a server with URL ${selfUrl}`,
648
+ 'Paste the agent token from this server\'s .env'
649
+ ]);
650
+ }
651
+ async function registerWithMain(p, agent) {
652
+ let mainUrl = '';
653
+ let mainOrigin = '';
654
+ for (;;) {
655
+ mainUrl = await askUrl(p, 'Main server URL', 'https://nterminal.example.com');
656
+ mainOrigin = originOf(mainUrl);
657
+ try {
658
+ const res = await fetch(`${mainUrl}/api/auth/session`, { signal: AbortSignal.timeout(8000) });
659
+ const status = (await res.json());
660
+ if (!status.passwordSet) {
661
+ console.log('That main has no password set yet. Onboard the MAIN first, then re-run this on the secondary.');
662
+ return;
663
+ }
664
+ break;
665
+ }
666
+ catch {
667
+ console.log(`Could not reach ${mainUrl}. Check the URL/network and try again.`);
668
+ }
669
+ }
670
+ let cookie = '';
671
+ for (;;) {
672
+ const password = await p.askSecret('Main login password');
673
+ const otp = (await p.ask('Main authenticator code')).replace(/\D/g, '');
674
+ try {
675
+ const res = await fetch(`${mainUrl}/api/auth/login`, {
676
+ method: 'POST',
677
+ headers: { 'content-type': 'application/json', origin: mainOrigin },
678
+ body: JSON.stringify({ password, otp, rememberNetwork: false }),
679
+ signal: AbortSignal.timeout(8000)
680
+ });
681
+ if (!res.ok) {
682
+ const body = (await res.json().catch(() => ({})));
683
+ console.log(`Login failed (${body.error ?? res.status}). Try again.`);
684
+ continue;
685
+ }
686
+ cookie = collectCookies(res.headers);
687
+ break;
688
+ }
689
+ catch (error) {
690
+ console.log(`Login error: ${errorText(error)}. Try again.`);
691
+ }
692
+ }
693
+ const payload = buildAgentRegisterPayload(agent.name, agent.selfUrl, agent.agentToken);
694
+ try {
695
+ // Remove any existing agent pointing at the same URL to avoid duplicates.
696
+ try {
697
+ const listRes = await fetch(`${mainUrl}/api/agents`, { headers: { cookie }, signal: AbortSignal.timeout(8000) });
698
+ if (listRes.ok) {
699
+ const { agents } = (await listRes.json());
700
+ for (const existing of agents.filter((a) => a.url.replace(/\/$/, '') === payload.url)) {
701
+ await fetch(`${mainUrl}/api/agents/${encodeURIComponent(existing.id)}`, {
702
+ method: 'DELETE',
703
+ headers: { cookie, origin: mainOrigin },
704
+ signal: AbortSignal.timeout(8000)
705
+ });
706
+ }
707
+ }
708
+ }
709
+ catch {
710
+ // best-effort de-dup; ignore
711
+ }
712
+ const regRes = await fetch(`${mainUrl}/api/agents`, {
713
+ method: 'POST',
714
+ headers: { 'content-type': 'application/json', origin: mainOrigin, cookie },
715
+ body: JSON.stringify(payload),
716
+ signal: AbortSignal.timeout(8000)
717
+ });
718
+ if (regRes.status === 403) {
719
+ console.log(`Registration blocked by origin policy. Add "${mainOrigin}" to the main's NTERMINAL_ALLOWED_ORIGINS.`);
720
+ }
721
+ else if (!regRes.ok) {
722
+ const body = (await regRes.json().catch(() => ({})));
723
+ console.log(`Registration failed (${body.error ?? regRes.status}).`);
724
+ }
725
+ else {
726
+ console.log(`Registered with main as "${payload.name}".`);
727
+ }
728
+ }
729
+ finally {
730
+ // Always clean up the CLI session + the trusted-device record it created on the main,
731
+ // even if registration threw.
732
+ await fetch(`${mainUrl}/api/auth/logout`, {
733
+ method: 'POST',
734
+ headers: { origin: mainOrigin, cookie },
735
+ signal: AbortSignal.timeout(8000)
736
+ }).catch(() => undefined);
737
+ }
738
+ console.log('\nNote: logging into the main from this CLI revoked any active browser session on the main');
739
+ console.log(' (single active session policy). Re-login in your browser.');
740
+ }
741
+ function collectCookies(headers) {
742
+ // Node fetch exposes combined set-cookie via getSetCookie()
743
+ const setCookies = headers.getSetCookie?.() ?? [];
744
+ return setCookies.map((c) => c.split(';')[0]).join('; ');
745
+ }
746
+ function errorText(error) {
747
+ return error instanceof Error ? error.message : String(error);
748
+ }
749
+ function printTopology() {
750
+ // Lays out the relationship between the two roles before we ask which one
751
+ // the operator is installing — first-time users often don't know whether
752
+ // they want main or secondary, and a name + one-line description fits in
753
+ // the picker but doesn't show how the pieces connect.
754
+ const lines = [
755
+ '\x1b[2m browser\x1b[0m',
756
+ "\x1b[2m │ https\x1b[0m",
757
+ '\x1b[2m ▼\x1b[0m',
758
+ ' ┌───────────┐',
759
+ ' │ main │\x1b[2m ← password + TOTP, fronts everything\x1b[0m',
760
+ ' └─────┬─────┘',
761
+ "\x1b[2m │ agent token over LAN/VPN\x1b[0m",
762
+ "\x1b[2m ▼\x1b[0m",
763
+ ' ┌───────────┐',
764
+ ' │ secondary │\x1b[2m ← additional shell hosts, registered with the main\x1b[0m',
765
+ ' └───────────┘\x1b[2m (zero or more; optional)\x1b[0m'
766
+ ];
767
+ console.log('Topology:');
768
+ for (const line of lines) {
769
+ console.log(line);
770
+ }
771
+ console.log('');
772
+ }
773
+ async function chooseRole() {
774
+ if (process.stdin.isTTY) {
775
+ return pickFromList('Install as', [
776
+ { label: 'main — orchestrates secondaries; password + 2FA gate', value: 'main' },
777
+ { label: 'secondary — agent that registers with a main', value: 'secondary' }
778
+ ]);
779
+ }
780
+ // Non-TTY (piped/automation): keep a one-line text prompt so scripted runs
781
+ // and the pre-existing test harness continue to work.
782
+ return new Promise((resolve) => {
783
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
784
+ rl.question('Install as (main / secondary) [main]: ', (answer) => {
785
+ rl.close();
786
+ resolve(answer.trim().toLowerCase().startsWith('s') ? 'secondary' : 'main');
787
+ });
788
+ });
789
+ }
790
+ async function main() {
791
+ console.log('NTerminal onboarding\n');
792
+ printTopology();
793
+ let p;
794
+ try {
795
+ const role = await chooseRole();
796
+ p = createPrompter();
797
+ if (role === 'secondary') {
798
+ await onboardSecondary(p);
799
+ }
800
+ else {
801
+ await onboardMain(p);
802
+ }
803
+ }
804
+ catch (error) {
805
+ p?.close();
806
+ console.error(`\nOnboarding aborted: ${errorText(error)}`);
807
+ process.exitCode = 1;
808
+ }
809
+ }
810
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
811
+ if (invokedDirectly) {
812
+ void main();
813
+ }
814
+ //# sourceMappingURL=onboarding.js.map