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.
- package/.env.example +12 -0
- package/LICENSE +674 -0
- package/README.md +181 -0
- package/assets/brand/app-icon-1024.png +0 -0
- package/assets/brand/app-icon-384.png +0 -0
- package/assets/brand/apple-touch-icon-360.png +0 -0
- package/assets/brand/favicon-32.png +0 -0
- package/assets/brand/favicon-64.png +0 -0
- package/assets/brand/favicon-96.png +0 -0
- package/assets/brand/favicon.svg +4 -0
- package/assets/brand/nterminal-mark-64.png +0 -0
- package/assets/brand/nterminal-mark.svg +4 -0
- package/assets/brand/nterminal-wordmark-486x68.png +0 -0
- package/assets/brand/nterminal-wordmark.svg +3 -0
- package/assets/screenshot/scr.png +0 -0
- package/bin/nterminal.js +114 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/MarkdownPreview-BeDi-V7k.js +29 -0
- package/dist/client/assets/MesloLGS-NF-Bold-Italic-DwFsXcwX.ttf +0 -0
- package/dist/client/assets/MesloLGS-NF-Bold-kN-HYz-g.ttf +0 -0
- package/dist/client/assets/MesloLGS-NF-Italic-CMg1T6-G.ttf +0 -0
- package/dist/client/assets/MesloLGS-NF-Regular-Cxr8pvCI.ttf +0 -0
- package/dist/client/assets/index-BQkKYjXb.js +33 -0
- package/dist/client/assets/index-WqeS39wU.css +1 -0
- package/dist/client/assets/notifications/character-2258.mp4 +0 -0
- package/dist/client/assets/notifications/character-2260.mp4 +0 -0
- package/dist/client/assets/notifications/character-2272.mp4 +0 -0
- package/dist/client/brand/nterminal-mark-64.png +0 -0
- package/dist/client/brand/nterminal-mark.svg +4 -0
- package/dist/client/brand/nterminal-wordmark-486x68.png +0 -0
- package/dist/client/brand/nterminal-wordmark.svg +3 -0
- package/dist/client/icons/app-icon-1024.png +0 -0
- package/dist/client/icons/app-icon-384.png +0 -0
- package/dist/client/icons/favicon-32.png +0 -0
- package/dist/client/icons/favicon-64.png +0 -0
- package/dist/client/icons/favicon-96.png +0 -0
- package/dist/client/icons/favicon.svg +4 -0
- package/dist/client/index.html +21 -0
- package/dist/client/manifest.webmanifest +24 -0
- package/dist/scripts/generate-secrets.js +3 -0
- package/dist/scripts/generate-secrets.js.map +1 -0
- package/dist/scripts/onboarding.js +814 -0
- package/dist/scripts/onboarding.js.map +1 -0
- package/dist/scripts/proxySetup.js +1007 -0
- package/dist/scripts/proxySetup.js.map +1 -0
- package/dist/server/agent/agentAuth.d.ts +6 -0
- package/dist/server/agent/agentAuth.js +35 -0
- package/dist/server/agent/agentAuth.js.map +1 -0
- package/dist/server/agent/agentProxy.d.ts +5 -0
- package/dist/server/agent/agentProxy.js +63 -0
- package/dist/server/agent/agentProxy.js.map +1 -0
- package/dist/server/agent/agentRoutes.d.ts +9 -0
- package/dist/server/agent/agentRoutes.js +327 -0
- package/dist/server/agent/agentRoutes.js.map +1 -0
- package/dist/server/agent/agentWebSocketProxy.d.ts +3 -0
- package/dist/server/agent/agentWebSocketProxy.js +65 -0
- package/dist/server/agent/agentWebSocketProxy.js.map +1 -0
- package/dist/server/auth/authService.d.ts +100 -0
- package/dist/server/auth/authService.js +415 -0
- package/dist/server/auth/authService.js.map +1 -0
- package/dist/server/auth/cookies.d.ts +11 -0
- package/dist/server/auth/cookies.js +39 -0
- package/dist/server/auth/cookies.js.map +1 -0
- package/dist/server/auth/ipMatch.d.ts +14 -0
- package/dist/server/auth/ipMatch.js +103 -0
- package/dist/server/auth/ipMatch.js.map +1 -0
- package/dist/server/auth/rateLimit.d.ts +17 -0
- package/dist/server/auth/rateLimit.js +25 -0
- package/dist/server/auth/rateLimit.js.map +1 -0
- package/dist/server/auth/totpService.d.ts +10 -0
- package/dist/server/auth/totpService.js +37 -0
- package/dist/server/auth/totpService.js.map +1 -0
- package/dist/server/config.d.ts +27 -0
- package/dist/server/config.js +138 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/files/fileExplorerService.d.ts +38 -0
- package/dist/server/files/fileExplorerService.js +551 -0
- package/dist/server/files/fileExplorerService.js.map +1 -0
- package/dist/server/files/rootToken.d.ts +51 -0
- package/dist/server/files/rootToken.js +139 -0
- package/dist/server/files/rootToken.js.map +1 -0
- package/dist/server/http.d.ts +13 -0
- package/dist/server/http.js +69 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +45 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/routes/agentManagementRoutes.d.ts +9 -0
- package/dist/server/routes/agentManagementRoutes.js +304 -0
- package/dist/server/routes/agentManagementRoutes.js.map +1 -0
- package/dist/server/routes/authRoutes.d.ts +10 -0
- package/dist/server/routes/authRoutes.js +95 -0
- package/dist/server/routes/authRoutes.js.map +1 -0
- package/dist/server/routes/fileRoutes.d.ts +11 -0
- package/dist/server/routes/fileRoutes.js +185 -0
- package/dist/server/routes/fileRoutes.js.map +1 -0
- package/dist/server/routes/notificationAssetRoutes.d.ts +9 -0
- package/dist/server/routes/notificationAssetRoutes.js +280 -0
- package/dist/server/routes/notificationAssetRoutes.js.map +1 -0
- package/dist/server/routes/securityRoutes.d.ts +7 -0
- package/dist/server/routes/securityRoutes.js +53 -0
- package/dist/server/routes/securityRoutes.js.map +1 -0
- package/dist/server/routes/socketBackpressure.d.ts +26 -0
- package/dist/server/routes/socketBackpressure.js +63 -0
- package/dist/server/routes/socketBackpressure.js.map +1 -0
- package/dist/server/routes/terminalLayoutRoutes.d.ts +9 -0
- package/dist/server/routes/terminalLayoutRoutes.js +108 -0
- package/dist/server/routes/terminalLayoutRoutes.js.map +1 -0
- package/dist/server/routes/terminalRoutes.d.ts +14 -0
- package/dist/server/routes/terminalRoutes.js +177 -0
- package/dist/server/routes/terminalRoutes.js.map +1 -0
- package/dist/server/routes/terminalWebSocket.d.ts +9 -0
- package/dist/server/routes/terminalWebSocket.js +129 -0
- package/dist/server/routes/terminalWebSocket.js.map +1 -0
- package/dist/server/routes/totpRoutes.d.ts +7 -0
- package/dist/server/routes/totpRoutes.js +46 -0
- package/dist/server/routes/totpRoutes.js.map +1 -0
- package/dist/server/routes/updateRoutes.d.ts +7 -0
- package/dist/server/routes/updateRoutes.js +24 -0
- package/dist/server/routes/updateRoutes.js.map +1 -0
- package/dist/server/routes/uploadRoutes.d.ts +9 -0
- package/dist/server/routes/uploadRoutes.js +95 -0
- package/dist/server/routes/uploadRoutes.js.map +1 -0
- package/dist/server/storage/fileStore.d.ts +90 -0
- package/dist/server/storage/fileStore.js +275 -0
- package/dist/server/storage/fileStore.js.map +1 -0
- package/dist/server/system/stats.d.ts +2 -0
- package/dist/server/system/stats.js +37 -0
- package/dist/server/system/stats.js.map +1 -0
- package/dist/server/terminal/NodePtyAdapter.d.ts +4 -0
- package/dist/server/terminal/NodePtyAdapter.js +14 -0
- package/dist/server/terminal/NodePtyAdapter.js.map +1 -0
- package/dist/server/terminal/PtyAdapter.d.ts +57 -0
- package/dist/server/terminal/PtyAdapter.js +2 -0
- package/dist/server/terminal/PtyAdapter.js.map +1 -0
- package/dist/server/terminal/TerminalManager.d.ts +74 -0
- package/dist/server/terminal/TerminalManager.js +561 -0
- package/dist/server/terminal/TerminalManager.js.map +1 -0
- package/dist/server/terminal/TmuxPtyAdapter.d.ts +25 -0
- package/dist/server/terminal/TmuxPtyAdapter.js +543 -0
- package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -0
- package/dist/server/terminal/codexTranscriptSource.d.ts +9 -0
- package/dist/server/terminal/codexTranscriptSource.js +144 -0
- package/dist/server/terminal/codexTranscriptSource.js.map +1 -0
- package/dist/server/terminal/cwdResolver.d.ts +8 -0
- package/dist/server/terminal/cwdResolver.js +37 -0
- package/dist/server/terminal/cwdResolver.js.map +1 -0
- package/dist/server/terminal/outputBuffer.d.ts +7 -0
- package/dist/server/terminal/outputBuffer.js +17 -0
- package/dist/server/terminal/outputBuffer.js.map +1 -0
- package/dist/server/terminal/transcriptHistory.d.ts +7 -0
- package/dist/server/terminal/transcriptHistory.js +315 -0
- package/dist/server/terminal/transcriptHistory.js.map +1 -0
- package/dist/server/update/gitUpdate.d.ts +27 -0
- package/dist/server/update/gitUpdate.js +241 -0
- package/dist/server/update/gitUpdate.js.map +1 -0
- package/dist/server/uploads/uploadPaths.d.ts +18 -0
- package/dist/server/uploads/uploadPaths.js +116 -0
- package/dist/server/uploads/uploadPaths.js.map +1 -0
- package/dist/server/uploads/uploadService.d.ts +21 -0
- package/dist/server/uploads/uploadService.js +230 -0
- package/dist/server/uploads/uploadService.js.map +1 -0
- package/dist/shared/layoutState.d.ts +6 -0
- package/dist/shared/layoutState.js +115 -0
- package/dist/shared/layoutState.js.map +1 -0
- package/dist/shared/notificationAssets.d.ts +9 -0
- package/dist/shared/notificationAssets.js +27 -0
- package/dist/shared/notificationAssets.js.map +1 -0
- package/dist/shared/protocol.d.ts +308 -0
- package/dist/shared/protocol.js +29 -0
- package/dist/shared/protocol.js.map +1 -0
- package/dist/shared/types.d.ts +56 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/types.js.map +1 -0
- package/docs/assets/nterminal-workspace.png +0 -0
- package/docs/configuration.md +97 -0
- package/docs/features.md +126 -0
- package/docs/onboarding.md +122 -0
- package/docs/operations.md +112 -0
- package/docs/terminal-history.md +54 -0
- package/package.json +85 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/notifications/character-2258.mp4 +0 -0
- package/public/assets/notifications/character-2260.mp4 +0 -0
- package/public/assets/notifications/character-2272.mp4 +0 -0
- package/public/brand/nterminal-mark-64.png +0 -0
- package/public/brand/nterminal-mark.svg +4 -0
- package/public/brand/nterminal-wordmark-486x68.png +0 -0
- package/public/brand/nterminal-wordmark.svg +3 -0
- package/public/icons/app-icon-1024.png +0 -0
- package/public/icons/app-icon-384.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/favicon-64.png +0 -0
- package/public/icons/favicon-96.png +0 -0
- package/public/icons/favicon.svg +4 -0
- package/public/manifest.webmanifest +24 -0
- 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(`[2K? ${prompt} [2m(↑/↓ then Enter)[0m\n`);
|
|
260
|
+
for (const [i, opt] of options.entries()) {
|
|
261
|
+
const marker = i === index ? '[36m›[0m' : ' ';
|
|
262
|
+
const label = i === index ? `[1m${opt.label}[0m` : opt.label;
|
|
263
|
+
stdout.write(`[2K${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 [2m— orchestrates secondaries; password + 2FA gate[0m', value: 'main' },
|
|
777
|
+
{ label: 'secondary [2m— agent that registers with a main[0m', 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
|