@elf5/periscope 1.0.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/__tests__/e2e/cli-test-utils.d.ts +79 -0
- package/dist/__tests__/e2e/cli-test-utils.d.ts.map +1 -0
- package/dist/__tests__/e2e/mock-server.d.ts +43 -0
- package/dist/__tests__/e2e/mock-server.d.ts.map +1 -0
- package/dist/__tests__/e2e/test-server.d.ts +46 -0
- package/dist/__tests__/e2e/test-server.d.ts.map +1 -0
- package/dist/__tests__/e2e/test-utils.d.ts +84 -0
- package/dist/__tests__/e2e/test-utils.d.ts.map +1 -0
- package/dist/__tests__/helpers/assertions.d.ts +5 -0
- package/dist/__tests__/helpers/assertions.d.ts.map +1 -0
- package/dist/__tests__/helpers/mock-factory.d.ts +31 -0
- package/dist/__tests__/helpers/mock-factory.d.ts.map +1 -0
- package/dist/__tests__/setup.d.ts +2 -0
- package/dist/__tests__/setup.d.ts.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +5156 -0
- package/dist/cli.js.map +7 -0
- package/dist/commands/auth.d.ts +14 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/base-command.d.ts +56 -0
- package/dist/commands/base-command.d.ts.map +1 -0
- package/dist/commands/config.d.ts +15 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/feedback.d.ts +15 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/tunnel.d.ts +31 -0
- package/dist/commands/tunnel.d.ts.map +1 -0
- package/dist/commands/user.d.ts +18 -0
- package/dist/commands/user.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4976 -0
- package/dist/index.js.map +7 -0
- package/dist/interactive.d.ts +25 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/lib/__tests__/__mocks__/ssh-client.mock.d.ts +18 -0
- package/dist/lib/__tests__/__mocks__/ssh-client.mock.d.ts.map +1 -0
- package/dist/lib/auth-callback-server.d.ts +25 -0
- package/dist/lib/auth-callback-server.d.ts.map +1 -0
- package/dist/lib/auth-types.d.ts +24 -0
- package/dist/lib/auth-types.d.ts.map +1 -0
- package/dist/lib/auth0-auth-manager.d.ts +73 -0
- package/dist/lib/auth0-auth-manager.d.ts.map +1 -0
- package/dist/lib/cache-utils.d.ts +14 -0
- package/dist/lib/cache-utils.d.ts.map +1 -0
- package/dist/lib/client.d.ts +181 -0
- package/dist/lib/client.d.ts.map +1 -0
- package/dist/lib/config-manager.d.ts +34 -0
- package/dist/lib/config-manager.d.ts.map +1 -0
- package/dist/lib/error-classifier.d.ts +55 -0
- package/dist/lib/error-classifier.d.ts.map +1 -0
- package/dist/lib/interactive-utils.d.ts +14 -0
- package/dist/lib/interactive-utils.d.ts.map +1 -0
- package/dist/lib/logger.d.ts +99 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/msal-auth-manager.d.ts +54 -0
- package/dist/lib/msal-auth-manager.d.ts.map +1 -0
- package/dist/lib/msal-cache-plugin.d.ts +29 -0
- package/dist/lib/msal-cache-plugin.d.ts.map +1 -0
- package/dist/lib/process-lifecycle.d.ts +13 -0
- package/dist/lib/process-lifecycle.d.ts.map +1 -0
- package/dist/lib/readline-instance.d.ts +6 -0
- package/dist/lib/readline-instance.d.ts.map +1 -0
- package/dist/lib/request-monitor.d.ts +21 -0
- package/dist/lib/request-monitor.d.ts.map +1 -0
- package/dist/lib/secure-memory.d.ts +28 -0
- package/dist/lib/secure-memory.d.ts.map +1 -0
- package/dist/lib/server-config.d.ts +25 -0
- package/dist/lib/server-config.d.ts.map +1 -0
- package/dist/lib/ssh-key-manager.d.ts +50 -0
- package/dist/lib/ssh-key-manager.d.ts.map +1 -0
- package/dist/lib/telemetry.d.ts +42 -0
- package/dist/lib/telemetry.d.ts.map +1 -0
- package/dist/lib/terms.d.ts +8 -0
- package/dist/lib/terms.d.ts.map +1 -0
- package/dist/lib/tunnel-manager.d.ts +82 -0
- package/dist/lib/tunnel-manager.d.ts.map +1 -0
- package/dist/lib/tunnel-utils.d.ts +20 -0
- package/dist/lib/tunnel-utils.d.ts.map +1 -0
- package/package.json +104 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4976 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/lib/readline-instance.ts
|
|
12
|
+
import * as readline from "readline";
|
|
13
|
+
import chalk2 from "chalk";
|
|
14
|
+
function getReadlineInterface() {
|
|
15
|
+
if (!readlineInstance) {
|
|
16
|
+
initializeReadline();
|
|
17
|
+
}
|
|
18
|
+
return readlineInstance;
|
|
19
|
+
}
|
|
20
|
+
function initializeReadline(isInteractive = false, completer) {
|
|
21
|
+
if (readlineInstance) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
readlineInstance = readline.createInterface({
|
|
25
|
+
input: process.stdin,
|
|
26
|
+
output: process.stdout,
|
|
27
|
+
prompt: isInteractive ? chalk2.cyan("periscope> ") : void 0,
|
|
28
|
+
completer
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function closeReadline() {
|
|
32
|
+
if (readlineInstance) {
|
|
33
|
+
readlineInstance.close();
|
|
34
|
+
readlineInstance = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isReadlineActive() {
|
|
38
|
+
return readlineInstance !== null;
|
|
39
|
+
}
|
|
40
|
+
var readlineInstance;
|
|
41
|
+
var init_readline_instance = __esm({
|
|
42
|
+
"src/lib/readline-instance.ts"() {
|
|
43
|
+
"use strict";
|
|
44
|
+
readlineInstance = null;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// src/lib/telemetry.ts
|
|
49
|
+
var telemetry_exports = {};
|
|
50
|
+
__export(telemetry_exports, {
|
|
51
|
+
flushTelemetry: () => flushTelemetry,
|
|
52
|
+
initTelemetry: () => initTelemetry,
|
|
53
|
+
setUserContext: () => setUserContext,
|
|
54
|
+
shutdownTelemetry: () => shutdownTelemetry,
|
|
55
|
+
trackEvent: () => trackEvent,
|
|
56
|
+
trackException: () => trackException
|
|
57
|
+
});
|
|
58
|
+
import * as os2 from "os";
|
|
59
|
+
import * as fs6 from "fs";
|
|
60
|
+
import * as path5 from "path";
|
|
61
|
+
import { fileURLToPath } from "url";
|
|
62
|
+
function getCliVersion() {
|
|
63
|
+
try {
|
|
64
|
+
const __dirname = path5.dirname(fileURLToPath(import.meta.url));
|
|
65
|
+
const pkg = JSON.parse(
|
|
66
|
+
fs6.readFileSync(path5.join(__dirname, "..", "..", "package.json"), "utf-8")
|
|
67
|
+
);
|
|
68
|
+
return pkg.version ?? "unknown";
|
|
69
|
+
} catch {
|
|
70
|
+
return "unknown";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function initTelemetry(connectionString) {
|
|
74
|
+
if (initialized) return false;
|
|
75
|
+
if (!connectionString) return false;
|
|
76
|
+
try {
|
|
77
|
+
process.env.OTEL_SERVICE_NAME = "periscope-cli";
|
|
78
|
+
const appInsights = await import("applicationinsights");
|
|
79
|
+
appInsights.default.setup(connectionString).setAutoCollectRequests(false).setAutoCollectPerformance(false, false).setAutoCollectExceptions(false).setAutoCollectDependencies(true).setAutoCollectConsole(false).setAutoCollectPreAggregatedMetrics(false).setUseDiskRetryCaching(false).start();
|
|
80
|
+
client2 = appInsights.default.defaultClient;
|
|
81
|
+
if (client2) {
|
|
82
|
+
const version = getCliVersion();
|
|
83
|
+
client2.commonProperties = {
|
|
84
|
+
cliVersion: version,
|
|
85
|
+
nodeVersion: process.version,
|
|
86
|
+
platform: os2.platform(),
|
|
87
|
+
arch: os2.arch()
|
|
88
|
+
};
|
|
89
|
+
initialized = true;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
client2 = null;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
function trackException(error, properties) {
|
|
98
|
+
if (!client2) return;
|
|
99
|
+
const exception = error instanceof Error ? error : new Error(String(error));
|
|
100
|
+
const enriched = userEmail ? { email: userEmail, ...properties } : properties;
|
|
101
|
+
client2.trackException({
|
|
102
|
+
exception,
|
|
103
|
+
properties: enriched
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function trackEvent(name, properties) {
|
|
107
|
+
if (!client2) return;
|
|
108
|
+
const enriched = userEmail ? { email: userEmail, ...properties } : properties;
|
|
109
|
+
client2.trackEvent({ name, properties: enriched });
|
|
110
|
+
}
|
|
111
|
+
function setUserContext(email) {
|
|
112
|
+
if (email) {
|
|
113
|
+
userEmail = email;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function flushTelemetry() {
|
|
117
|
+
if (!client2) return;
|
|
118
|
+
try {
|
|
119
|
+
await Promise.race([
|
|
120
|
+
client2.flush(),
|
|
121
|
+
new Promise((resolve) => setTimeout(resolve, 5e3))
|
|
122
|
+
]);
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function shutdownTelemetry() {
|
|
127
|
+
await flushTelemetry();
|
|
128
|
+
if (client2) {
|
|
129
|
+
try {
|
|
130
|
+
const appInsights = await import("applicationinsights");
|
|
131
|
+
appInsights.dispose();
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
client2 = null;
|
|
135
|
+
initialized = false;
|
|
136
|
+
userEmail = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
var client2, initialized, userEmail;
|
|
140
|
+
var init_telemetry = __esm({
|
|
141
|
+
"src/lib/telemetry.ts"() {
|
|
142
|
+
"use strict";
|
|
143
|
+
client2 = null;
|
|
144
|
+
initialized = false;
|
|
145
|
+
userEmail = null;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// src/lib/process-lifecycle.ts
|
|
150
|
+
var process_lifecycle_exports = {};
|
|
151
|
+
__export(process_lifecycle_exports, {
|
|
152
|
+
gracefulExit: () => gracefulExit
|
|
153
|
+
});
|
|
154
|
+
async function gracefulExit(code = 0) {
|
|
155
|
+
await shutdownTelemetry();
|
|
156
|
+
closeReadline();
|
|
157
|
+
process.exit(code);
|
|
158
|
+
}
|
|
159
|
+
var init_process_lifecycle = __esm({
|
|
160
|
+
"src/lib/process-lifecycle.ts"() {
|
|
161
|
+
"use strict";
|
|
162
|
+
init_readline_instance();
|
|
163
|
+
init_telemetry();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// package.json
|
|
168
|
+
var package_exports = {};
|
|
169
|
+
__export(package_exports, {
|
|
170
|
+
default: () => package_default
|
|
171
|
+
});
|
|
172
|
+
var package_default;
|
|
173
|
+
var init_package = __esm({
|
|
174
|
+
"package.json"() {
|
|
175
|
+
package_default = {
|
|
176
|
+
name: "@elf5/periscope",
|
|
177
|
+
version: "1.0.64",
|
|
178
|
+
description: "CLI client for Periscope SSH tunnel server",
|
|
179
|
+
main: "dist/index.js",
|
|
180
|
+
types: "dist/index.d.ts",
|
|
181
|
+
bin: {
|
|
182
|
+
periscope: "./dist/cli.js"
|
|
183
|
+
},
|
|
184
|
+
scripts: {
|
|
185
|
+
build: "node scripts/build.mjs",
|
|
186
|
+
"build:tsc": "tsc",
|
|
187
|
+
dev: "tsc --watch",
|
|
188
|
+
clean: "rimraf dist",
|
|
189
|
+
prepublishOnly: "npm run clean && npm run build",
|
|
190
|
+
start: "node dist/cli.js",
|
|
191
|
+
cli: "npm run build && node dist/cli.js",
|
|
192
|
+
test: "npm run test:unit && npm run test:offline",
|
|
193
|
+
"test:watch": "vitest --config vitest.config.unit.js",
|
|
194
|
+
"test:ui": "vitest --ui --config vitest.config.unit.js",
|
|
195
|
+
"test:unit": "vitest run --coverage --config vitest.config.unit.js",
|
|
196
|
+
"test:offline": "vitest run --config vitest.config.integration.js",
|
|
197
|
+
"test:e2e": "vitest run --config vitest.config.e2e.js",
|
|
198
|
+
"test:ci": "npm run test && npm run test:e2e",
|
|
199
|
+
format: "prettier --write src/**/*.ts",
|
|
200
|
+
"format:check": "prettier --check src/**/*.ts",
|
|
201
|
+
lint: "eslint src",
|
|
202
|
+
"lint:fix": "eslint src --fix",
|
|
203
|
+
"test-server": "npx tsx src/__tests__/e2e/test-server.ts",
|
|
204
|
+
prepare: "husky"
|
|
205
|
+
},
|
|
206
|
+
keywords: [
|
|
207
|
+
"ssh",
|
|
208
|
+
"tunnel",
|
|
209
|
+
"cli",
|
|
210
|
+
"periscope",
|
|
211
|
+
"port-forwarding"
|
|
212
|
+
],
|
|
213
|
+
author: "Elf 5",
|
|
214
|
+
type: "module",
|
|
215
|
+
license: "MIT",
|
|
216
|
+
repository: {
|
|
217
|
+
type: "git",
|
|
218
|
+
url: "https://github.com/elf-5/periscope-client-npm.git"
|
|
219
|
+
},
|
|
220
|
+
bugs: {
|
|
221
|
+
url: "https://github.com/elf-5/periscope-client-npm/issues"
|
|
222
|
+
},
|
|
223
|
+
homepage: "https://github.com/elf-5/periscope-client-npm#readme",
|
|
224
|
+
engines: {
|
|
225
|
+
node: ">=22.0.0"
|
|
226
|
+
},
|
|
227
|
+
files: [
|
|
228
|
+
"dist",
|
|
229
|
+
"README.md",
|
|
230
|
+
"LICENSE"
|
|
231
|
+
],
|
|
232
|
+
overrides: {
|
|
233
|
+
glob: "^11.0.0",
|
|
234
|
+
uuid: "^10.0.0"
|
|
235
|
+
},
|
|
236
|
+
dependencies: {
|
|
237
|
+
"@azure/msal-node": "^2.6.6",
|
|
238
|
+
"@microsoft/dev-tunnels-connections": "^1.2.1",
|
|
239
|
+
"@microsoft/dev-tunnels-contracts": "^1.2.1",
|
|
240
|
+
"@microsoft/dev-tunnels-management": "^1.2.1",
|
|
241
|
+
"@microsoft/dev-tunnels-ssh": "^3.12.5",
|
|
242
|
+
"@microsoft/dev-tunnels-ssh-keys": "^3.12.5",
|
|
243
|
+
"@microsoft/dev-tunnels-ssh-tcp": "^3.12.5",
|
|
244
|
+
applicationinsights: "^3.13.0",
|
|
245
|
+
axios: "^1.7.2",
|
|
246
|
+
chalk: "^5.3.0",
|
|
247
|
+
commander: "^12.0.0",
|
|
248
|
+
dotenv: "^16.4.5",
|
|
249
|
+
open: "^10.0.3",
|
|
250
|
+
"openid-client": "^6.8.2"
|
|
251
|
+
},
|
|
252
|
+
"lint-staged": {
|
|
253
|
+
"*.ts": [
|
|
254
|
+
"prettier --write",
|
|
255
|
+
"eslint --fix"
|
|
256
|
+
]
|
|
257
|
+
},
|
|
258
|
+
devDependencies: {
|
|
259
|
+
"@elf-5/periscope-api-client": "^1.0.129",
|
|
260
|
+
"@types/node": "^20.14.0",
|
|
261
|
+
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
|
262
|
+
"@typescript-eslint/parser": "^8.41.0",
|
|
263
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
264
|
+
"@vitest/ui": "^3.2.4",
|
|
265
|
+
esbuild: "^0.27.3",
|
|
266
|
+
eslint: "^9.34.0",
|
|
267
|
+
"eslint-config-prettier": "^10.1.8",
|
|
268
|
+
globals: "^16.3.0",
|
|
269
|
+
husky: "^9.1.7",
|
|
270
|
+
"lint-staged": "^16.2.7",
|
|
271
|
+
prettier: "^3.8.1",
|
|
272
|
+
rimraf: "^6.0.0",
|
|
273
|
+
tsx: "^4.21.0",
|
|
274
|
+
typescript: "^5.4.5",
|
|
275
|
+
vitest: "^3.2.4",
|
|
276
|
+
"yocto-queue": "^1.2.1"
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// src/lib/msal-auth-manager.ts
|
|
283
|
+
import {
|
|
284
|
+
PublicClientApplication,
|
|
285
|
+
LogLevel
|
|
286
|
+
} from "@azure/msal-node";
|
|
287
|
+
import * as fs3 from "fs";
|
|
288
|
+
import * as path3 from "path";
|
|
289
|
+
import * as http2 from "http";
|
|
290
|
+
import * as crypto from "crypto";
|
|
291
|
+
import open2 from "open";
|
|
292
|
+
|
|
293
|
+
// src/lib/logger.ts
|
|
294
|
+
import chalk from "chalk";
|
|
295
|
+
import { createRequire } from "module";
|
|
296
|
+
var Logger = class _Logger {
|
|
297
|
+
config;
|
|
298
|
+
constructor(config2 = { level: 2 /* INFO */ }) {
|
|
299
|
+
this.config = config2;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Set the current log level
|
|
303
|
+
*/
|
|
304
|
+
setLevel(level) {
|
|
305
|
+
this.config.level = level;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Get the current log level
|
|
309
|
+
*/
|
|
310
|
+
getLevel() {
|
|
311
|
+
return this.config.level;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Check if a log level should be output
|
|
315
|
+
*/
|
|
316
|
+
shouldLog(level) {
|
|
317
|
+
return level <= this.config.level;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Format an argument for logging
|
|
321
|
+
* Stack traces are only included at DEBUG level or higher
|
|
322
|
+
*/
|
|
323
|
+
formatArg(arg) {
|
|
324
|
+
if (arg instanceof Error) {
|
|
325
|
+
if (this.shouldLog(3 /* DEBUG */) && arg.stack) {
|
|
326
|
+
return `${arg.message}
|
|
327
|
+
${arg.stack}`;
|
|
328
|
+
}
|
|
329
|
+
return arg.message;
|
|
330
|
+
} else if (typeof arg === "object" && arg !== null) {
|
|
331
|
+
try {
|
|
332
|
+
return JSON.stringify(arg);
|
|
333
|
+
} catch {
|
|
334
|
+
return String(arg);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
return String(arg);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Format the log message with optional timestamp and prefix
|
|
342
|
+
*/
|
|
343
|
+
formatMessage(level, message, ...args) {
|
|
344
|
+
let formatted = message;
|
|
345
|
+
if (args.length > 0) {
|
|
346
|
+
formatted = message + " " + args.map((arg) => this.formatArg(arg)).join(" ");
|
|
347
|
+
}
|
|
348
|
+
if (this.config.timestamp) {
|
|
349
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
350
|
+
formatted = `[${timestamp}] ${formatted}`;
|
|
351
|
+
}
|
|
352
|
+
if (this.config.prefix) {
|
|
353
|
+
formatted = `[${this.config.prefix}] ${formatted}`;
|
|
354
|
+
}
|
|
355
|
+
return formatted;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Error level logging (always shown unless level is below ERROR)
|
|
359
|
+
*/
|
|
360
|
+
error(message, ...args) {
|
|
361
|
+
if (this.shouldLog(0 /* ERROR */)) {
|
|
362
|
+
const formatted = this.formatMessage(0 /* ERROR */, message, ...args);
|
|
363
|
+
console.error(chalk.red("\u274C " + formatted));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Warning level logging
|
|
368
|
+
*/
|
|
369
|
+
warn(message, ...args) {
|
|
370
|
+
if (this.shouldLog(1 /* WARN */)) {
|
|
371
|
+
const formatted = this.formatMessage(1 /* WARN */, message, ...args);
|
|
372
|
+
console.warn(chalk.yellow("\u26A0\uFE0F " + formatted));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Info level logging (user-facing information)
|
|
377
|
+
*/
|
|
378
|
+
info(message, ...args) {
|
|
379
|
+
if (this.shouldLog(2 /* INFO */)) {
|
|
380
|
+
const formatted = this.formatMessage(2 /* INFO */, message, ...args);
|
|
381
|
+
console.log(chalk.cyan("\u2139\uFE0F " + formatted));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Success logging (special case of info)
|
|
386
|
+
*/
|
|
387
|
+
success(message, ...args) {
|
|
388
|
+
if (this.shouldLog(2 /* INFO */)) {
|
|
389
|
+
const formatted = this.formatMessage(2 /* INFO */, message, ...args);
|
|
390
|
+
console.log(chalk.green("\u2705 " + formatted));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Debug level logging (internal operations, verbose)
|
|
395
|
+
*/
|
|
396
|
+
debug(message, ...args) {
|
|
397
|
+
if (this.shouldLog(3 /* DEBUG */)) {
|
|
398
|
+
const formatted = this.formatMessage(3 /* DEBUG */, message, ...args);
|
|
399
|
+
console.log(chalk.gray("\u{1F50D} " + formatted));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Trace level logging (very detailed, internal state)
|
|
404
|
+
*/
|
|
405
|
+
trace(message, ...args) {
|
|
406
|
+
if (this.shouldLog(4 /* TRACE */)) {
|
|
407
|
+
const formatted = this.formatMessage(4 /* TRACE */, message, ...args);
|
|
408
|
+
console.log(chalk.dim("\u{1F52C} " + formatted));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Raw output without formatting (for CLI output that shouldn't be logged)
|
|
413
|
+
*/
|
|
414
|
+
raw(message, ...args) {
|
|
415
|
+
if (args.length > 0) {
|
|
416
|
+
console.log(message, ...args);
|
|
417
|
+
} else {
|
|
418
|
+
console.log(message);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Print a blank line without any icon or prefix.
|
|
423
|
+
*/
|
|
424
|
+
blank() {
|
|
425
|
+
console.log("");
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Print a section header: blank line + bold text, no icon.
|
|
429
|
+
* Use instead of log.info('\nSection Title:') for section headings.
|
|
430
|
+
*/
|
|
431
|
+
header(text) {
|
|
432
|
+
console.log("");
|
|
433
|
+
console.log(chalk.bold(text));
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Print a horizontal separator line without any icon or prefix.
|
|
437
|
+
*/
|
|
438
|
+
separator(length = 40) {
|
|
439
|
+
console.log("\u2500".repeat(length));
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Create a child logger with additional prefix
|
|
443
|
+
*/
|
|
444
|
+
child(prefix) {
|
|
445
|
+
const childPrefix = this.config.prefix ? `${this.config.prefix}:${prefix}` : prefix;
|
|
446
|
+
return new _Logger({
|
|
447
|
+
...this.config,
|
|
448
|
+
prefix: childPrefix
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
var globalLogger = null;
|
|
453
|
+
function getLogger() {
|
|
454
|
+
if (!globalLogger) {
|
|
455
|
+
const logLevel = getLogLevelFromEnv();
|
|
456
|
+
globalLogger = new Logger({
|
|
457
|
+
level: logLevel,
|
|
458
|
+
timestamp: false
|
|
459
|
+
// Disable timestamps by default for CLI
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return globalLogger;
|
|
463
|
+
}
|
|
464
|
+
function getLogLevelFromEnv() {
|
|
465
|
+
let envLevel = process.env.PERISCOPE_LOG_LEVEL?.toUpperCase();
|
|
466
|
+
if (!envLevel) {
|
|
467
|
+
try {
|
|
468
|
+
const require2 = createRequire(import.meta.url);
|
|
469
|
+
const { ConfigManager: ConfigManager2 } = require2("./config-manager.js");
|
|
470
|
+
const config2 = ConfigManager2.load();
|
|
471
|
+
envLevel = config2.logLevel?.toUpperCase();
|
|
472
|
+
} catch {
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
switch (envLevel) {
|
|
476
|
+
case "ERROR":
|
|
477
|
+
return 0 /* ERROR */;
|
|
478
|
+
case "WARN":
|
|
479
|
+
case "WARNING":
|
|
480
|
+
return 1 /* WARN */;
|
|
481
|
+
case "INFO":
|
|
482
|
+
return 2 /* INFO */;
|
|
483
|
+
case "DEBUG":
|
|
484
|
+
return 3 /* DEBUG */;
|
|
485
|
+
case "TRACE":
|
|
486
|
+
return 4 /* TRACE */;
|
|
487
|
+
default:
|
|
488
|
+
return 2 /* INFO */;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
var log = {
|
|
492
|
+
error: (message, ...args) => getLogger().error(message, ...args),
|
|
493
|
+
warn: (message, ...args) => getLogger().warn(message, ...args),
|
|
494
|
+
info: (message, ...args) => getLogger().info(message, ...args),
|
|
495
|
+
success: (message, ...args) => getLogger().success(message, ...args),
|
|
496
|
+
debug: (message, ...args) => getLogger().debug(message, ...args),
|
|
497
|
+
trace: (message, ...args) => getLogger().trace(message, ...args),
|
|
498
|
+
raw: (message, ...args) => getLogger().raw(message, ...args),
|
|
499
|
+
blank: () => getLogger().blank(),
|
|
500
|
+
header: (text) => getLogger().header(text),
|
|
501
|
+
separator: (length) => getLogger().separator(length)
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// src/lib/cache-utils.ts
|
|
505
|
+
import * as fs from "fs";
|
|
506
|
+
import * as path from "path";
|
|
507
|
+
import * as os from "os";
|
|
508
|
+
var CACHE_SUBDIR = ".periscope";
|
|
509
|
+
function getCacheDir() {
|
|
510
|
+
return path.join(os.homedir(), CACHE_SUBDIR);
|
|
511
|
+
}
|
|
512
|
+
function ensureCacheDir() {
|
|
513
|
+
const cacheDir = getCacheDir();
|
|
514
|
+
if (!fs.existsSync(cacheDir)) {
|
|
515
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function writeSecureFile(filePath, data) {
|
|
519
|
+
ensureCacheDir();
|
|
520
|
+
fs.writeFileSync(filePath, data, { mode: 384 });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/lib/msal-cache-plugin.ts
|
|
524
|
+
import * as fs2 from "fs";
|
|
525
|
+
import * as path2 from "path";
|
|
526
|
+
var MSAL_CACHE_FILE = "msal-cache.json";
|
|
527
|
+
function getMsalCacheFilePath() {
|
|
528
|
+
return path2.join(getCacheDir(), MSAL_CACHE_FILE);
|
|
529
|
+
}
|
|
530
|
+
function createMsalCachePlugin() {
|
|
531
|
+
return {
|
|
532
|
+
async beforeCacheAccess(cacheContext) {
|
|
533
|
+
const cachePath = getMsalCacheFilePath();
|
|
534
|
+
try {
|
|
535
|
+
if (fs2.existsSync(cachePath)) {
|
|
536
|
+
const data = fs2.readFileSync(cachePath, "utf-8");
|
|
537
|
+
cacheContext.tokenCache.deserialize(data);
|
|
538
|
+
}
|
|
539
|
+
} catch (error) {
|
|
540
|
+
log.debug("Failed to read MSAL cache from disk:", error);
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
async afterCacheAccess(cacheContext) {
|
|
544
|
+
if (cacheContext.cacheHasChanged) {
|
|
545
|
+
try {
|
|
546
|
+
const cachePath = getMsalCacheFilePath();
|
|
547
|
+
const data = cacheContext.tokenCache.serialize();
|
|
548
|
+
writeSecureFile(cachePath, data);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
log.error("Failed to write MSAL cache to disk:", error);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function clearMsalCache() {
|
|
557
|
+
try {
|
|
558
|
+
const cachePath = getMsalCacheFilePath();
|
|
559
|
+
if (fs2.existsSync(cachePath)) {
|
|
560
|
+
fs2.unlinkSync(cachePath);
|
|
561
|
+
}
|
|
562
|
+
} catch (error) {
|
|
563
|
+
log.error("Failed to clear MSAL cache:", error);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/lib/auth-callback-server.ts
|
|
568
|
+
import * as http from "http";
|
|
569
|
+
import open from "open";
|
|
570
|
+
function escapeHtml(s) {
|
|
571
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
572
|
+
}
|
|
573
|
+
function listenForAuthCode(port, authUrl, expectedState) {
|
|
574
|
+
return new Promise((resolve, reject) => {
|
|
575
|
+
let timeoutHandle;
|
|
576
|
+
const done = (result) => {
|
|
577
|
+
clearTimeout(timeoutHandle);
|
|
578
|
+
server.close();
|
|
579
|
+
resolve(result);
|
|
580
|
+
};
|
|
581
|
+
const server = http.createServer((req, res) => {
|
|
582
|
+
const url = new URL(req.url || "", `http://localhost:${port}`);
|
|
583
|
+
const code = url.searchParams.get("code");
|
|
584
|
+
const error = url.searchParams.get("error");
|
|
585
|
+
if (error) {
|
|
586
|
+
const safeError = escapeHtml(error);
|
|
587
|
+
const safeDesc = escapeHtml(
|
|
588
|
+
url.searchParams.get("error_description") || ""
|
|
589
|
+
);
|
|
590
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
591
|
+
res.end(`
|
|
592
|
+
<html>
|
|
593
|
+
<body style="font-family: system-ui; text-align: center; padding: 50px;">
|
|
594
|
+
<h1>Authentication Failed</h1>
|
|
595
|
+
<p>Error: ${safeError}</p>
|
|
596
|
+
<p>${safeDesc}</p>
|
|
597
|
+
<p>You can close this window.</p>
|
|
598
|
+
</body>
|
|
599
|
+
</html>
|
|
600
|
+
`);
|
|
601
|
+
done(null);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (code) {
|
|
605
|
+
const callbackState = url.searchParams.get("state");
|
|
606
|
+
if (expectedState && callbackState !== expectedState) {
|
|
607
|
+
log.debug(
|
|
608
|
+
`Ignoring stale callback (state mismatch: expected ${expectedState.slice(0, 8)}\u2026, got ${callbackState?.slice(0, 8)}\u2026)`
|
|
609
|
+
);
|
|
610
|
+
const safeLocation2 = authUrl.replace(/[\r\n]/g, "");
|
|
611
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
612
|
+
res.end(`
|
|
613
|
+
<html>
|
|
614
|
+
<head>
|
|
615
|
+
<meta charset="UTF-8">
|
|
616
|
+
<title>Authentication - Retry</title>
|
|
617
|
+
</head>
|
|
618
|
+
<body style="font-family: system-ui; text-align: center; padding: 50px;">
|
|
619
|
+
<h1>Session Expired</h1>
|
|
620
|
+
<p>This callback was from a previous login attempt. Redirecting...</p>
|
|
621
|
+
<script>window.location.href = ${JSON.stringify(safeLocation2)};</script>
|
|
622
|
+
</body>
|
|
623
|
+
</html>
|
|
624
|
+
`);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
628
|
+
res.end(`
|
|
629
|
+
<html>
|
|
630
|
+
<head>
|
|
631
|
+
<meta charset="UTF-8">
|
|
632
|
+
<title>Authentication Successful</title>
|
|
633
|
+
</head>
|
|
634
|
+
<body style="font-family: system-ui; text-align: center; padding: 50px;">
|
|
635
|
+
<h1>✓ Authentication Successful</h1>
|
|
636
|
+
<p>You can close this window and return to the terminal.</p>
|
|
637
|
+
<script>window.setTimeout(() => window.close(), 2000);</script>
|
|
638
|
+
</body>
|
|
639
|
+
</html>
|
|
640
|
+
`);
|
|
641
|
+
done({ code, fullPath: url.pathname + url.search });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const safeLocation = authUrl.replace(/[\r\n]/g, "");
|
|
645
|
+
res.writeHead(302, { Location: safeLocation });
|
|
646
|
+
res.end();
|
|
647
|
+
});
|
|
648
|
+
server.on("error", (err) => {
|
|
649
|
+
clearTimeout(timeoutHandle);
|
|
650
|
+
server.close();
|
|
651
|
+
if (err.code === "EADDRINUSE") {
|
|
652
|
+
reject(
|
|
653
|
+
new Error(
|
|
654
|
+
`Port ${port} is already in use. Another authentication flow may be in progress.`
|
|
655
|
+
)
|
|
656
|
+
);
|
|
657
|
+
} else {
|
|
658
|
+
log.warn(`Auth callback server error: ${err.message}`);
|
|
659
|
+
resolve(null);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
server.listen(port, "127.0.0.1", () => {
|
|
663
|
+
log.blank();
|
|
664
|
+
log.info("Opening browser for authentication...");
|
|
665
|
+
log.info("If the browser doesn't open automatically, visit:");
|
|
666
|
+
log.info(` http://localhost:${port}`);
|
|
667
|
+
log.blank();
|
|
668
|
+
open(authUrl).catch(() => {
|
|
669
|
+
log.warn(
|
|
670
|
+
"Failed to open browser automatically. Please open the URL manually."
|
|
671
|
+
);
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
timeoutHandle = setTimeout(() => done(null), 5 * 60 * 1e3);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/lib/auth-types.ts
|
|
679
|
+
var TOKEN_EXPIRY_BUFFER_MS = 6e4;
|
|
680
|
+
function isTokenExpired(expiresAt) {
|
|
681
|
+
return expiresAt <= new Date(Date.now() + TOKEN_EXPIRY_BUFFER_MS);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// src/lib/msal-auth-manager.ts
|
|
685
|
+
var MsalAuthManager = class _MsalAuthManager {
|
|
686
|
+
static TOKEN_CACHE_FILE = "token-cache.json";
|
|
687
|
+
msalApp = null;
|
|
688
|
+
config = null;
|
|
689
|
+
constructor(config2) {
|
|
690
|
+
if (config2) {
|
|
691
|
+
this.config = config2;
|
|
692
|
+
this.initializeMSAL();
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
initializeMSAL() {
|
|
696
|
+
if (!this.config) {
|
|
697
|
+
throw new Error("MSAL configuration is required");
|
|
698
|
+
}
|
|
699
|
+
const msalConfig = {
|
|
700
|
+
auth: {
|
|
701
|
+
clientId: this.config.clientId,
|
|
702
|
+
authority: this.config.authority,
|
|
703
|
+
// B2C requires knownAuthorities to include the B2C login domain
|
|
704
|
+
knownAuthorities: _MsalAuthManager.extractKnownAuthorities(
|
|
705
|
+
this.config.authority
|
|
706
|
+
)
|
|
707
|
+
},
|
|
708
|
+
cache: {
|
|
709
|
+
cachePlugin: createMsalCachePlugin()
|
|
710
|
+
},
|
|
711
|
+
system: {
|
|
712
|
+
loggerOptions: {
|
|
713
|
+
loggerCallback(loglevel, message) {
|
|
714
|
+
if (loglevel === LogLevel.Error) {
|
|
715
|
+
log.error("MSAL Error:", message);
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
piiLoggingEnabled: false,
|
|
719
|
+
logLevel: LogLevel.Error
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
this.msalApp = new PublicClientApplication(msalConfig);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Extract knownAuthorities from an authority URL for non-standard authority domains.
|
|
727
|
+
* B2C (*.b2clogin.com) and Entra External ID (*.ciamlogin.com) must be listed in knownAuthorities.
|
|
728
|
+
*/
|
|
729
|
+
static extractKnownAuthorities(authority) {
|
|
730
|
+
try {
|
|
731
|
+
const url = new URL(authority);
|
|
732
|
+
if (url.hostname.includes(".b2clogin.com") || url.hostname.includes(".ciamlogin.com")) {
|
|
733
|
+
return [url.hostname];
|
|
734
|
+
}
|
|
735
|
+
} catch {
|
|
736
|
+
}
|
|
737
|
+
return [];
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Authenticate user using device code flow
|
|
741
|
+
* This will display a device code and open the browser for authentication
|
|
742
|
+
*/
|
|
743
|
+
async authenticate() {
|
|
744
|
+
if (!this.msalApp || !this.config) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
"MSAL not initialized. Please configure authentication first."
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
const cachedToken = this.getCachedToken();
|
|
751
|
+
if (cachedToken && !isTokenExpired(cachedToken.expiresOn)) {
|
|
752
|
+
return cachedToken;
|
|
753
|
+
}
|
|
754
|
+
const deviceCodeRequest = {
|
|
755
|
+
scopes: this.config.scopes,
|
|
756
|
+
deviceCodeCallback: (response2) => {
|
|
757
|
+
log.blank();
|
|
758
|
+
log.info("To authenticate, use a web browser to open the page:");
|
|
759
|
+
log.info(` ${response2.verificationUri}`);
|
|
760
|
+
log.blank();
|
|
761
|
+
log.info("And enter the code:");
|
|
762
|
+
log.info(` ${response2.userCode}`);
|
|
763
|
+
log.blank();
|
|
764
|
+
log.info("Opening browser automatically...");
|
|
765
|
+
log.blank();
|
|
766
|
+
open2(response2.verificationUri).catch(() => {
|
|
767
|
+
log.warn(
|
|
768
|
+
"Failed to open browser automatically. Please open the URL manually."
|
|
769
|
+
);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
const response = await this.msalApp.acquireTokenByDeviceCode(deviceCodeRequest);
|
|
774
|
+
if (!response) {
|
|
775
|
+
throw new Error("Failed to acquire authentication token");
|
|
776
|
+
}
|
|
777
|
+
const authToken = {
|
|
778
|
+
accessToken: response.accessToken,
|
|
779
|
+
expiresOn: response.expiresOn || new Date(Date.now() + 36e5)
|
|
780
|
+
// Default 1 hour
|
|
781
|
+
// Note: MSAL node may not provide refresh token in all flows
|
|
782
|
+
};
|
|
783
|
+
this.cacheToken(authToken);
|
|
784
|
+
return authToken;
|
|
785
|
+
} catch (error) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
`Authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Authenticate user using interactive browser flow with prompt control
|
|
793
|
+
* This opens the system browser for authentication and handles the redirect
|
|
794
|
+
*/
|
|
795
|
+
async authenticateInteractive(prompt = "select_account") {
|
|
796
|
+
if (!this.msalApp || !this.config) {
|
|
797
|
+
throw new Error(
|
|
798
|
+
"MSAL not initialized. Please configure authentication first."
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
try {
|
|
802
|
+
if (prompt === "none") {
|
|
803
|
+
try {
|
|
804
|
+
const accounts = await this.msalApp.getAllAccounts();
|
|
805
|
+
if (accounts && accounts.length > 0) {
|
|
806
|
+
const silentRequest = {
|
|
807
|
+
scopes: this.config.scopes,
|
|
808
|
+
account: accounts[0]
|
|
809
|
+
// Use the first available account
|
|
810
|
+
};
|
|
811
|
+
const silentResponse = await this.msalApp.acquireTokenSilent(silentRequest);
|
|
812
|
+
if (silentResponse) {
|
|
813
|
+
const authToken2 = {
|
|
814
|
+
accessToken: silentResponse.accessToken,
|
|
815
|
+
expiresOn: silentResponse.expiresOn || new Date(Date.now() + 36e5)
|
|
816
|
+
};
|
|
817
|
+
this.cacheToken(authToken2);
|
|
818
|
+
return authToken2;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
} catch {
|
|
822
|
+
throw new Error(
|
|
823
|
+
"Silent authentication failed. User interaction required."
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
log.info(
|
|
828
|
+
`
|
|
829
|
+
Starting browser authentication with prompt behavior: ${prompt}`
|
|
830
|
+
);
|
|
831
|
+
const redirectPort = await this.getAvailablePort();
|
|
832
|
+
const redirectUri = `http://localhost:${redirectPort}`;
|
|
833
|
+
const pkceCodes = {
|
|
834
|
+
challenge: "",
|
|
835
|
+
verifier: ""
|
|
836
|
+
};
|
|
837
|
+
pkceCodes.verifier = crypto.randomBytes(32).toString("base64url");
|
|
838
|
+
pkceCodes.challenge = crypto.createHash("sha256").update(pkceCodes.verifier).digest("base64url");
|
|
839
|
+
const authCodeUrlParameters = {
|
|
840
|
+
scopes: this.config.scopes,
|
|
841
|
+
redirectUri,
|
|
842
|
+
codeChallenge: pkceCodes.challenge,
|
|
843
|
+
codeChallengeMethod: "S256",
|
|
844
|
+
prompt,
|
|
845
|
+
// MSAL expects specific prompt values
|
|
846
|
+
responseMode: "query"
|
|
847
|
+
};
|
|
848
|
+
const authCodeUrl = await this.msalApp.getAuthCodeUrl(
|
|
849
|
+
authCodeUrlParameters
|
|
850
|
+
);
|
|
851
|
+
const result = await listenForAuthCode(redirectPort, authCodeUrl);
|
|
852
|
+
if (!result) {
|
|
853
|
+
throw new Error("Failed to receive authorization code");
|
|
854
|
+
}
|
|
855
|
+
const tokenRequest = {
|
|
856
|
+
code: result.code,
|
|
857
|
+
scopes: this.config.scopes,
|
|
858
|
+
redirectUri,
|
|
859
|
+
codeVerifier: pkceCodes.verifier
|
|
860
|
+
};
|
|
861
|
+
const response = await this.msalApp.acquireTokenByCode(tokenRequest);
|
|
862
|
+
if (!response) {
|
|
863
|
+
throw new Error("Failed to acquire authentication token");
|
|
864
|
+
}
|
|
865
|
+
const authToken = {
|
|
866
|
+
accessToken: response.accessToken,
|
|
867
|
+
expiresOn: response.expiresOn || new Date(Date.now() + 36e5)
|
|
868
|
+
};
|
|
869
|
+
this.cacheToken(authToken);
|
|
870
|
+
return authToken;
|
|
871
|
+
} catch (error) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`Browser authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Get an available port for the redirect server
|
|
879
|
+
*/
|
|
880
|
+
async getAvailablePort() {
|
|
881
|
+
return new Promise((resolve) => {
|
|
882
|
+
const server = http2.createServer();
|
|
883
|
+
server.listen(0, "127.0.0.1", () => {
|
|
884
|
+
const address = server.address();
|
|
885
|
+
const port = address && typeof address !== "string" ? address.port : 3e3;
|
|
886
|
+
server.close(() => resolve(port));
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Try to get a valid token without user interaction.
|
|
892
|
+
* Checks the simple cache first, then attempts MSAL silent refresh.
|
|
893
|
+
* Returns null if no valid token is available.
|
|
894
|
+
*/
|
|
895
|
+
async tryGetValidTokenSilently() {
|
|
896
|
+
const cachedToken = this.getCachedToken();
|
|
897
|
+
if (cachedToken && !isTokenExpired(cachedToken.expiresOn)) {
|
|
898
|
+
const ttlMin = Math.round(
|
|
899
|
+
(cachedToken.expiresOn.getTime() - Date.now()) / 6e4
|
|
900
|
+
);
|
|
901
|
+
log.debug(`Using cached MSAL token (expires in ${ttlMin}m)`);
|
|
902
|
+
return cachedToken.accessToken;
|
|
903
|
+
}
|
|
904
|
+
if (this.msalApp && this.config) {
|
|
905
|
+
try {
|
|
906
|
+
const accounts = await this.msalApp.getAllAccounts();
|
|
907
|
+
if (accounts.length > 0) {
|
|
908
|
+
log.debug("Attempting silent token refresh...");
|
|
909
|
+
const silentResult = await this.msalApp.acquireTokenSilent({
|
|
910
|
+
account: accounts[0],
|
|
911
|
+
scopes: this.config.scopes
|
|
912
|
+
});
|
|
913
|
+
if (silentResult) {
|
|
914
|
+
const refreshedToken = {
|
|
915
|
+
accessToken: silentResult.accessToken,
|
|
916
|
+
expiresOn: silentResult.expiresOn || new Date(Date.now() + 36e5)
|
|
917
|
+
};
|
|
918
|
+
this.cacheToken(refreshedToken);
|
|
919
|
+
const ttlMin = Math.round(
|
|
920
|
+
(refreshedToken.expiresOn.getTime() - Date.now()) / 6e4
|
|
921
|
+
);
|
|
922
|
+
log.debug(`Token silently refreshed (expires in ${ttlMin}m)`);
|
|
923
|
+
return refreshedToken.accessToken;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
} catch (error) {
|
|
927
|
+
log.debug(
|
|
928
|
+
"Silent token refresh failed, falling back to interactive auth:",
|
|
929
|
+
error
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Get a valid access token, refreshing if necessary
|
|
937
|
+
*/
|
|
938
|
+
async getValidToken() {
|
|
939
|
+
const token = await this.tryGetValidTokenSilently();
|
|
940
|
+
if (token) {
|
|
941
|
+
return token;
|
|
942
|
+
}
|
|
943
|
+
const newToken = await this.authenticate();
|
|
944
|
+
return newToken.accessToken;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Check if user is currently authenticated with a valid token
|
|
948
|
+
*/
|
|
949
|
+
isAuthenticated() {
|
|
950
|
+
const cachedToken = this.getCachedToken();
|
|
951
|
+
return cachedToken !== null && !isTokenExpired(cachedToken.expiresOn);
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Clear all cached authentication data
|
|
955
|
+
*/
|
|
956
|
+
logout() {
|
|
957
|
+
try {
|
|
958
|
+
const tokenCachePath = this.getTokenCachePath();
|
|
959
|
+
if (fs3.existsSync(tokenCachePath)) {
|
|
960
|
+
fs3.unlinkSync(tokenCachePath);
|
|
961
|
+
}
|
|
962
|
+
} catch (error) {
|
|
963
|
+
log.error("Error clearing token cache during logout:", error);
|
|
964
|
+
}
|
|
965
|
+
clearMsalCache();
|
|
966
|
+
}
|
|
967
|
+
getCachedToken() {
|
|
968
|
+
try {
|
|
969
|
+
const tokenCachePath = this.getTokenCachePath();
|
|
970
|
+
if (fs3.existsSync(tokenCachePath)) {
|
|
971
|
+
const content = fs3.readFileSync(tokenCachePath, "utf-8");
|
|
972
|
+
const data = JSON.parse(content);
|
|
973
|
+
return {
|
|
974
|
+
accessToken: data.accessToken,
|
|
975
|
+
expiresOn: new Date(data.expiresOn),
|
|
976
|
+
refreshToken: data.refreshToken
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
} catch (error) {
|
|
980
|
+
log.debug("Failed to load cached token:", error);
|
|
981
|
+
}
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
cacheToken(token) {
|
|
985
|
+
try {
|
|
986
|
+
const tokenCachePath = this.getTokenCachePath();
|
|
987
|
+
const tokenData = {
|
|
988
|
+
accessToken: token.accessToken,
|
|
989
|
+
expiresOn: token.expiresOn.toISOString(),
|
|
990
|
+
refreshToken: token.refreshToken
|
|
991
|
+
};
|
|
992
|
+
writeSecureFile(tokenCachePath, JSON.stringify(tokenData, null, 2));
|
|
993
|
+
} catch (error) {
|
|
994
|
+
log.error("Failed to cache token:", error);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
getTokenCachePath() {
|
|
998
|
+
return path3.join(getCacheDir(), _MsalAuthManager.TOKEN_CACHE_FILE);
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
// src/lib/auth0-auth-manager.ts
|
|
1003
|
+
import * as client from "openid-client";
|
|
1004
|
+
import * as fs4 from "fs";
|
|
1005
|
+
import * as path4 from "path";
|
|
1006
|
+
import open3 from "open";
|
|
1007
|
+
var Auth0AuthManager = class _Auth0AuthManager {
|
|
1008
|
+
static TOKEN_CACHE_FILE = "auth0-token-cache.json";
|
|
1009
|
+
static REDIRECT_PORT = 19836;
|
|
1010
|
+
oidcConfig = null;
|
|
1011
|
+
config = null;
|
|
1012
|
+
constructor(config2) {
|
|
1013
|
+
if (config2) {
|
|
1014
|
+
this.config = config2;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Discover OIDC endpoints and initialize the openid-client Configuration.
|
|
1019
|
+
* Lazy-loaded on first use.
|
|
1020
|
+
*/
|
|
1021
|
+
async ensureOidcConfig() {
|
|
1022
|
+
if (this.oidcConfig) return this.oidcConfig;
|
|
1023
|
+
if (!this.config) {
|
|
1024
|
+
throw new Error("Auth0 configuration is required");
|
|
1025
|
+
}
|
|
1026
|
+
const serverUrl = new URL(this.config.authority);
|
|
1027
|
+
this.oidcConfig = await client.discovery(
|
|
1028
|
+
serverUrl,
|
|
1029
|
+
this.config.clientId,
|
|
1030
|
+
void 0,
|
|
1031
|
+
client.None()
|
|
1032
|
+
);
|
|
1033
|
+
return this.oidcConfig;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Authenticate using the device code flow (RFC 8628).
|
|
1037
|
+
* Displays a code + URL, opens the browser, and polls for completion.
|
|
1038
|
+
*/
|
|
1039
|
+
async authenticate() {
|
|
1040
|
+
const oidcConfig = await this.ensureOidcConfig();
|
|
1041
|
+
const parameters = {
|
|
1042
|
+
scope: this.getScopes()
|
|
1043
|
+
};
|
|
1044
|
+
if (this.config?.audience) {
|
|
1045
|
+
parameters.audience = this.config.audience;
|
|
1046
|
+
}
|
|
1047
|
+
const deviceResponse = await client.initiateDeviceAuthorization(
|
|
1048
|
+
oidcConfig,
|
|
1049
|
+
parameters
|
|
1050
|
+
);
|
|
1051
|
+
const verificationUrl = deviceResponse.verification_uri_complete || deviceResponse.verification_uri;
|
|
1052
|
+
log.blank();
|
|
1053
|
+
log.info(
|
|
1054
|
+
"To authenticate, open the page below and confirm the code matches:"
|
|
1055
|
+
);
|
|
1056
|
+
log.info(` ${verificationUrl}`);
|
|
1057
|
+
log.blank();
|
|
1058
|
+
log.info(`Your code: ${deviceResponse.user_code}`);
|
|
1059
|
+
log.blank();
|
|
1060
|
+
log.info("Opening browser automatically...");
|
|
1061
|
+
log.blank();
|
|
1062
|
+
open3(verificationUrl).catch(() => {
|
|
1063
|
+
log.warn(
|
|
1064
|
+
"Failed to open browser automatically. Please open the URL manually."
|
|
1065
|
+
);
|
|
1066
|
+
});
|
|
1067
|
+
const tokenResponse = await client.pollDeviceAuthorizationGrant(
|
|
1068
|
+
oidcConfig,
|
|
1069
|
+
deviceResponse
|
|
1070
|
+
);
|
|
1071
|
+
return this.processTokenResponse(tokenResponse);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Authenticate using the interactive browser flow (Authorization Code + PKCE).
|
|
1075
|
+
* Opens the browser directly to Auth0 Universal Login — user enters email,
|
|
1076
|
+
* receives OTP, enters it, and is redirected back. Single code, no device confirmation.
|
|
1077
|
+
*/
|
|
1078
|
+
async authenticateInteractive(_prompt) {
|
|
1079
|
+
const oidcConfig = await this.ensureOidcConfig();
|
|
1080
|
+
const codeVerifier = client.randomPKCECodeVerifier();
|
|
1081
|
+
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
|
1082
|
+
const state = client.randomState();
|
|
1083
|
+
const port = _Auth0AuthManager.REDIRECT_PORT;
|
|
1084
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
1085
|
+
const parameters = {
|
|
1086
|
+
redirect_uri: redirectUri,
|
|
1087
|
+
scope: this.getScopes(),
|
|
1088
|
+
code_challenge: codeChallenge,
|
|
1089
|
+
code_challenge_method: "S256",
|
|
1090
|
+
state
|
|
1091
|
+
};
|
|
1092
|
+
if (this.config?.audience) {
|
|
1093
|
+
parameters.audience = this.config.audience;
|
|
1094
|
+
}
|
|
1095
|
+
const authUrl = client.buildAuthorizationUrl(oidcConfig, parameters);
|
|
1096
|
+
log.debug("Waiting for Auth0 callback on port", port);
|
|
1097
|
+
const result = await listenForAuthCode(port, authUrl.href, state);
|
|
1098
|
+
if (!result) {
|
|
1099
|
+
throw new Error("Failed to receive authorization code from Auth0");
|
|
1100
|
+
}
|
|
1101
|
+
log.debug("Received authorization code, exchanging for tokens...");
|
|
1102
|
+
log.debug("Callback fullPath:", result.fullPath);
|
|
1103
|
+
const callbackUrl = new URL(result.fullPath, `http://localhost:${port}`);
|
|
1104
|
+
try {
|
|
1105
|
+
const tokenResponse = await client.authorizationCodeGrant(
|
|
1106
|
+
oidcConfig,
|
|
1107
|
+
callbackUrl,
|
|
1108
|
+
{
|
|
1109
|
+
pkceCodeVerifier: codeVerifier,
|
|
1110
|
+
expectedState: state,
|
|
1111
|
+
idTokenExpected: true
|
|
1112
|
+
}
|
|
1113
|
+
);
|
|
1114
|
+
log.debug("Token exchange successful");
|
|
1115
|
+
return this.processTokenResponse(tokenResponse);
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
log.debug("Token exchange failed:", error);
|
|
1118
|
+
if (error instanceof Error && error.cause) {
|
|
1119
|
+
log.debug("Token exchange error cause:", error.cause);
|
|
1120
|
+
}
|
|
1121
|
+
throw error;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Try to get a valid token without user interaction.
|
|
1126
|
+
* Checks cache first, then tries refresh token.
|
|
1127
|
+
*/
|
|
1128
|
+
async tryGetValidTokenSilently() {
|
|
1129
|
+
const cached = this.getCachedTokens();
|
|
1130
|
+
if (!cached) return null;
|
|
1131
|
+
const expiresAt = new Date(cached.expiresAt);
|
|
1132
|
+
if (!isTokenExpired(expiresAt)) {
|
|
1133
|
+
const ttlMin = Math.round((expiresAt.getTime() - Date.now()) / 6e4);
|
|
1134
|
+
log.debug(`Using cached Auth0 token (expires in ${ttlMin}m)`);
|
|
1135
|
+
return cached.accessToken;
|
|
1136
|
+
}
|
|
1137
|
+
if (cached.refreshToken) {
|
|
1138
|
+
try {
|
|
1139
|
+
log.debug("Attempting Auth0 token refresh...");
|
|
1140
|
+
const token = await this.refreshAccessToken(cached.refreshToken);
|
|
1141
|
+
const ttlMin = Math.round(
|
|
1142
|
+
(token.expiresOn.getTime() - Date.now()) / 6e4
|
|
1143
|
+
);
|
|
1144
|
+
log.debug(`Auth0 token refreshed successfully (expires in ${ttlMin}m)`);
|
|
1145
|
+
return token.accessToken;
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
log.debug("Auth0 token refresh failed:", error);
|
|
1148
|
+
}
|
|
1149
|
+
} else {
|
|
1150
|
+
log.debug(
|
|
1151
|
+
"No refresh token available \u2014 cannot silently refresh. Re-authentication required."
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
return null;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Get a valid access token, refreshing or re-authenticating if necessary.
|
|
1158
|
+
*
|
|
1159
|
+
* Falls back to the device code flow (not browser PKCE) so this method can be
|
|
1160
|
+
* called in headless environments (e.g., mid-tunnel token refresh). Callers that
|
|
1161
|
+
* need browser-based re-authentication should call authenticateInteractive() directly.
|
|
1162
|
+
*/
|
|
1163
|
+
async getValidToken() {
|
|
1164
|
+
const token = await this.tryGetValidTokenSilently();
|
|
1165
|
+
if (token) return token;
|
|
1166
|
+
const newToken = await this.authenticate();
|
|
1167
|
+
return newToken.accessToken;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Check if a valid cached token exists.
|
|
1171
|
+
*/
|
|
1172
|
+
isAuthenticated() {
|
|
1173
|
+
const cached = this.getCachedTokens();
|
|
1174
|
+
if (!cached) return false;
|
|
1175
|
+
return !isTokenExpired(new Date(cached.expiresAt));
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Clear all cached Auth0 token data.
|
|
1179
|
+
*/
|
|
1180
|
+
logout() {
|
|
1181
|
+
try {
|
|
1182
|
+
const cachePath = this.getTokenCachePath();
|
|
1183
|
+
if (fs4.existsSync(cachePath)) {
|
|
1184
|
+
fs4.unlinkSync(cachePath);
|
|
1185
|
+
}
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
log.error("Error clearing Auth0 token cache during logout:", error);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Use a refresh token to obtain a new access token.
|
|
1192
|
+
*/
|
|
1193
|
+
async refreshAccessToken(refreshToken) {
|
|
1194
|
+
const oidcConfig = await this.ensureOidcConfig();
|
|
1195
|
+
const tokenResponse = await client.refreshTokenGrant(
|
|
1196
|
+
oidcConfig,
|
|
1197
|
+
refreshToken
|
|
1198
|
+
);
|
|
1199
|
+
return this.processTokenResponse(tokenResponse);
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Process a token endpoint response into our AuthToken format and cache it.
|
|
1203
|
+
*/
|
|
1204
|
+
processTokenResponse(response) {
|
|
1205
|
+
const expiresIn = response.expiresIn();
|
|
1206
|
+
const expiresOn = expiresIn ? new Date(Date.now() + expiresIn * 1e3) : new Date(Date.now() + 36e5);
|
|
1207
|
+
if (!response.refresh_token) {
|
|
1208
|
+
log.debug(
|
|
1209
|
+
"Auth0 token response did not include a refresh token. Token refresh will not be available when the access token expires."
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
const authToken = {
|
|
1213
|
+
accessToken: response.access_token,
|
|
1214
|
+
expiresOn,
|
|
1215
|
+
refreshToken: response.refresh_token
|
|
1216
|
+
};
|
|
1217
|
+
this.cacheTokens({
|
|
1218
|
+
accessToken: response.access_token,
|
|
1219
|
+
refreshToken: response.refresh_token,
|
|
1220
|
+
idToken: response.id_token,
|
|
1221
|
+
expiresAt: expiresOn.toISOString()
|
|
1222
|
+
});
|
|
1223
|
+
return authToken;
|
|
1224
|
+
}
|
|
1225
|
+
getScopes() {
|
|
1226
|
+
if (!this.config?.scopes?.length) {
|
|
1227
|
+
return "openid profile email offline_access";
|
|
1228
|
+
}
|
|
1229
|
+
const scopes = new Set(this.config.scopes);
|
|
1230
|
+
scopes.add("offline_access");
|
|
1231
|
+
return [...scopes].join(" ");
|
|
1232
|
+
}
|
|
1233
|
+
getCachedTokens() {
|
|
1234
|
+
try {
|
|
1235
|
+
const cachePath = this.getTokenCachePath();
|
|
1236
|
+
if (fs4.existsSync(cachePath)) {
|
|
1237
|
+
const content = fs4.readFileSync(cachePath, "utf-8");
|
|
1238
|
+
const parsed = JSON.parse(content);
|
|
1239
|
+
if (!parsed.accessToken || !parsed.expiresAt) return null;
|
|
1240
|
+
return parsed;
|
|
1241
|
+
}
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
log.debug("Failed to load Auth0 cached tokens:", error);
|
|
1244
|
+
}
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
cacheTokens(tokens) {
|
|
1248
|
+
try {
|
|
1249
|
+
const cachePath = this.getTokenCachePath();
|
|
1250
|
+
writeSecureFile(cachePath, JSON.stringify(tokens, null, 2));
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
log.error("Failed to cache Auth0 tokens:", error);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
getTokenCachePath() {
|
|
1256
|
+
return path4.join(getCacheDir(), _Auth0AuthManager.TOKEN_CACHE_FILE);
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
// node_modules/@elf-5/periscope-api-client/dist/Generated/PeriscopeApiClient.js
|
|
1261
|
+
var PeriscopeApi;
|
|
1262
|
+
(function(PeriscopeApi2) {
|
|
1263
|
+
class PeriscopeApiClient {
|
|
1264
|
+
constructor(baseUrl, http3) {
|
|
1265
|
+
this.jsonParseReviver = void 0;
|
|
1266
|
+
this.http = http3 ? http3 : window;
|
|
1267
|
+
this.baseUrl = baseUrl ?? "";
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* @param clientVersion (optional)
|
|
1271
|
+
* @return OK
|
|
1272
|
+
*/
|
|
1273
|
+
configuration(clientVersion) {
|
|
1274
|
+
let url_ = this.baseUrl + "/api/configuration?";
|
|
1275
|
+
if (clientVersion === null)
|
|
1276
|
+
throw new Error("The parameter 'clientVersion' cannot be null.");
|
|
1277
|
+
else if (clientVersion !== void 0)
|
|
1278
|
+
url_ += "clientVersion=" + encodeURIComponent("" + clientVersion) + "&";
|
|
1279
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1280
|
+
let options_ = {
|
|
1281
|
+
method: "GET",
|
|
1282
|
+
headers: {
|
|
1283
|
+
"Accept": "application/json"
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1287
|
+
return this.processConfiguration(_response);
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
processConfiguration(response) {
|
|
1291
|
+
const status = response.status;
|
|
1292
|
+
let _headers = {};
|
|
1293
|
+
if (response.headers && response.headers.forEach) {
|
|
1294
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1295
|
+
}
|
|
1296
|
+
;
|
|
1297
|
+
if (status === 200) {
|
|
1298
|
+
return response.text().then((_responseText) => {
|
|
1299
|
+
let result200 = null;
|
|
1300
|
+
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1301
|
+
return result200;
|
|
1302
|
+
});
|
|
1303
|
+
} else if (status === 503) {
|
|
1304
|
+
return response.text().then((_responseText) => {
|
|
1305
|
+
return throwException("Service Unavailable", status, _responseText, _headers);
|
|
1306
|
+
});
|
|
1307
|
+
} else if (status !== 200 && status !== 204) {
|
|
1308
|
+
return response.text().then((_responseText) => {
|
|
1309
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
return Promise.resolve(null);
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* @return OK
|
|
1316
|
+
*/
|
|
1317
|
+
telemetryGET() {
|
|
1318
|
+
let url_ = this.baseUrl + "/api/configuration/telemetry";
|
|
1319
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1320
|
+
let options_ = {
|
|
1321
|
+
method: "GET",
|
|
1322
|
+
headers: {
|
|
1323
|
+
"Accept": "application/json"
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1327
|
+
return this.processTelemetryGET(_response);
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
processTelemetryGET(response) {
|
|
1331
|
+
const status = response.status;
|
|
1332
|
+
let _headers = {};
|
|
1333
|
+
if (response.headers && response.headers.forEach) {
|
|
1334
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1335
|
+
}
|
|
1336
|
+
;
|
|
1337
|
+
if (status === 200) {
|
|
1338
|
+
return response.text().then((_responseText) => {
|
|
1339
|
+
let result200 = null;
|
|
1340
|
+
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1341
|
+
return result200;
|
|
1342
|
+
});
|
|
1343
|
+
} else if (status === 401) {
|
|
1344
|
+
return response.text().then((_responseText) => {
|
|
1345
|
+
let result401 = null;
|
|
1346
|
+
result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1347
|
+
return throwException("Unauthorized", status, _responseText, _headers, result401);
|
|
1348
|
+
});
|
|
1349
|
+
} else if (status !== 200 && status !== 204) {
|
|
1350
|
+
return response.text().then((_responseText) => {
|
|
1351
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
return Promise.resolve(null);
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* @param body (optional)
|
|
1358
|
+
* @return OK
|
|
1359
|
+
*/
|
|
1360
|
+
submitFeedback(body) {
|
|
1361
|
+
let url_ = this.baseUrl + "/api/feedback";
|
|
1362
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1363
|
+
const content_ = JSON.stringify(body);
|
|
1364
|
+
let options_ = {
|
|
1365
|
+
body: content_,
|
|
1366
|
+
method: "POST",
|
|
1367
|
+
headers: {
|
|
1368
|
+
"Content-Type": "application/json",
|
|
1369
|
+
"Accept": "application/json"
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1373
|
+
return this.processSubmitFeedback(_response);
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
processSubmitFeedback(response) {
|
|
1377
|
+
const status = response.status;
|
|
1378
|
+
let _headers = {};
|
|
1379
|
+
if (response.headers && response.headers.forEach) {
|
|
1380
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1381
|
+
}
|
|
1382
|
+
;
|
|
1383
|
+
if (status === 200) {
|
|
1384
|
+
return response.text().then((_responseText) => {
|
|
1385
|
+
let result200 = null;
|
|
1386
|
+
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1387
|
+
return result200;
|
|
1388
|
+
});
|
|
1389
|
+
} else if (status === 400) {
|
|
1390
|
+
return response.text().then((_responseText) => {
|
|
1391
|
+
let result400 = null;
|
|
1392
|
+
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1393
|
+
return throwException("Bad Request", status, _responseText, _headers, result400);
|
|
1394
|
+
});
|
|
1395
|
+
} else if (status !== 200 && status !== 204) {
|
|
1396
|
+
return response.text().then((_responseText) => {
|
|
1397
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
return Promise.resolve(null);
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* @return OK
|
|
1404
|
+
*/
|
|
1405
|
+
telemetryGET2() {
|
|
1406
|
+
let url_ = this.baseUrl + "/api/test/telemetry";
|
|
1407
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1408
|
+
let options_ = {
|
|
1409
|
+
method: "GET",
|
|
1410
|
+
headers: {}
|
|
1411
|
+
};
|
|
1412
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1413
|
+
return this.processTelemetryGET2(_response);
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
processTelemetryGET2(response) {
|
|
1417
|
+
const status = response.status;
|
|
1418
|
+
let _headers = {};
|
|
1419
|
+
if (response.headers && response.headers.forEach) {
|
|
1420
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1421
|
+
}
|
|
1422
|
+
;
|
|
1423
|
+
if (status === 200) {
|
|
1424
|
+
return response.text().then((_responseText) => {
|
|
1425
|
+
return;
|
|
1426
|
+
});
|
|
1427
|
+
} else if (status !== 200 && status !== 204) {
|
|
1428
|
+
return response.text().then((_responseText) => {
|
|
1429
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
return Promise.resolve(null);
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* @return OK
|
|
1436
|
+
*/
|
|
1437
|
+
telemetryDELETE() {
|
|
1438
|
+
let url_ = this.baseUrl + "/api/test/telemetry";
|
|
1439
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1440
|
+
let options_ = {
|
|
1441
|
+
method: "DELETE",
|
|
1442
|
+
headers: {}
|
|
1443
|
+
};
|
|
1444
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1445
|
+
return this.processTelemetryDELETE(_response);
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
processTelemetryDELETE(response) {
|
|
1449
|
+
const status = response.status;
|
|
1450
|
+
let _headers = {};
|
|
1451
|
+
if (response.headers && response.headers.forEach) {
|
|
1452
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1453
|
+
}
|
|
1454
|
+
;
|
|
1455
|
+
if (status === 200) {
|
|
1456
|
+
return response.text().then((_responseText) => {
|
|
1457
|
+
return;
|
|
1458
|
+
});
|
|
1459
|
+
} else if (status !== 200 && status !== 204) {
|
|
1460
|
+
return response.text().then((_responseText) => {
|
|
1461
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
return Promise.resolve(null);
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* @return OK
|
|
1468
|
+
*/
|
|
1469
|
+
licenseConfigPOST(body) {
|
|
1470
|
+
let url_ = this.baseUrl + "/api/test/license-config";
|
|
1471
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1472
|
+
const content_ = JSON.stringify(body);
|
|
1473
|
+
let options_ = {
|
|
1474
|
+
body: content_,
|
|
1475
|
+
method: "POST",
|
|
1476
|
+
headers: {
|
|
1477
|
+
"Content-Type": "application/json"
|
|
1478
|
+
}
|
|
1479
|
+
};
|
|
1480
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1481
|
+
return this.processLicenseConfigPOST(_response);
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
processLicenseConfigPOST(response) {
|
|
1485
|
+
const status = response.status;
|
|
1486
|
+
let _headers = {};
|
|
1487
|
+
if (response.headers && response.headers.forEach) {
|
|
1488
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1489
|
+
}
|
|
1490
|
+
;
|
|
1491
|
+
if (status === 200) {
|
|
1492
|
+
return response.text().then((_responseText) => {
|
|
1493
|
+
return;
|
|
1494
|
+
});
|
|
1495
|
+
} else if (status !== 200 && status !== 204) {
|
|
1496
|
+
return response.text().then((_responseText) => {
|
|
1497
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
return Promise.resolve(null);
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* @return OK
|
|
1504
|
+
*/
|
|
1505
|
+
licenseConfigDELETE() {
|
|
1506
|
+
let url_ = this.baseUrl + "/api/test/license-config";
|
|
1507
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1508
|
+
let options_ = {
|
|
1509
|
+
method: "DELETE",
|
|
1510
|
+
headers: {}
|
|
1511
|
+
};
|
|
1512
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1513
|
+
return this.processLicenseConfigDELETE(_response);
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
processLicenseConfigDELETE(response) {
|
|
1517
|
+
const status = response.status;
|
|
1518
|
+
let _headers = {};
|
|
1519
|
+
if (response.headers && response.headers.forEach) {
|
|
1520
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1521
|
+
}
|
|
1522
|
+
;
|
|
1523
|
+
if (status === 200) {
|
|
1524
|
+
return response.text().then((_responseText) => {
|
|
1525
|
+
return;
|
|
1526
|
+
});
|
|
1527
|
+
} else if (status !== 200 && status !== 204) {
|
|
1528
|
+
return response.text().then((_responseText) => {
|
|
1529
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
return Promise.resolve(null);
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* @return OK
|
|
1536
|
+
*/
|
|
1537
|
+
getCurrentUser() {
|
|
1538
|
+
let url_ = this.baseUrl + "/api/user";
|
|
1539
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1540
|
+
let options_ = {
|
|
1541
|
+
method: "GET",
|
|
1542
|
+
headers: {
|
|
1543
|
+
"Accept": "application/json"
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1547
|
+
return this.processGetCurrentUser(_response);
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
processGetCurrentUser(response) {
|
|
1551
|
+
const status = response.status;
|
|
1552
|
+
let _headers = {};
|
|
1553
|
+
if (response.headers && response.headers.forEach) {
|
|
1554
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1555
|
+
}
|
|
1556
|
+
;
|
|
1557
|
+
if (status === 200) {
|
|
1558
|
+
return response.text().then((_responseText) => {
|
|
1559
|
+
let result200 = null;
|
|
1560
|
+
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1561
|
+
return result200;
|
|
1562
|
+
});
|
|
1563
|
+
} else if (status === 404) {
|
|
1564
|
+
return response.text().then((_responseText) => {
|
|
1565
|
+
let result404 = null;
|
|
1566
|
+
result404 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1567
|
+
return throwException("Not Found", status, _responseText, _headers, result404);
|
|
1568
|
+
});
|
|
1569
|
+
} else if (status === 500) {
|
|
1570
|
+
return response.text().then((_responseText) => {
|
|
1571
|
+
return throwException("Internal Server Error", status, _responseText, _headers);
|
|
1572
|
+
});
|
|
1573
|
+
} else if (status !== 200 && status !== 204) {
|
|
1574
|
+
return response.text().then((_responseText) => {
|
|
1575
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
return Promise.resolve(null);
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* @return OK
|
|
1582
|
+
*/
|
|
1583
|
+
getSshCredentials() {
|
|
1584
|
+
let url_ = this.baseUrl + "/api/user/ssh-credentials";
|
|
1585
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1586
|
+
let options_ = {
|
|
1587
|
+
method: "GET",
|
|
1588
|
+
headers: {
|
|
1589
|
+
"Accept": "application/json"
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1593
|
+
return this.processGetSshCredentials(_response);
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
processGetSshCredentials(response) {
|
|
1597
|
+
const status = response.status;
|
|
1598
|
+
let _headers = {};
|
|
1599
|
+
if (response.headers && response.headers.forEach) {
|
|
1600
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1601
|
+
}
|
|
1602
|
+
;
|
|
1603
|
+
if (status === 200) {
|
|
1604
|
+
return response.text().then((_responseText) => {
|
|
1605
|
+
let result200 = null;
|
|
1606
|
+
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1607
|
+
return result200;
|
|
1608
|
+
});
|
|
1609
|
+
} else if (status === 400) {
|
|
1610
|
+
return response.text().then((_responseText) => {
|
|
1611
|
+
let result400 = null;
|
|
1612
|
+
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1613
|
+
return throwException("Bad Request", status, _responseText, _headers, result400);
|
|
1614
|
+
});
|
|
1615
|
+
} else if (status === 500) {
|
|
1616
|
+
return response.text().then((_responseText) => {
|
|
1617
|
+
return throwException("Internal Server Error", status, _responseText, _headers);
|
|
1618
|
+
});
|
|
1619
|
+
} else if (status !== 200 && status !== 204) {
|
|
1620
|
+
return response.text().then((_responseText) => {
|
|
1621
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
return Promise.resolve(null);
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* @param body (optional)
|
|
1628
|
+
* @return OK
|
|
1629
|
+
*/
|
|
1630
|
+
validateSshHostKey(body) {
|
|
1631
|
+
let url_ = this.baseUrl + "/api/user/ssh/validate-host-key";
|
|
1632
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1633
|
+
const content_ = JSON.stringify(body);
|
|
1634
|
+
let options_ = {
|
|
1635
|
+
body: content_,
|
|
1636
|
+
method: "POST",
|
|
1637
|
+
headers: {
|
|
1638
|
+
"Content-Type": "application/json"
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1642
|
+
return this.processValidateSshHostKey(_response);
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
processValidateSshHostKey(response) {
|
|
1646
|
+
const status = response.status;
|
|
1647
|
+
let _headers = {};
|
|
1648
|
+
if (response.headers && response.headers.forEach) {
|
|
1649
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1650
|
+
}
|
|
1651
|
+
;
|
|
1652
|
+
if (status === 200) {
|
|
1653
|
+
return response.text().then((_responseText) => {
|
|
1654
|
+
return;
|
|
1655
|
+
});
|
|
1656
|
+
} else if (status === 422) {
|
|
1657
|
+
return response.text().then((_responseText) => {
|
|
1658
|
+
let result422 = null;
|
|
1659
|
+
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1660
|
+
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
|
|
1661
|
+
});
|
|
1662
|
+
} else if (status === 400) {
|
|
1663
|
+
return response.text().then((_responseText) => {
|
|
1664
|
+
let result400 = null;
|
|
1665
|
+
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1666
|
+
return throwException("Bad Request", status, _responseText, _headers, result400);
|
|
1667
|
+
});
|
|
1668
|
+
} else if (status !== 200 && status !== 204) {
|
|
1669
|
+
return response.text().then((_responseText) => {
|
|
1670
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
return Promise.resolve(null);
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* @param body (optional)
|
|
1677
|
+
* @return OK
|
|
1678
|
+
*/
|
|
1679
|
+
registerPublicKey(body) {
|
|
1680
|
+
let url_ = this.baseUrl + "/api/user/public-key";
|
|
1681
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1682
|
+
const content_ = JSON.stringify(body);
|
|
1683
|
+
let options_ = {
|
|
1684
|
+
body: content_,
|
|
1685
|
+
method: "PUT",
|
|
1686
|
+
headers: {
|
|
1687
|
+
"Content-Type": "application/json",
|
|
1688
|
+
"Accept": "application/json"
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1692
|
+
return this.processRegisterPublicKey(_response);
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
processRegisterPublicKey(response) {
|
|
1696
|
+
const status = response.status;
|
|
1697
|
+
let _headers = {};
|
|
1698
|
+
if (response.headers && response.headers.forEach) {
|
|
1699
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1700
|
+
}
|
|
1701
|
+
;
|
|
1702
|
+
if (status === 200) {
|
|
1703
|
+
return response.text().then((_responseText) => {
|
|
1704
|
+
let result200 = null;
|
|
1705
|
+
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1706
|
+
return result200;
|
|
1707
|
+
});
|
|
1708
|
+
} else if (status === 400) {
|
|
1709
|
+
return response.text().then((_responseText) => {
|
|
1710
|
+
let result400 = null;
|
|
1711
|
+
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1712
|
+
return throwException("Bad Request", status, _responseText, _headers, result400);
|
|
1713
|
+
});
|
|
1714
|
+
} else if (status === 500) {
|
|
1715
|
+
return response.text().then((_responseText) => {
|
|
1716
|
+
return throwException("Internal Server Error", status, _responseText, _headers);
|
|
1717
|
+
});
|
|
1718
|
+
} else if (status !== 200 && status !== 204) {
|
|
1719
|
+
return response.text().then((_responseText) => {
|
|
1720
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
return Promise.resolve(null);
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* @return OK
|
|
1727
|
+
*/
|
|
1728
|
+
getTermsStatus() {
|
|
1729
|
+
let url_ = this.baseUrl + "/api/user/terms-status";
|
|
1730
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1731
|
+
let options_ = {
|
|
1732
|
+
method: "GET",
|
|
1733
|
+
headers: {
|
|
1734
|
+
"Accept": "application/json"
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1738
|
+
return this.processGetTermsStatus(_response);
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
processGetTermsStatus(response) {
|
|
1742
|
+
const status = response.status;
|
|
1743
|
+
let _headers = {};
|
|
1744
|
+
if (response.headers && response.headers.forEach) {
|
|
1745
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1746
|
+
}
|
|
1747
|
+
;
|
|
1748
|
+
if (status === 200) {
|
|
1749
|
+
return response.text().then((_responseText) => {
|
|
1750
|
+
let result200 = null;
|
|
1751
|
+
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1752
|
+
return result200;
|
|
1753
|
+
});
|
|
1754
|
+
} else if (status !== 200 && status !== 204) {
|
|
1755
|
+
return response.text().then((_responseText) => {
|
|
1756
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
return Promise.resolve(null);
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* @param body (optional)
|
|
1763
|
+
* @return OK
|
|
1764
|
+
*/
|
|
1765
|
+
acceptTerms(body) {
|
|
1766
|
+
let url_ = this.baseUrl + "/api/user/accept-terms";
|
|
1767
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1768
|
+
const content_ = JSON.stringify(body);
|
|
1769
|
+
let options_ = {
|
|
1770
|
+
body: content_,
|
|
1771
|
+
method: "POST",
|
|
1772
|
+
headers: {
|
|
1773
|
+
"Content-Type": "application/json",
|
|
1774
|
+
"Accept": "application/json"
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1778
|
+
return this.processAcceptTerms(_response);
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
processAcceptTerms(response) {
|
|
1782
|
+
const status = response.status;
|
|
1783
|
+
let _headers = {};
|
|
1784
|
+
if (response.headers && response.headers.forEach) {
|
|
1785
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1786
|
+
}
|
|
1787
|
+
;
|
|
1788
|
+
if (status === 200) {
|
|
1789
|
+
return response.text().then((_responseText) => {
|
|
1790
|
+
let result200 = null;
|
|
1791
|
+
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1792
|
+
return result200;
|
|
1793
|
+
});
|
|
1794
|
+
} else if (status === 400) {
|
|
1795
|
+
return response.text().then((_responseText) => {
|
|
1796
|
+
let result400 = null;
|
|
1797
|
+
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1798
|
+
return throwException("Bad Request", status, _responseText, _headers, result400);
|
|
1799
|
+
});
|
|
1800
|
+
} else if (status !== 200 && status !== 204) {
|
|
1801
|
+
return response.text().then((_responseText) => {
|
|
1802
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
return Promise.resolve(null);
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* @param body (optional)
|
|
1809
|
+
* @return OK
|
|
1810
|
+
*/
|
|
1811
|
+
updateUserSlug(body) {
|
|
1812
|
+
let url_ = this.baseUrl + "/api/user/slug";
|
|
1813
|
+
url_ = url_.replace(/[?&]$/, "");
|
|
1814
|
+
const content_ = JSON.stringify(body);
|
|
1815
|
+
let options_ = {
|
|
1816
|
+
body: content_,
|
|
1817
|
+
method: "PUT",
|
|
1818
|
+
headers: {
|
|
1819
|
+
"Content-Type": "application/json",
|
|
1820
|
+
"Accept": "application/json"
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
return this.http.fetch(url_, options_).then((_response) => {
|
|
1824
|
+
return this.processUpdateUserSlug(_response);
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
processUpdateUserSlug(response) {
|
|
1828
|
+
const status = response.status;
|
|
1829
|
+
let _headers = {};
|
|
1830
|
+
if (response.headers && response.headers.forEach) {
|
|
1831
|
+
response.headers.forEach((v, k) => _headers[k] = v);
|
|
1832
|
+
}
|
|
1833
|
+
;
|
|
1834
|
+
if (status === 200) {
|
|
1835
|
+
return response.text().then((_responseText) => {
|
|
1836
|
+
let result200 = null;
|
|
1837
|
+
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1838
|
+
return result200;
|
|
1839
|
+
});
|
|
1840
|
+
} else if (status === 400) {
|
|
1841
|
+
return response.text().then((_responseText) => {
|
|
1842
|
+
let result400 = null;
|
|
1843
|
+
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1844
|
+
return throwException("Bad Request", status, _responseText, _headers, result400);
|
|
1845
|
+
});
|
|
1846
|
+
} else if (status === 409) {
|
|
1847
|
+
return response.text().then((_responseText) => {
|
|
1848
|
+
let result409 = null;
|
|
1849
|
+
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver);
|
|
1850
|
+
return throwException("Conflict", status, _responseText, _headers, result409);
|
|
1851
|
+
});
|
|
1852
|
+
} else if (status === 500) {
|
|
1853
|
+
return response.text().then((_responseText) => {
|
|
1854
|
+
return throwException("Internal Server Error", status, _responseText, _headers);
|
|
1855
|
+
});
|
|
1856
|
+
} else if (status !== 200 && status !== 204) {
|
|
1857
|
+
return response.text().then((_responseText) => {
|
|
1858
|
+
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
return Promise.resolve(null);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
PeriscopeApi2.PeriscopeApiClient = PeriscopeApiClient;
|
|
1865
|
+
class ApiException extends Error {
|
|
1866
|
+
constructor(message, status, response, headers, result) {
|
|
1867
|
+
super();
|
|
1868
|
+
this.isApiException = true;
|
|
1869
|
+
this.message = message;
|
|
1870
|
+
this.status = status;
|
|
1871
|
+
this.response = response;
|
|
1872
|
+
this.headers = headers;
|
|
1873
|
+
this.result = result;
|
|
1874
|
+
}
|
|
1875
|
+
static isApiException(obj) {
|
|
1876
|
+
return obj.isApiException === true;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
PeriscopeApi2.ApiException = ApiException;
|
|
1880
|
+
function throwException(message, status, response, headers, result) {
|
|
1881
|
+
throw new ApiException(message, status, response, headers, result);
|
|
1882
|
+
}
|
|
1883
|
+
})(PeriscopeApi || (PeriscopeApi = {}));
|
|
1884
|
+
|
|
1885
|
+
// src/lib/server-config.ts
|
|
1886
|
+
var cachedConfig = null;
|
|
1887
|
+
var fetchPromise = null;
|
|
1888
|
+
async function getServerConfig(serverUrl) {
|
|
1889
|
+
if (cachedConfig) return cachedConfig;
|
|
1890
|
+
if (fetchPromise) return fetchPromise;
|
|
1891
|
+
fetchPromise = fetchServerConfig(serverUrl);
|
|
1892
|
+
try {
|
|
1893
|
+
cachedConfig = await fetchPromise;
|
|
1894
|
+
return cachedConfig;
|
|
1895
|
+
} finally {
|
|
1896
|
+
fetchPromise = null;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
async function fetchServerConfig(serverUrl) {
|
|
1900
|
+
const client3 = new PeriscopeApi.PeriscopeApiClient(serverUrl, {
|
|
1901
|
+
fetch: (url, init) => fetch(url, init)
|
|
1902
|
+
});
|
|
1903
|
+
return client3.configuration(void 0);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// src/lib/client.ts
|
|
1907
|
+
import * as https from "https";
|
|
1908
|
+
import * as fs5 from "fs";
|
|
1909
|
+
var AccountStatus = {
|
|
1910
|
+
PENDING_APPROVAL: "PendingApproval",
|
|
1911
|
+
ACTIVE: "Active",
|
|
1912
|
+
REJECTED: "Rejected",
|
|
1913
|
+
INACTIVE: "Inactive"
|
|
1914
|
+
};
|
|
1915
|
+
var PeriscopeClient = class {
|
|
1916
|
+
constructor(config2) {
|
|
1917
|
+
this.config = config2;
|
|
1918
|
+
if (!config2.serverUrl) {
|
|
1919
|
+
throw new Error("Server URL is required");
|
|
1920
|
+
}
|
|
1921
|
+
if (config2.caCertPath) {
|
|
1922
|
+
try {
|
|
1923
|
+
const caCert = fs5.readFileSync(config2.caCertPath);
|
|
1924
|
+
this.httpsAgent = new https.Agent({
|
|
1925
|
+
ca: caCert
|
|
1926
|
+
});
|
|
1927
|
+
this.logger.debug(
|
|
1928
|
+
`Using custom CA certificate from: ${config2.caCertPath}`
|
|
1929
|
+
);
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
this.logger.error(
|
|
1932
|
+
`Failed to load CA certificate from ${config2.caCertPath}:`,
|
|
1933
|
+
error
|
|
1934
|
+
);
|
|
1935
|
+
throw new Error(
|
|
1936
|
+
`Failed to load CA certificate: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
authManager = null;
|
|
1942
|
+
apiClient = null;
|
|
1943
|
+
logger = getLogger().child("PeriscopeClient");
|
|
1944
|
+
httpsAgent = null;
|
|
1945
|
+
authInitPromise = null;
|
|
1946
|
+
/**
|
|
1947
|
+
* Perform fetch with SSL configuration
|
|
1948
|
+
*/
|
|
1949
|
+
async fetchWithOptions(url, init) {
|
|
1950
|
+
const options = { ...init };
|
|
1951
|
+
if (this.httpsAgent && url.toString().startsWith("https://")) {
|
|
1952
|
+
options.agent = this.httpsAgent;
|
|
1953
|
+
}
|
|
1954
|
+
return fetch(url, options);
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Initialize the auth manager with configuration from the API.
|
|
1958
|
+
* Selects MSAL (Entra) or Auth0 based on the server's authProvider field.
|
|
1959
|
+
* Uses a cached promise to prevent duplicate initialization attempts.
|
|
1960
|
+
*/
|
|
1961
|
+
async initializeAuth() {
|
|
1962
|
+
if (this.authManager) {
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
if (this.authInitPromise) {
|
|
1966
|
+
return this.authInitPromise;
|
|
1967
|
+
}
|
|
1968
|
+
this.authInitPromise = this.doInitializeAuth();
|
|
1969
|
+
try {
|
|
1970
|
+
await this.authInitPromise;
|
|
1971
|
+
} finally {
|
|
1972
|
+
this.authInitPromise = null;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Internal auth initialization logic.
|
|
1977
|
+
* Uses the centralized ServerConfig cache to avoid duplicate /api/configuration requests.
|
|
1978
|
+
*/
|
|
1979
|
+
async doInitializeAuth() {
|
|
1980
|
+
const serverConfig = await getServerConfig(this.config.serverUrl);
|
|
1981
|
+
if (!serverConfig.clientId || !serverConfig.authority) {
|
|
1982
|
+
throw new Error("Incomplete auth configuration received from server");
|
|
1983
|
+
}
|
|
1984
|
+
const isAuth0 = serverConfig.authProvider?.toLowerCase() === "auth0";
|
|
1985
|
+
if (isAuth0) {
|
|
1986
|
+
this.authManager = new Auth0AuthManager({
|
|
1987
|
+
authority: serverConfig.authority,
|
|
1988
|
+
clientId: serverConfig.clientId,
|
|
1989
|
+
scopes: serverConfig.scopes || ["openid", "profile", "email"],
|
|
1990
|
+
audience: serverConfig.audience
|
|
1991
|
+
});
|
|
1992
|
+
this.logger.debug("Auth0 auth manager initialized from server config");
|
|
1993
|
+
} else {
|
|
1994
|
+
if (!serverConfig.scopes) {
|
|
1995
|
+
throw new Error("Incomplete MSAL configuration received from server");
|
|
1996
|
+
}
|
|
1997
|
+
this.authManager = new MsalAuthManager({
|
|
1998
|
+
clientId: serverConfig.clientId,
|
|
1999
|
+
authority: serverConfig.authority,
|
|
2000
|
+
scopes: serverConfig.scopes
|
|
2001
|
+
});
|
|
2002
|
+
this.logger.debug("MSAL auth manager initialized from server config");
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Create a custom fetch implementation that includes authentication headers.
|
|
2007
|
+
* Dynamically fetches a fresh token on each request via authManager so that
|
|
2008
|
+
* long-lived API clients (e.g., during tunnel reconnection) automatically
|
|
2009
|
+
* pick up refreshed tokens instead of replaying an expired one.
|
|
2010
|
+
*/
|
|
2011
|
+
createAuthenticatedFetch(token) {
|
|
2012
|
+
return {
|
|
2013
|
+
fetch: async (url, init) => {
|
|
2014
|
+
const headers = new Headers(init?.headers);
|
|
2015
|
+
let currentToken;
|
|
2016
|
+
if (this.authManager) {
|
|
2017
|
+
const silentToken = await this.authManager.tryGetValidTokenSilently();
|
|
2018
|
+
if (!silentToken) {
|
|
2019
|
+
throw new Error(
|
|
2020
|
+
"Authentication expired. Please run `periscope auth login` to re-authenticate."
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
currentToken = silentToken;
|
|
2024
|
+
} else {
|
|
2025
|
+
currentToken = token;
|
|
2026
|
+
}
|
|
2027
|
+
headers.set("Authorization", `Bearer ${currentToken}`);
|
|
2028
|
+
headers.set("Content-Type", "application/json");
|
|
2029
|
+
headers.set("Accept", "application/json");
|
|
2030
|
+
const requestInit = {
|
|
2031
|
+
...init,
|
|
2032
|
+
headers
|
|
2033
|
+
};
|
|
2034
|
+
this.logger.debug(`Making authenticated request to: ${url}`);
|
|
2035
|
+
this.logger.trace("Using Bearer token: [present]");
|
|
2036
|
+
const response = await this.fetchWithOptions(url, requestInit);
|
|
2037
|
+
this.logger.debug(`Response status: ${response.status}`);
|
|
2038
|
+
return response;
|
|
2039
|
+
}
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Create an unauthenticated fetch implementation for public endpoints
|
|
2044
|
+
*/
|
|
2045
|
+
createUnauthenticatedFetch() {
|
|
2046
|
+
return {
|
|
2047
|
+
fetch: async (url, init) => {
|
|
2048
|
+
const headers = new Headers(init?.headers);
|
|
2049
|
+
headers.set("Content-Type", "application/json");
|
|
2050
|
+
headers.set("Accept", "application/json");
|
|
2051
|
+
const requestInit = {
|
|
2052
|
+
...init,
|
|
2053
|
+
headers
|
|
2054
|
+
};
|
|
2055
|
+
this.logger.debug(`Making unauthenticated request to: ${url}`);
|
|
2056
|
+
const response = await this.fetchWithOptions(url, requestInit);
|
|
2057
|
+
this.logger.debug(`Response status: ${response.status}`);
|
|
2058
|
+
return response;
|
|
2059
|
+
}
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Initialize the API client with authentication token
|
|
2064
|
+
*/
|
|
2065
|
+
async initializeApiClient(token) {
|
|
2066
|
+
try {
|
|
2067
|
+
if (!this.config.serverUrl) {
|
|
2068
|
+
throw new Error("Server URL is required");
|
|
2069
|
+
}
|
|
2070
|
+
const authenticatedFetch = this.createAuthenticatedFetch(token);
|
|
2071
|
+
this.apiClient = new PeriscopeApi.PeriscopeApiClient(
|
|
2072
|
+
this.config.serverUrl,
|
|
2073
|
+
authenticatedFetch
|
|
2074
|
+
);
|
|
2075
|
+
this.logger.debug("Periscope API client initialized successfully");
|
|
2076
|
+
this.logger.debug("API base URL:", this.config.serverUrl);
|
|
2077
|
+
this.logger.debug("Auth token:", token ? "Present" : "Missing");
|
|
2078
|
+
return true;
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
this.logger.error("Failed to initialize Periscope API client:", error);
|
|
2081
|
+
return false;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Check if API client is initialized and ready
|
|
2086
|
+
*/
|
|
2087
|
+
async isApiClientReady() {
|
|
2088
|
+
if (this.apiClient) {
|
|
2089
|
+
return true;
|
|
2090
|
+
}
|
|
2091
|
+
await this.ensureApiClientInitialized();
|
|
2092
|
+
return this.apiClient !== null;
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Authenticate and ensure we have a valid token
|
|
2096
|
+
*/
|
|
2097
|
+
async authenticate() {
|
|
2098
|
+
await this.initializeAuth();
|
|
2099
|
+
if (!this.authManager) {
|
|
2100
|
+
throw new Error("Failed to initialize auth configuration");
|
|
2101
|
+
}
|
|
2102
|
+
const authResult = await this.authManager.authenticate();
|
|
2103
|
+
await this.initializeApiClient(authResult.accessToken);
|
|
2104
|
+
return authResult;
|
|
2105
|
+
}
|
|
2106
|
+
/**
|
|
2107
|
+
* Authenticate using interactive flow with prompt control
|
|
2108
|
+
*/
|
|
2109
|
+
async authenticateInteractive(prompt) {
|
|
2110
|
+
await this.initializeAuth();
|
|
2111
|
+
if (!this.authManager) {
|
|
2112
|
+
throw new Error("Failed to initialize auth configuration");
|
|
2113
|
+
}
|
|
2114
|
+
const authResult = await this.authManager.authenticateInteractive(prompt);
|
|
2115
|
+
await this.initializeApiClient(authResult.accessToken);
|
|
2116
|
+
return authResult;
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Check if currently authenticated.
|
|
2120
|
+
*
|
|
2121
|
+
* Checks in order:
|
|
2122
|
+
* 1. Already initialized authManager with valid token
|
|
2123
|
+
* 2. Cached token on disk (without needing server config)
|
|
2124
|
+
* 3. Initialize auth from server and check
|
|
2125
|
+
*
|
|
2126
|
+
* This layered approach allows the CLI to work even when the server
|
|
2127
|
+
* doesn't provide auth configuration (e.g., test environments).
|
|
2128
|
+
*/
|
|
2129
|
+
async isAuthenticated() {
|
|
2130
|
+
if (this.authManager?.isAuthenticated()) {
|
|
2131
|
+
await this.ensureApiClientInitialized();
|
|
2132
|
+
return true;
|
|
2133
|
+
}
|
|
2134
|
+
if (await this.tryInitializeFromCachedToken()) {
|
|
2135
|
+
try {
|
|
2136
|
+
await this.initializeAuth();
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
this.logger.debug("Auth init failed after cached token login:", error);
|
|
2139
|
+
}
|
|
2140
|
+
return true;
|
|
2141
|
+
}
|
|
2142
|
+
try {
|
|
2143
|
+
await this.initializeAuth();
|
|
2144
|
+
if (this.authManager) {
|
|
2145
|
+
const token = await this.authManager.tryGetValidTokenSilently();
|
|
2146
|
+
if (token) {
|
|
2147
|
+
await this.initializeApiClient(token);
|
|
2148
|
+
return true;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
} catch (error) {
|
|
2152
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2153
|
+
const isNetworkError = errorMessage.includes("ECONNREFUSED") || errorMessage.includes("ENOTFOUND") || errorMessage.includes("ETIMEDOUT") || errorMessage.includes("ECONNRESET") || errorMessage.includes("fetch failed") || errorMessage.toLowerCase().includes("network");
|
|
2154
|
+
if (isNetworkError) {
|
|
2155
|
+
throw new Error(
|
|
2156
|
+
`Server is unreachable (${this.config.serverUrl}): ${errorMessage}`
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
this.logger.debug("Auth initialization failed during auth check:", error);
|
|
2160
|
+
}
|
|
2161
|
+
return false;
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* @deprecated Use isAuthenticated() instead. This alias exists for backwards compatibility.
|
|
2165
|
+
*/
|
|
2166
|
+
async isAuthenticatedAsync() {
|
|
2167
|
+
return this.isAuthenticated();
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Try to initialize from a cached token file without needing server config.
|
|
2171
|
+
* Checks both MSAL and Auth0 token caches.
|
|
2172
|
+
* Returns true if a valid cached token was found and API client initialized.
|
|
2173
|
+
*
|
|
2174
|
+
* NOTE: Config-less managers are used only to read the cache and extract
|
|
2175
|
+
* a still-valid access token. They are NOT stored as this.authManager
|
|
2176
|
+
* because they lack server config needed for re-authentication and
|
|
2177
|
+
* token refresh. The full authManager is set later via initializeAuth().
|
|
2178
|
+
*/
|
|
2179
|
+
async tryInitializeFromCachedToken() {
|
|
2180
|
+
try {
|
|
2181
|
+
const msalAuthManager = new MsalAuthManager();
|
|
2182
|
+
if (msalAuthManager.isAuthenticated()) {
|
|
2183
|
+
const token = await msalAuthManager.getValidToken();
|
|
2184
|
+
await this.initializeApiClient(token);
|
|
2185
|
+
this.logger.debug("Initialized API client from cached MSAL token");
|
|
2186
|
+
return true;
|
|
2187
|
+
}
|
|
2188
|
+
} catch {
|
|
2189
|
+
}
|
|
2190
|
+
try {
|
|
2191
|
+
const auth0AuthManager = new Auth0AuthManager();
|
|
2192
|
+
if (auth0AuthManager.isAuthenticated()) {
|
|
2193
|
+
const token = await auth0AuthManager.getValidToken();
|
|
2194
|
+
await this.initializeApiClient(token);
|
|
2195
|
+
this.logger.debug("Initialized API client from cached Auth0 token");
|
|
2196
|
+
return true;
|
|
2197
|
+
}
|
|
2198
|
+
} catch {
|
|
2199
|
+
}
|
|
2200
|
+
return false;
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Ensure API client is initialized if we have valid authentication.
|
|
2204
|
+
*/
|
|
2205
|
+
async ensureApiClientInitialized() {
|
|
2206
|
+
if (this.apiClient || !this.authManager) {
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
try {
|
|
2210
|
+
const token = await this.authManager.getValidToken();
|
|
2211
|
+
await this.initializeApiClient(token);
|
|
2212
|
+
} catch (error) {
|
|
2213
|
+
this.logger.debug("Failed to initialize API client:", error);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Logout and clear authentication data.
|
|
2218
|
+
* Clears both MSAL and Auth0 caches to ensure a clean slate.
|
|
2219
|
+
*/
|
|
2220
|
+
async logout() {
|
|
2221
|
+
try {
|
|
2222
|
+
await this.initializeAuth();
|
|
2223
|
+
} catch (error) {
|
|
2224
|
+
this.logger.warn("Failed to initialize auth for logout:", error);
|
|
2225
|
+
}
|
|
2226
|
+
if (this.authManager) {
|
|
2227
|
+
this.authManager.logout();
|
|
2228
|
+
}
|
|
2229
|
+
try {
|
|
2230
|
+
if (!(this.authManager instanceof MsalAuthManager)) {
|
|
2231
|
+
new MsalAuthManager().logout();
|
|
2232
|
+
}
|
|
2233
|
+
} catch {
|
|
2234
|
+
}
|
|
2235
|
+
try {
|
|
2236
|
+
if (!(this.authManager instanceof Auth0AuthManager)) {
|
|
2237
|
+
new Auth0AuthManager().logout();
|
|
2238
|
+
}
|
|
2239
|
+
} catch {
|
|
2240
|
+
}
|
|
2241
|
+
this.apiClient = null;
|
|
2242
|
+
}
|
|
2243
|
+
// API Methods - these use the real API client
|
|
2244
|
+
/**
|
|
2245
|
+
* Get auth configuration from server (unauthenticated endpoint)
|
|
2246
|
+
*/
|
|
2247
|
+
async getAuthConfig() {
|
|
2248
|
+
if (!this.config.serverUrl) {
|
|
2249
|
+
throw new Error("Server URL is required to fetch auth configuration");
|
|
2250
|
+
}
|
|
2251
|
+
try {
|
|
2252
|
+
const unauthenticatedFetch = this.createUnauthenticatedFetch();
|
|
2253
|
+
const unauthenticatedClient = new PeriscopeApi.PeriscopeApiClient(
|
|
2254
|
+
this.config.serverUrl,
|
|
2255
|
+
unauthenticatedFetch
|
|
2256
|
+
);
|
|
2257
|
+
this.logger.debug(
|
|
2258
|
+
`Getting auth configuration from ${this.config.serverUrl}`
|
|
2259
|
+
);
|
|
2260
|
+
const authConfig = await unauthenticatedClient.configuration(void 0);
|
|
2261
|
+
this.logger.debug("Successfully fetched auth configuration from server");
|
|
2262
|
+
return authConfig;
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
this.logger.debug(
|
|
2265
|
+
"Failed to fetch auth configuration from server:",
|
|
2266
|
+
error
|
|
2267
|
+
);
|
|
2268
|
+
throw new Error(
|
|
2269
|
+
`Failed to fetch auth configuration: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
async checkHealth() {
|
|
2274
|
+
if (!this.config.serverUrl) {
|
|
2275
|
+
throw new Error("Server URL is required");
|
|
2276
|
+
}
|
|
2277
|
+
const response = await this.fetchWithOptions(
|
|
2278
|
+
`${this.config.serverUrl}/healthz`
|
|
2279
|
+
);
|
|
2280
|
+
if (!response.ok) {
|
|
2281
|
+
return { healthy: false };
|
|
2282
|
+
}
|
|
2283
|
+
const body = await response.json();
|
|
2284
|
+
return { healthy: body.status === "Healthy" };
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* Register the client's SSH public key with the server.
|
|
2288
|
+
* Returns the normalized public key string accepted by the server.
|
|
2289
|
+
*/
|
|
2290
|
+
async registerPublicKey(publicKey) {
|
|
2291
|
+
if (!await this.isApiClientReady() || !this.apiClient) {
|
|
2292
|
+
throw new Error("API client not initialized. Please authenticate first.");
|
|
2293
|
+
}
|
|
2294
|
+
const response = await this.apiClient.registerPublicKey({ publicKey });
|
|
2295
|
+
if (response.publicKey == null) {
|
|
2296
|
+
throw new Error(
|
|
2297
|
+
"Server did not return the registered public key. The server may be outdated."
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
return response.publicKey;
|
|
2301
|
+
}
|
|
2302
|
+
async getSSHCredentials() {
|
|
2303
|
+
if (!await this.isApiClientReady() || !this.apiClient) {
|
|
2304
|
+
throw new Error("API client not initialized. Please authenticate first.");
|
|
2305
|
+
}
|
|
2306
|
+
try {
|
|
2307
|
+
this.logger.debug("Fetching SSH credentials...");
|
|
2308
|
+
const sshCredentials = await this.apiClient.getSshCredentials();
|
|
2309
|
+
this.logger.debug(
|
|
2310
|
+
`SSH credentials fetched for user '${sshCredentials.email ?? "unknown"}'`
|
|
2311
|
+
);
|
|
2312
|
+
return sshCredentials;
|
|
2313
|
+
} catch (error) {
|
|
2314
|
+
this.logger.warn("Failed to fetch SSH credentials:", error);
|
|
2315
|
+
throw error;
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* Get current user information
|
|
2320
|
+
*/
|
|
2321
|
+
async getCurrentUser() {
|
|
2322
|
+
if (!await this.isApiClientReady() || !this.apiClient) {
|
|
2323
|
+
throw new Error("API client not initialized. Please authenticate first.");
|
|
2324
|
+
}
|
|
2325
|
+
try {
|
|
2326
|
+
this.logger.debug("Fetching current user from Periscope server...");
|
|
2327
|
+
const user = await this.apiClient.getCurrentUser();
|
|
2328
|
+
this.logger.debug(
|
|
2329
|
+
"Successfully fetched current user from Periscope server"
|
|
2330
|
+
);
|
|
2331
|
+
return user;
|
|
2332
|
+
} catch (error) {
|
|
2333
|
+
this.logger.warn(
|
|
2334
|
+
"Failed to fetch current user from Periscope server:",
|
|
2335
|
+
error
|
|
2336
|
+
);
|
|
2337
|
+
throw error;
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Get current user's account status from the server.
|
|
2342
|
+
* Returns the status string (e.g., "Active", "PendingApproval").
|
|
2343
|
+
* Throws if the status cannot be determined (fail-closed).
|
|
2344
|
+
*/
|
|
2345
|
+
async getUserStatus() {
|
|
2346
|
+
const user = await this.getCurrentUser();
|
|
2347
|
+
if (!user.status) {
|
|
2348
|
+
throw new Error("Server did not return account status");
|
|
2349
|
+
}
|
|
2350
|
+
return user.status;
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Get current user's Terms of Service acceptance status.
|
|
2354
|
+
* Uses direct fetch since these endpoints may not yet be in the generated API client.
|
|
2355
|
+
*/
|
|
2356
|
+
async getTermsStatus() {
|
|
2357
|
+
if (!await this.isApiClientReady()) {
|
|
2358
|
+
throw new Error("API client not initialized. Please authenticate first.");
|
|
2359
|
+
}
|
|
2360
|
+
if (!this.authManager) {
|
|
2361
|
+
throw new Error(
|
|
2362
|
+
"Auth manager not initialized. Cannot check terms status."
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
const token = await this.authManager.getValidToken();
|
|
2366
|
+
const response = await this.fetchWithOptions(
|
|
2367
|
+
`${this.config.serverUrl}/api/user/terms-status`,
|
|
2368
|
+
{
|
|
2369
|
+
method: "GET",
|
|
2370
|
+
headers: {
|
|
2371
|
+
Authorization: `Bearer ${token}`,
|
|
2372
|
+
Accept: "application/json"
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
);
|
|
2376
|
+
if (!response.ok) {
|
|
2377
|
+
throw new Error(
|
|
2378
|
+
`Failed to get terms status: ${response.status} ${response.statusText}`
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
return await response.json();
|
|
2382
|
+
}
|
|
2383
|
+
/**
|
|
2384
|
+
* Accept the Terms of Service for the current user.
|
|
2385
|
+
*/
|
|
2386
|
+
async acceptTerms(termsVersion) {
|
|
2387
|
+
if (!await this.isApiClientReady()) {
|
|
2388
|
+
throw new Error("API client not initialized. Please authenticate first.");
|
|
2389
|
+
}
|
|
2390
|
+
if (!this.authManager) {
|
|
2391
|
+
throw new Error("Auth manager not initialized. Cannot accept terms.");
|
|
2392
|
+
}
|
|
2393
|
+
const token = await this.authManager.getValidToken();
|
|
2394
|
+
const response = await this.fetchWithOptions(
|
|
2395
|
+
`${this.config.serverUrl}/api/user/accept-terms`,
|
|
2396
|
+
{
|
|
2397
|
+
method: "POST",
|
|
2398
|
+
headers: {
|
|
2399
|
+
Authorization: `Bearer ${token}`,
|
|
2400
|
+
"Content-Type": "application/json",
|
|
2401
|
+
Accept: "application/json"
|
|
2402
|
+
},
|
|
2403
|
+
body: JSON.stringify({ termsVersion })
|
|
2404
|
+
}
|
|
2405
|
+
);
|
|
2406
|
+
if (!response.ok) {
|
|
2407
|
+
throw new Error(
|
|
2408
|
+
`Failed to accept terms: ${response.status} ${response.statusText}`
|
|
2409
|
+
);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* Update the user's slug for tunnel namespacing (ELF-166).
|
|
2414
|
+
* Requires authentication.
|
|
2415
|
+
*/
|
|
2416
|
+
async updateSlug(request) {
|
|
2417
|
+
if (!await this.isApiClientReady() || !this.apiClient) {
|
|
2418
|
+
throw new Error("API client not initialized. Please authenticate first.");
|
|
2419
|
+
}
|
|
2420
|
+
try {
|
|
2421
|
+
await this.apiClient.updateUserSlug(request);
|
|
2422
|
+
} catch (error) {
|
|
2423
|
+
if (error && typeof error === "object" && "status" in error && error.status === 409) {
|
|
2424
|
+
throw Object.assign(new Error("Slug already in use"), {
|
|
2425
|
+
response: { status: 409 }
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
throw error;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Validate the SSH host key received during the SSH handshake (ELF-198).
|
|
2433
|
+
* Sends the raw SSH wire-format public key bytes (base64-encoded) to the server,
|
|
2434
|
+
* which compares them against its own key using constant-time comparison.
|
|
2435
|
+
* Throws ApiException with status 422 if the key does not match (potential MITM).
|
|
2436
|
+
*/
|
|
2437
|
+
async validateSshHostKey(keyBytes) {
|
|
2438
|
+
if (!await this.isApiClientReady() || !this.apiClient) {
|
|
2439
|
+
throw new Error("API client not initialized. Please authenticate first.");
|
|
2440
|
+
}
|
|
2441
|
+
await this.apiClient.validateSshHostKey({ keyBytes });
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Submit user feedback to create a Linear issue.
|
|
2445
|
+
* Requires authentication.
|
|
2446
|
+
*/
|
|
2447
|
+
async submitFeedback(message) {
|
|
2448
|
+
if (!await this.isApiClientReady() || !this.apiClient) {
|
|
2449
|
+
throw new Error("API client not initialized. Please authenticate first.");
|
|
2450
|
+
}
|
|
2451
|
+
try {
|
|
2452
|
+
this.logger.debug("Submitting feedback...");
|
|
2453
|
+
const response = await this.apiClient.submitFeedback({
|
|
2454
|
+
message,
|
|
2455
|
+
source: "cli"
|
|
2456
|
+
});
|
|
2457
|
+
this.logger.debug("Feedback submitted successfully");
|
|
2458
|
+
return response;
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
this.logger.warn("Failed to submit feedback:", error);
|
|
2461
|
+
throw error;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Get the Application Insights connection string from the authenticated telemetry endpoint.
|
|
2466
|
+
* Returns null if not authenticated or if the server has no connection string configured.
|
|
2467
|
+
*/
|
|
2468
|
+
async getTelemetryConnectionString() {
|
|
2469
|
+
if (!await this.isApiClientReady()) {
|
|
2470
|
+
return null;
|
|
2471
|
+
}
|
|
2472
|
+
try {
|
|
2473
|
+
const config2 = await this.apiClient.telemetryGET();
|
|
2474
|
+
return config2.applicationInsightsConnectionString ?? null;
|
|
2475
|
+
} catch (error) {
|
|
2476
|
+
this.logger.debug("Failed to fetch telemetry connection string:", error);
|
|
2477
|
+
return null;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* Get the configuration object
|
|
2482
|
+
*/
|
|
2483
|
+
getConfig() {
|
|
2484
|
+
return this.config;
|
|
2485
|
+
}
|
|
2486
|
+
};
|
|
2487
|
+
|
|
2488
|
+
// src/lib/secure-memory.ts
|
|
2489
|
+
init_readline_instance();
|
|
2490
|
+
var activeTunnelManagers = /* @__PURE__ */ new Set();
|
|
2491
|
+
function registerTunnelManager(tunnelManager) {
|
|
2492
|
+
activeTunnelManagers.add(tunnelManager);
|
|
2493
|
+
}
|
|
2494
|
+
function unregisterTunnelManager(tunnelManager) {
|
|
2495
|
+
activeTunnelManagers.delete(tunnelManager);
|
|
2496
|
+
}
|
|
2497
|
+
async function stopActiveTunnelManagers() {
|
|
2498
|
+
const logger = getLogger().child("SecureCleanup");
|
|
2499
|
+
const managers = [...activeTunnelManagers];
|
|
2500
|
+
activeTunnelManagers.clear();
|
|
2501
|
+
for (const tunnelManager of managers) {
|
|
2502
|
+
try {
|
|
2503
|
+
await tunnelManager.stopAll();
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
logger.error("Error during tunnel cleanup:", error);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
var setupDone = false;
|
|
2510
|
+
function setupSecureCleanup() {
|
|
2511
|
+
if (setupDone) return;
|
|
2512
|
+
setupDone = true;
|
|
2513
|
+
let isCleaningUp = false;
|
|
2514
|
+
const logger = getLogger().child("SecureCleanup");
|
|
2515
|
+
const cleanup = async () => {
|
|
2516
|
+
if (isCleaningUp) return;
|
|
2517
|
+
isCleaningUp = true;
|
|
2518
|
+
logger.info("Cleaning up...");
|
|
2519
|
+
await stopActiveTunnelManagers();
|
|
2520
|
+
};
|
|
2521
|
+
const exit = async (code) => {
|
|
2522
|
+
const { gracefulExit: gracefulExit2 } = await Promise.resolve().then(() => (init_process_lifecycle(), process_lifecycle_exports));
|
|
2523
|
+
await gracefulExit2(code);
|
|
2524
|
+
};
|
|
2525
|
+
process.on("SIGINT", async () => {
|
|
2526
|
+
if (process.env.PERISCOPE_INTERACTIVE === "true" && isReadlineActive()) {
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
logger.info("Shutting down gracefully...");
|
|
2530
|
+
await cleanup();
|
|
2531
|
+
await exit(0);
|
|
2532
|
+
});
|
|
2533
|
+
process.on("SIGTERM", async () => {
|
|
2534
|
+
logger.info("Shutting down gracefully...");
|
|
2535
|
+
await cleanup();
|
|
2536
|
+
await exit(0);
|
|
2537
|
+
});
|
|
2538
|
+
process.on("uncaughtException", async (error) => {
|
|
2539
|
+
if (isSshChannelDisposedError(error)) {
|
|
2540
|
+
logger.warn(
|
|
2541
|
+
`SSH channel disposed (connection lost) \u2014 reconnection will be attempted: ${error.message}`
|
|
2542
|
+
);
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
logger.error("Uncaught exception:", error);
|
|
2546
|
+
try {
|
|
2547
|
+
const { trackException: trackException2 } = await Promise.resolve().then(() => (init_telemetry(), telemetry_exports));
|
|
2548
|
+
trackException2(error, { source: "uncaughtException" });
|
|
2549
|
+
} catch {
|
|
2550
|
+
}
|
|
2551
|
+
await cleanup();
|
|
2552
|
+
await exit(1);
|
|
2553
|
+
});
|
|
2554
|
+
process.on("unhandledRejection", async (reason, promise) => {
|
|
2555
|
+
logger.error("Unhandled rejection at:", promise, "reason:", reason);
|
|
2556
|
+
try {
|
|
2557
|
+
const { trackException: trackException2 } = await Promise.resolve().then(() => (init_telemetry(), telemetry_exports));
|
|
2558
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
2559
|
+
trackException2(error, { source: "unhandledRejection" });
|
|
2560
|
+
} catch {
|
|
2561
|
+
}
|
|
2562
|
+
await cleanup();
|
|
2563
|
+
await exit(1);
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
async function performSecureCleanup() {
|
|
2567
|
+
const logger = getLogger().child("SecureCleanup");
|
|
2568
|
+
logger.info("Performing secure cleanup...");
|
|
2569
|
+
await stopActiveTunnelManagers();
|
|
2570
|
+
}
|
|
2571
|
+
function isSshChannelDisposedError(error) {
|
|
2572
|
+
return error.name === "ObjectDisposedError";
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
// src/lib/tunnel-manager.ts
|
|
2576
|
+
import {
|
|
2577
|
+
SshSessionConfiguration,
|
|
2578
|
+
SshDisconnectReason,
|
|
2579
|
+
SshAuthenticationType
|
|
2580
|
+
} from "@microsoft/dev-tunnels-ssh";
|
|
2581
|
+
|
|
2582
|
+
// src/lib/ssh-key-manager.ts
|
|
2583
|
+
import * as fs7 from "node:fs";
|
|
2584
|
+
import * as path6 from "node:path";
|
|
2585
|
+
import * as os3 from "node:os";
|
|
2586
|
+
import {
|
|
2587
|
+
SshAlgorithms
|
|
2588
|
+
} from "@microsoft/dev-tunnels-ssh";
|
|
2589
|
+
import {
|
|
2590
|
+
exportPrivateKey,
|
|
2591
|
+
exportPublicKey,
|
|
2592
|
+
importKeyFile
|
|
2593
|
+
} from "@microsoft/dev-tunnels-ssh-keys";
|
|
2594
|
+
var KEY_FORMAT_SSH = 1;
|
|
2595
|
+
var KEY_FORMAT_PRIVATE = 0;
|
|
2596
|
+
var DEFAULT_KEY_DIR = path6.join(os3.homedir(), ".periscope");
|
|
2597
|
+
var DEFAULT_PRIVATE_KEY_FILE = "id_ecdsa";
|
|
2598
|
+
var SshKeyNotFoundError = class extends Error {
|
|
2599
|
+
constructor(keyPath) {
|
|
2600
|
+
super(
|
|
2601
|
+
`SSH key not found at ${keyPath}. Run 'periscope user key generate' to generate and register a key.`
|
|
2602
|
+
);
|
|
2603
|
+
this.name = "SshKeyNotFoundError";
|
|
2604
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
2605
|
+
}
|
|
2606
|
+
};
|
|
2607
|
+
var SshKeyManager = class {
|
|
2608
|
+
/**
|
|
2609
|
+
* Returns the default SSH private key path.
|
|
2610
|
+
*/
|
|
2611
|
+
static getDefaultKeyPath() {
|
|
2612
|
+
return path6.join(DEFAULT_KEY_DIR, DEFAULT_PRIVATE_KEY_FILE);
|
|
2613
|
+
}
|
|
2614
|
+
/**
|
|
2615
|
+
* Generates a new ECDSA P-256 key pair and saves to disk.
|
|
2616
|
+
* Warns before overwriting an existing key — callers should confirm with the user
|
|
2617
|
+
* before calling this on an already-registered key path.
|
|
2618
|
+
*/
|
|
2619
|
+
static async generateKeyPair(keyPath) {
|
|
2620
|
+
const privateKeyPath = keyPath ?? this.getDefaultKeyPath();
|
|
2621
|
+
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
2622
|
+
if (fs7.existsSync(privateKeyPath)) {
|
|
2623
|
+
log.warn(
|
|
2624
|
+
`Overwriting existing SSH key at ${privateKeyPath}. Any active tunnels using the old key will need to reconnect after re-login.`
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
fs7.mkdirSync(path6.dirname(privateKeyPath), { recursive: true });
|
|
2628
|
+
const algorithms = SshAlgorithms.publicKey;
|
|
2629
|
+
const algorithm = algorithms["ecdsaSha2Nistp256"];
|
|
2630
|
+
if (!algorithm) {
|
|
2631
|
+
throw new Error("ECDSA P-256 algorithm not available");
|
|
2632
|
+
}
|
|
2633
|
+
const keyPair = await algorithm.generateKeyPair();
|
|
2634
|
+
const privateKeyStr = await exportPrivateKey(
|
|
2635
|
+
keyPair,
|
|
2636
|
+
null,
|
|
2637
|
+
KEY_FORMAT_PRIVATE
|
|
2638
|
+
);
|
|
2639
|
+
const publicKeyStr = await exportPublicKey(keyPair, KEY_FORMAT_SSH);
|
|
2640
|
+
const tmpPrivatePath = `${privateKeyPath}.tmp`;
|
|
2641
|
+
const tmpPublicPath = `${publicKeyPath}.tmp`;
|
|
2642
|
+
try {
|
|
2643
|
+
fs7.writeFileSync(tmpPrivatePath, privateKeyStr, { mode: 384 });
|
|
2644
|
+
fs7.writeFileSync(tmpPublicPath, publicKeyStr, { mode: 420 });
|
|
2645
|
+
fs7.renameSync(tmpPrivatePath, privateKeyPath);
|
|
2646
|
+
fs7.renameSync(tmpPublicPath, publicKeyPath);
|
|
2647
|
+
} catch (err) {
|
|
2648
|
+
for (const tmp of [tmpPrivatePath, tmpPublicPath]) {
|
|
2649
|
+
try {
|
|
2650
|
+
fs7.unlinkSync(tmp);
|
|
2651
|
+
} catch {
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
throw err;
|
|
2655
|
+
}
|
|
2656
|
+
log.debug(`SSH key pair generated at ${privateKeyPath}`);
|
|
2657
|
+
return keyPair;
|
|
2658
|
+
}
|
|
2659
|
+
/**
|
|
2660
|
+
* Loads an existing key pair from disk.
|
|
2661
|
+
* Throws if the key file does not exist.
|
|
2662
|
+
*/
|
|
2663
|
+
static async loadKeyPair(keyPath) {
|
|
2664
|
+
const privateKeyPath = keyPath ?? this.getDefaultKeyPath();
|
|
2665
|
+
if (!fs7.existsSync(privateKeyPath)) {
|
|
2666
|
+
throw new SshKeyNotFoundError(privateKeyPath);
|
|
2667
|
+
}
|
|
2668
|
+
try {
|
|
2669
|
+
return await importKeyFile(privateKeyPath, null);
|
|
2670
|
+
} catch (err) {
|
|
2671
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2672
|
+
throw new Error(
|
|
2673
|
+
`Failed to load SSH key at ${privateKeyPath}: ${msg}. If the key is passphrase-protected, use an unprotected key or specify an alternative key path with --key.`
|
|
2674
|
+
);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
/**
|
|
2678
|
+
* Ensures a key pair exists, generating one if not.
|
|
2679
|
+
* Returns the loaded key pair.
|
|
2680
|
+
*/
|
|
2681
|
+
static async ensureKeyPair(keyPath) {
|
|
2682
|
+
const privateKeyPath = keyPath ?? this.getDefaultKeyPath();
|
|
2683
|
+
if (!fs7.existsSync(privateKeyPath)) {
|
|
2684
|
+
log.info("Generating SSH key pair...");
|
|
2685
|
+
return this.generateKeyPair(keyPath);
|
|
2686
|
+
}
|
|
2687
|
+
return this.loadKeyPair(keyPath);
|
|
2688
|
+
}
|
|
2689
|
+
/**
|
|
2690
|
+
* Exports the public key from an in-memory KeyPair in SSH wire format.
|
|
2691
|
+
* Use this after generateKeyPair() or ensureKeyPair() to avoid a redundant disk read.
|
|
2692
|
+
*/
|
|
2693
|
+
static async exportPublicKey(keyPair) {
|
|
2694
|
+
return (await exportPublicKey(keyPair, KEY_FORMAT_SSH)).trim();
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* Returns the public key string in SSH format (e.g. "ecdsa-sha2-nistp256 AAAA...").
|
|
2698
|
+
* Reads from the .pub file if present; otherwise imports the private key to derive it.
|
|
2699
|
+
*/
|
|
2700
|
+
static async getPublicKeyString(keyPath) {
|
|
2701
|
+
const privateKeyPath = keyPath ?? this.getDefaultKeyPath();
|
|
2702
|
+
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
2703
|
+
if (fs7.existsSync(publicKeyPath)) {
|
|
2704
|
+
return fs7.readFileSync(publicKeyPath, "utf-8").trim();
|
|
2705
|
+
}
|
|
2706
|
+
const keyPair = await this.loadKeyPair(keyPath);
|
|
2707
|
+
return (await exportPublicKey(keyPair, KEY_FORMAT_SSH)).trim();
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Returns info about the current SSH key (path, existence).
|
|
2711
|
+
*/
|
|
2712
|
+
static getKeyInfo(keyPath) {
|
|
2713
|
+
const privateKeyPath = keyPath ?? this.getDefaultKeyPath();
|
|
2714
|
+
return {
|
|
2715
|
+
exists: fs7.existsSync(privateKeyPath),
|
|
2716
|
+
path: privateKeyPath,
|
|
2717
|
+
pubKeyPath: `${privateKeyPath}.pub`
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
};
|
|
2721
|
+
|
|
2722
|
+
// src/lib/interactive-utils.ts
|
|
2723
|
+
function isInteractiveMode() {
|
|
2724
|
+
return process.env.PERISCOPE_INTERACTIVE === "true";
|
|
2725
|
+
}
|
|
2726
|
+
function exitOrThrow(code, message) {
|
|
2727
|
+
if (isInteractiveMode()) {
|
|
2728
|
+
throw new Error(message || `Command failed with exit code ${code}`);
|
|
2729
|
+
} else {
|
|
2730
|
+
if (message) {
|
|
2731
|
+
log.error(message);
|
|
2732
|
+
}
|
|
2733
|
+
process.exit(code);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// src/lib/tunnel-utils.ts
|
|
2738
|
+
function displayTunnelInfo(tunnel, serverUrl, options) {
|
|
2739
|
+
const {
|
|
2740
|
+
isInteractive = false,
|
|
2741
|
+
tunnelName,
|
|
2742
|
+
showBackgroundStatus = false,
|
|
2743
|
+
showAsListItem = false
|
|
2744
|
+
} = options || {};
|
|
2745
|
+
const serverHostname = tunnel.sshHost || new URL(serverUrl).hostname;
|
|
2746
|
+
let remoteUrl;
|
|
2747
|
+
if (tunnel.remoteURL) {
|
|
2748
|
+
remoteUrl = tunnel.remoteURL;
|
|
2749
|
+
} else if (tunnel.name && tunnel.wildcardHostname) {
|
|
2750
|
+
const separator = tunnel.urlSeparator || ".";
|
|
2751
|
+
const fullSubdomain = tunnel.slug ? `${tunnel.name}-${tunnel.slug}` : tunnel.name;
|
|
2752
|
+
remoteUrl = `https://${fullSubdomain}${separator}${tunnel.wildcardHostname}`;
|
|
2753
|
+
} else if (tunnel.name) {
|
|
2754
|
+
const fullSubdomain = tunnel.slug ? `${tunnel.name}-${tunnel.slug}` : tunnel.name;
|
|
2755
|
+
remoteUrl = `https://${fullSubdomain}.${serverHostname}`;
|
|
2756
|
+
} else {
|
|
2757
|
+
remoteUrl = "N/A";
|
|
2758
|
+
}
|
|
2759
|
+
if (isInteractive && tunnelName) {
|
|
2760
|
+
log.success(`Tunnel connected: ${tunnelName}`);
|
|
2761
|
+
}
|
|
2762
|
+
if (showAsListItem) {
|
|
2763
|
+
log.info(` Local: localhost:${tunnel.clientPort || "N/A"}`);
|
|
2764
|
+
log.info(` Server: ${serverHostname}:${tunnel.sshTunnelPort || 443}`);
|
|
2765
|
+
log.info(` \u{1F310} Remote URL: ${remoteUrl}`);
|
|
2766
|
+
log.info("\u2500".repeat(60));
|
|
2767
|
+
} else {
|
|
2768
|
+
log.info(`Local: localhost:${tunnel.clientPort || "N/A"}`);
|
|
2769
|
+
log.info(`Server: ${serverHostname}:${tunnel.sshTunnelPort || 443}`);
|
|
2770
|
+
log.info(`Remote URL: ${remoteUrl}`);
|
|
2771
|
+
}
|
|
2772
|
+
if (showBackgroundStatus) {
|
|
2773
|
+
log.info("Status: Running in background");
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// src/lib/tunnel-manager.ts
|
|
2778
|
+
import {
|
|
2779
|
+
SshClient,
|
|
2780
|
+
PortForwardingService as PortForwardingService2
|
|
2781
|
+
} from "@microsoft/dev-tunnels-ssh-tcp";
|
|
2782
|
+
|
|
2783
|
+
// src/lib/error-classifier.ts
|
|
2784
|
+
var HTTP_STATUS = {
|
|
2785
|
+
BAD_REQUEST: 400,
|
|
2786
|
+
UNAUTHORIZED: 401,
|
|
2787
|
+
FORBIDDEN: 403,
|
|
2788
|
+
NOT_FOUND: 404,
|
|
2789
|
+
REQUEST_TIMEOUT: 408,
|
|
2790
|
+
RATE_LIMITED: 429,
|
|
2791
|
+
INTERNAL_SERVER_ERROR: 500,
|
|
2792
|
+
BAD_GATEWAY: 502,
|
|
2793
|
+
SERVICE_UNAVAILABLE: 503,
|
|
2794
|
+
GATEWAY_TIMEOUT: 504,
|
|
2795
|
+
CLOUDFLARE_ERROR: 522,
|
|
2796
|
+
ORIGIN_UNREACHABLE: 523,
|
|
2797
|
+
TIMEOUT: 524
|
|
2798
|
+
};
|
|
2799
|
+
var ErrorClassifier = class {
|
|
2800
|
+
/**
|
|
2801
|
+
* Classify an error based on various signals
|
|
2802
|
+
*/
|
|
2803
|
+
static classify(error, context) {
|
|
2804
|
+
const errorMessage = this.extractMessage(error).toLowerCase();
|
|
2805
|
+
const httpStatus = this.extractHttpStatus(error);
|
|
2806
|
+
if (this.isAuthenticationError(errorMessage, httpStatus)) {
|
|
2807
|
+
return this.createAuthenticationError(errorMessage, httpStatus);
|
|
2808
|
+
}
|
|
2809
|
+
if (this.isAuthorizationError(errorMessage, httpStatus)) {
|
|
2810
|
+
return this.createAuthorizationError(errorMessage, httpStatus);
|
|
2811
|
+
}
|
|
2812
|
+
if (this.isNetworkError(errorMessage, httpStatus)) {
|
|
2813
|
+
return this.createNetworkError(errorMessage, httpStatus);
|
|
2814
|
+
}
|
|
2815
|
+
if (this.isValidationError(errorMessage, httpStatus)) {
|
|
2816
|
+
return this.createValidationError(errorMessage, context);
|
|
2817
|
+
}
|
|
2818
|
+
if (this.isServerError(errorMessage, httpStatus)) {
|
|
2819
|
+
return this.createServerError(errorMessage, httpStatus);
|
|
2820
|
+
}
|
|
2821
|
+
if (this.isTunnelError(errorMessage, context)) {
|
|
2822
|
+
return this.createTunnelError(errorMessage, context);
|
|
2823
|
+
}
|
|
2824
|
+
return this.createUnknownError(errorMessage, httpStatus);
|
|
2825
|
+
}
|
|
2826
|
+
/**
|
|
2827
|
+
* Extract message from various error formats
|
|
2828
|
+
*/
|
|
2829
|
+
static extractMessage(error) {
|
|
2830
|
+
if (error instanceof Error) {
|
|
2831
|
+
return error.message;
|
|
2832
|
+
}
|
|
2833
|
+
if (error && typeof error === "object") {
|
|
2834
|
+
const err = error;
|
|
2835
|
+
if (typeof err.message === "string") {
|
|
2836
|
+
return err.message;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
return String(error);
|
|
2840
|
+
}
|
|
2841
|
+
static extractHttpStatus(error) {
|
|
2842
|
+
if (error && typeof error === "object") {
|
|
2843
|
+
const err = error;
|
|
2844
|
+
if (typeof err.status === "number") return err.status;
|
|
2845
|
+
if (typeof err.statusCode === "number") return err.statusCode;
|
|
2846
|
+
if (typeof err.response?.status === "number") return err.response.status;
|
|
2847
|
+
if (typeof err.response?.statusCode === "number")
|
|
2848
|
+
return err.response.statusCode;
|
|
2849
|
+
if (typeof err.code === "number") return err.code;
|
|
2850
|
+
const message = err.message || "";
|
|
2851
|
+
const statusMatch = message.match(/\b([4-5]\d{2})\b/);
|
|
2852
|
+
if (statusMatch) {
|
|
2853
|
+
const statusCode = parseInt(statusMatch[1], 10);
|
|
2854
|
+
if (statusCode >= 400 && statusCode < 600) {
|
|
2855
|
+
return statusCode;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
return void 0;
|
|
2860
|
+
}
|
|
2861
|
+
static isAuthenticationError(message, status) {
|
|
2862
|
+
const authKeywords = [
|
|
2863
|
+
"authentication failed",
|
|
2864
|
+
"token expired",
|
|
2865
|
+
"invalid token",
|
|
2866
|
+
"token invalid",
|
|
2867
|
+
"authentication required",
|
|
2868
|
+
"login required",
|
|
2869
|
+
"session expired",
|
|
2870
|
+
"unauthorized",
|
|
2871
|
+
"not authenticated",
|
|
2872
|
+
"invalid credentials"
|
|
2873
|
+
];
|
|
2874
|
+
return status === HTTP_STATUS.UNAUTHORIZED || authKeywords.some((keyword) => message.includes(keyword));
|
|
2875
|
+
}
|
|
2876
|
+
static isAuthorizationError(message, status) {
|
|
2877
|
+
const authzKeywords = [
|
|
2878
|
+
"forbidden",
|
|
2879
|
+
"access denied",
|
|
2880
|
+
"insufficient permissions",
|
|
2881
|
+
"not authorized",
|
|
2882
|
+
"permission denied",
|
|
2883
|
+
"scope required",
|
|
2884
|
+
"accountpendingapproval",
|
|
2885
|
+
"maxusersexceeded",
|
|
2886
|
+
"user limit reached"
|
|
2887
|
+
];
|
|
2888
|
+
return status === HTTP_STATUS.FORBIDDEN || authzKeywords.some((keyword) => message.includes(keyword));
|
|
2889
|
+
}
|
|
2890
|
+
static isNetworkError(message, status) {
|
|
2891
|
+
const networkKeywords = [
|
|
2892
|
+
"connection timeout",
|
|
2893
|
+
"network error",
|
|
2894
|
+
"connection refused",
|
|
2895
|
+
"connection reset",
|
|
2896
|
+
"dns lookup failed",
|
|
2897
|
+
"host not found",
|
|
2898
|
+
"enotfound",
|
|
2899
|
+
"econnrefused",
|
|
2900
|
+
"econnreset",
|
|
2901
|
+
"etimedout",
|
|
2902
|
+
"socket hang up",
|
|
2903
|
+
"network unreachable",
|
|
2904
|
+
"rate limited",
|
|
2905
|
+
"too many requests",
|
|
2906
|
+
"request rate exceeded",
|
|
2907
|
+
"fetch failed",
|
|
2908
|
+
"server is unreachable",
|
|
2909
|
+
"failed to fetch"
|
|
2910
|
+
];
|
|
2911
|
+
const networkStatuses = [
|
|
2912
|
+
HTTP_STATUS.REQUEST_TIMEOUT,
|
|
2913
|
+
HTTP_STATUS.RATE_LIMITED,
|
|
2914
|
+
HTTP_STATUS.BAD_GATEWAY,
|
|
2915
|
+
HTTP_STATUS.SERVICE_UNAVAILABLE,
|
|
2916
|
+
HTTP_STATUS.GATEWAY_TIMEOUT,
|
|
2917
|
+
HTTP_STATUS.CLOUDFLARE_ERROR,
|
|
2918
|
+
HTTP_STATUS.ORIGIN_UNREACHABLE,
|
|
2919
|
+
HTTP_STATUS.TIMEOUT
|
|
2920
|
+
];
|
|
2921
|
+
return status !== void 0 && networkStatuses.includes(status) || networkKeywords.some((keyword) => message.includes(keyword));
|
|
2922
|
+
}
|
|
2923
|
+
static isValidationError(message, status) {
|
|
2924
|
+
const validationKeywords = [
|
|
2925
|
+
"validation error",
|
|
2926
|
+
"invalid input",
|
|
2927
|
+
"bad request",
|
|
2928
|
+
"malformed",
|
|
2929
|
+
"invalid format",
|
|
2930
|
+
"missing required",
|
|
2931
|
+
"invalid parameter"
|
|
2932
|
+
];
|
|
2933
|
+
return status === HTTP_STATUS.BAD_REQUEST || validationKeywords.some((keyword) => message.includes(keyword));
|
|
2934
|
+
}
|
|
2935
|
+
static isServerError(message, status) {
|
|
2936
|
+
const serverKeywords = [
|
|
2937
|
+
"internal server error",
|
|
2938
|
+
"server error",
|
|
2939
|
+
"service unavailable",
|
|
2940
|
+
"database error",
|
|
2941
|
+
"configuration error"
|
|
2942
|
+
];
|
|
2943
|
+
return status !== void 0 && status >= 500 && status < 600 || serverKeywords.some((keyword) => message.includes(keyword));
|
|
2944
|
+
}
|
|
2945
|
+
static isTunnelError(message, context) {
|
|
2946
|
+
const tunnelKeywords = [
|
|
2947
|
+
"tunnel",
|
|
2948
|
+
"ssh connection",
|
|
2949
|
+
"port forwarding",
|
|
2950
|
+
"local port",
|
|
2951
|
+
"remote port",
|
|
2952
|
+
"address already in use",
|
|
2953
|
+
"port already in use",
|
|
2954
|
+
"bind failed",
|
|
2955
|
+
"eaddrinuse",
|
|
2956
|
+
"port is not available",
|
|
2957
|
+
"connection already exists",
|
|
2958
|
+
"failed to establish tunnel"
|
|
2959
|
+
];
|
|
2960
|
+
const portInUsePattern = /port\s+\d+\s+is\s+already\s+in\s+use/i;
|
|
2961
|
+
const isTunnelContext = context?.toLowerCase().includes("tunnel");
|
|
2962
|
+
return isTunnelContext || tunnelKeywords.some((keyword) => message.includes(keyword)) || portInUsePattern.test(message);
|
|
2963
|
+
}
|
|
2964
|
+
static createAuthenticationError(message, status) {
|
|
2965
|
+
const isTokenExpired2 = message.includes("expired");
|
|
2966
|
+
const isInvalidToken = message.includes("invalid") && message.includes("token");
|
|
2967
|
+
return {
|
|
2968
|
+
type: "authentication" /* AUTHENTICATION */,
|
|
2969
|
+
severity: "high" /* HIGH */,
|
|
2970
|
+
httpStatus: status,
|
|
2971
|
+
isRetryable: true,
|
|
2972
|
+
userMessage: isTokenExpired2 ? "Your session has expired. Please log in again." : isInvalidToken ? "Your authentication token is invalid. Please log in again." : "Authentication required. Please log in.",
|
|
2973
|
+
suggestedActions: [
|
|
2974
|
+
"Run: periscope auth login",
|
|
2975
|
+
"Check status: periscope status"
|
|
2976
|
+
],
|
|
2977
|
+
technicalDetails: isTokenExpired2 ? "Token expiration detected" : isInvalidToken ? "Invalid token format or signature" : "Authentication credentials missing or invalid"
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
static createAuthorizationError(message, status) {
|
|
2981
|
+
const isPendingApproval = message.includes("accountpendingapproval") || message.includes("pending approval");
|
|
2982
|
+
const isMaxUsersExceeded = message.includes("maxusersexceeded") || message.includes("user limit reached");
|
|
2983
|
+
if (isMaxUsersExceeded) {
|
|
2984
|
+
return {
|
|
2985
|
+
type: "authorization" /* AUTHORIZATION */,
|
|
2986
|
+
severity: "high" /* HIGH */,
|
|
2987
|
+
httpStatus: status,
|
|
2988
|
+
isRetryable: false,
|
|
2989
|
+
userMessage: "Your organization has reached its maximum user limit. No new accounts can be created.",
|
|
2990
|
+
suggestedActions: [
|
|
2991
|
+
"Contact your administrator to increase the user limit or remove inactive users",
|
|
2992
|
+
"Check your license details with your administrator"
|
|
2993
|
+
],
|
|
2994
|
+
technicalDetails: "Server returned MaxUsersExceeded (HTTP 403)"
|
|
2995
|
+
};
|
|
2996
|
+
}
|
|
2997
|
+
return {
|
|
2998
|
+
type: "authorization" /* AUTHORIZATION */,
|
|
2999
|
+
severity: "high" /* HIGH */,
|
|
3000
|
+
httpStatus: status,
|
|
3001
|
+
isRetryable: false,
|
|
3002
|
+
userMessage: isPendingApproval ? "Your account is pending approval by an administrator." : "Access denied. You do not have permission to perform this action.",
|
|
3003
|
+
suggestedActions: isPendingApproval ? [
|
|
3004
|
+
"Wait for an administrator to approve your account",
|
|
3005
|
+
"Check your account status: periscope status"
|
|
3006
|
+
] : [
|
|
3007
|
+
"Check your account permissions",
|
|
3008
|
+
"Contact your administrator if you believe you should have access",
|
|
3009
|
+
"Verify you are using the correct account: periscope status"
|
|
3010
|
+
],
|
|
3011
|
+
technicalDetails: isPendingApproval ? "Account status is PendingApproval" : "Insufficient permissions for the requested operation"
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
static createNetworkError(message, status) {
|
|
3015
|
+
const isTimeout = message.includes("timeout");
|
|
3016
|
+
const isConnectionRefused = message.includes("refused") || message.includes("econnrefused");
|
|
3017
|
+
const isDnsError = message.includes("enotfound") || message.includes("dns");
|
|
3018
|
+
const isServerUnreachable = message.includes("server is unreachable") || message.includes("fetch failed");
|
|
3019
|
+
const isRateLimited = status === HTTP_STATUS.RATE_LIMITED || message.includes("rate limited") || message.includes("too many requests");
|
|
3020
|
+
const urlMatch = message.match(/\((https?:\/\/[^)]+)\)/);
|
|
3021
|
+
const serverUrl = urlMatch ? urlMatch[1] : null;
|
|
3022
|
+
let userMessage;
|
|
3023
|
+
if (isServerUnreachable) {
|
|
3024
|
+
userMessage = serverUrl ? `Cannot connect to server at ${serverUrl}. Please check your network connection.` : "Cannot connect to server. Please check your network connection and server URL.";
|
|
3025
|
+
} else if (isRateLimited) {
|
|
3026
|
+
userMessage = "Too many requests. Please wait a few minutes and try again.";
|
|
3027
|
+
} else if (isDnsError) {
|
|
3028
|
+
userMessage = serverUrl ? `Cannot resolve server address for ${serverUrl}. Please check the server URL.` : "Cannot resolve server address. Please check the server URL.";
|
|
3029
|
+
} else if (isConnectionRefused) {
|
|
3030
|
+
userMessage = serverUrl ? `Connection refused by ${serverUrl}. The server may be down or unreachable.` : "Connection refused. The server may be down or unreachable.";
|
|
3031
|
+
} else if (isTimeout) {
|
|
3032
|
+
userMessage = "Request timed out. Please try again.";
|
|
3033
|
+
} else {
|
|
3034
|
+
userMessage = "Network error. Please check your connection.";
|
|
3035
|
+
}
|
|
3036
|
+
return {
|
|
3037
|
+
type: "network" /* NETWORK */,
|
|
3038
|
+
severity: isTimeout ? "medium" /* MEDIUM */ : isRateLimited ? "low" /* LOW */ : "high" /* HIGH */,
|
|
3039
|
+
httpStatus: status,
|
|
3040
|
+
isRetryable: true,
|
|
3041
|
+
userMessage,
|
|
3042
|
+
suggestedActions: isRateLimited ? [
|
|
3043
|
+
"Wait a few minutes before trying again",
|
|
3044
|
+
"Reduce the frequency of requests",
|
|
3045
|
+
"Check if you have exceeded the rate limit"
|
|
3046
|
+
] : [
|
|
3047
|
+
"Check your internet connection",
|
|
3048
|
+
"Verify the server URL is correct: periscope config get",
|
|
3049
|
+
"Check network connectivity and firewall settings"
|
|
3050
|
+
],
|
|
3051
|
+
technicalDetails: isDnsError ? "DNS resolution failed" : isConnectionRefused ? "Connection refused by server" : isTimeout ? "Request timeout" : isRateLimited ? "API rate limit exceeded" : "Network connectivity issue"
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
static createValidationError(message, context) {
|
|
3055
|
+
return {
|
|
3056
|
+
type: "validation" /* VALIDATION */,
|
|
3057
|
+
severity: "low" /* LOW */,
|
|
3058
|
+
httpStatus: HTTP_STATUS.BAD_REQUEST,
|
|
3059
|
+
isRetryable: false,
|
|
3060
|
+
userMessage: "Invalid input. Please check your command parameters.",
|
|
3061
|
+
suggestedActions: [
|
|
3062
|
+
"Check the command parameters and try again",
|
|
3063
|
+
"Review the command documentation: periscope --help",
|
|
3064
|
+
"Ensure all required parameters are provided"
|
|
3065
|
+
],
|
|
3066
|
+
technicalDetails: `Input validation failed${context ? ` in context: ${context}` : ""}: ${message}`
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
static createServerError(message, status) {
|
|
3070
|
+
const isServiceUnavailable = status === HTTP_STATUS.SERVICE_UNAVAILABLE || message.includes("unavailable");
|
|
3071
|
+
return {
|
|
3072
|
+
type: "server" /* SERVER */,
|
|
3073
|
+
severity: "high" /* HIGH */,
|
|
3074
|
+
httpStatus: status,
|
|
3075
|
+
isRetryable: isServiceUnavailable,
|
|
3076
|
+
userMessage: isServiceUnavailable ? "Server is temporarily unavailable. Please try again later." : "Server error. Please try again or contact support.",
|
|
3077
|
+
suggestedActions: [
|
|
3078
|
+
"Try again in a few minutes",
|
|
3079
|
+
"Check server status or contact support if the problem persists"
|
|
3080
|
+
],
|
|
3081
|
+
technicalDetails: isServiceUnavailable ? "Service temporarily unavailable" : "Internal server error"
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
static createTunnelError(message, context) {
|
|
3085
|
+
const portInUsePattern = /port\s+\d+\s+is\s+already\s+in\s+use/i;
|
|
3086
|
+
const isPortError = message.includes("port") && (message.includes("use") || message.includes("bound")) || portInUsePattern.test(message);
|
|
3087
|
+
const isAddressInUse = message.includes("address already in use") || message.includes("eaddrinuse");
|
|
3088
|
+
const isLocalServiceError = message.includes("local") && message.includes("service");
|
|
3089
|
+
const isBindError = message.includes("bind failed");
|
|
3090
|
+
const isConnectionFailed = message.includes("failed to establish tunnel") || message.includes("ssh connection");
|
|
3091
|
+
return {
|
|
3092
|
+
type: "tunnel" /* TUNNEL */,
|
|
3093
|
+
severity: isPortError || isAddressInUse || isBindError ? "high" /* HIGH */ : "medium" /* MEDIUM */,
|
|
3094
|
+
isRetryable: !(isPortError || isAddressInUse || isBindError),
|
|
3095
|
+
userMessage: isAddressInUse || isPortError ? "Port is already in use. Try a different local port." : isBindError ? "Failed to bind to port. Check port availability and permissions." : isLocalServiceError ? "Local service is not running." : isConnectionFailed ? "Failed to establish SSH connection to tunnel server." : "Tunnel operation failed.",
|
|
3096
|
+
suggestedActions: [
|
|
3097
|
+
"Try using a different local port",
|
|
3098
|
+
"Verify local port is not already in use: netstat -tlnp | grep :PORT",
|
|
3099
|
+
"Check tunnel connection: periscope connect <name> --target http://localhost:<port>",
|
|
3100
|
+
"Ensure local service is running on the specified port"
|
|
3101
|
+
],
|
|
3102
|
+
technicalDetails: `${isAddressInUse ? "Address/port already in use (EADDRINUSE)" : isPortError ? "Port conflict detected" : isBindError ? "Port binding failed" : isLocalServiceError ? "Local service not available" : isConnectionFailed ? "SSH tunnel connection failed" : "Tunnel operation failed"}${context ? ` in context: ${context}` : ""}`
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
static createUnknownError(message, httpStatus) {
|
|
3106
|
+
return {
|
|
3107
|
+
type: "unknown" /* UNKNOWN */,
|
|
3108
|
+
severity: "medium" /* MEDIUM */,
|
|
3109
|
+
httpStatus,
|
|
3110
|
+
isRetryable: true,
|
|
3111
|
+
userMessage: "An unexpected error occurred. Please try again.",
|
|
3112
|
+
suggestedActions: [
|
|
3113
|
+
"Try the operation again",
|
|
3114
|
+
"Check the command documentation: periscope --help",
|
|
3115
|
+
"Contact support if the issue persists"
|
|
3116
|
+
],
|
|
3117
|
+
technicalDetails: `Unclassified error occurred: ${message}`
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
};
|
|
3121
|
+
|
|
3122
|
+
// src/lib/request-monitor.ts
|
|
3123
|
+
import chalk3 from "chalk";
|
|
3124
|
+
import { PortForwardingService } from "@microsoft/dev-tunnels-ssh-tcp";
|
|
3125
|
+
|
|
3126
|
+
// src/lib/config-manager.ts
|
|
3127
|
+
import * as fs8 from "fs";
|
|
3128
|
+
import * as path7 from "path";
|
|
3129
|
+
import * as os4 from "os";
|
|
3130
|
+
import * as dotenv from "dotenv";
|
|
3131
|
+
var ConfigManager = class {
|
|
3132
|
+
static CONFIG_DIR = path7.join(os4.homedir(), ".periscope");
|
|
3133
|
+
static CONFIG_FILE = "config.json";
|
|
3134
|
+
static testConfig = null;
|
|
3135
|
+
static getConfigPath() {
|
|
3136
|
+
return path7.join(this.CONFIG_DIR, this.CONFIG_FILE);
|
|
3137
|
+
}
|
|
3138
|
+
/**
|
|
3139
|
+
* Set a test configuration that bypasses file system operations
|
|
3140
|
+
* Only use this in tests!
|
|
3141
|
+
*/
|
|
3142
|
+
static setTestConfig(config2) {
|
|
3143
|
+
if (process.env.NODE_ENV !== "test" && !process.env.VITEST) {
|
|
3144
|
+
throw new Error("setTestConfig can only be used in test environment");
|
|
3145
|
+
}
|
|
3146
|
+
this.testConfig = config2;
|
|
3147
|
+
}
|
|
3148
|
+
/**
|
|
3149
|
+
* Load configuration from file, environment variables, and .env file
|
|
3150
|
+
* Priority order: environment variables > .env file > config file
|
|
3151
|
+
*/
|
|
3152
|
+
static load() {
|
|
3153
|
+
let config2 = {};
|
|
3154
|
+
if (this.testConfig !== null) {
|
|
3155
|
+
config2 = { ...this.testConfig };
|
|
3156
|
+
} else {
|
|
3157
|
+
this.loadDotEnv();
|
|
3158
|
+
try {
|
|
3159
|
+
const configPath = this.getConfigPath();
|
|
3160
|
+
if (fs8.existsSync(configPath)) {
|
|
3161
|
+
const content = fs8.readFileSync(configPath, "utf-8");
|
|
3162
|
+
config2 = JSON.parse(content);
|
|
3163
|
+
}
|
|
3164
|
+
} catch (error) {
|
|
3165
|
+
log.debug("Failed to load config:", error);
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
config2 = this.mergeEnvironmentVariables(config2);
|
|
3169
|
+
return config2;
|
|
3170
|
+
}
|
|
3171
|
+
/**
|
|
3172
|
+
* Load .env file from current working directory or project root
|
|
3173
|
+
*/
|
|
3174
|
+
static loadDotEnv() {
|
|
3175
|
+
const cwd = process.cwd();
|
|
3176
|
+
const envPaths = [
|
|
3177
|
+
path7.join(cwd, ".env"),
|
|
3178
|
+
path7.join(cwd, ".env.local"),
|
|
3179
|
+
// Also try parent directories up to 3 levels
|
|
3180
|
+
path7.join(cwd, "..", ".env"),
|
|
3181
|
+
path7.join(cwd, "..", "..", ".env"),
|
|
3182
|
+
path7.join(cwd, "..", "..", "..", ".env")
|
|
3183
|
+
];
|
|
3184
|
+
for (const envPath of envPaths) {
|
|
3185
|
+
if (fs8.existsSync(envPath)) {
|
|
3186
|
+
dotenv.config({ path: envPath });
|
|
3187
|
+
break;
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
/**
|
|
3192
|
+
* Merge environment variables into configuration
|
|
3193
|
+
*/
|
|
3194
|
+
static mergeEnvironmentVariables(config2) {
|
|
3195
|
+
const env = process.env;
|
|
3196
|
+
if (env.PERISCOPE_SERVER_URL) {
|
|
3197
|
+
config2.serverUrl = env.PERISCOPE_SERVER_URL;
|
|
3198
|
+
}
|
|
3199
|
+
if (env.PERISCOPE_LOG_LEVEL) {
|
|
3200
|
+
config2.logLevel = env.PERISCOPE_LOG_LEVEL;
|
|
3201
|
+
}
|
|
3202
|
+
if (env.PERISCOPE_CA_CERT_PATH) {
|
|
3203
|
+
config2.caCertPath = env.PERISCOPE_CA_CERT_PATH;
|
|
3204
|
+
}
|
|
3205
|
+
if (env.PERISCOPE_SHOW_REQUEST_LOG !== void 0) {
|
|
3206
|
+
config2.showRequestLog = env.PERISCOPE_SHOW_REQUEST_LOG !== "false";
|
|
3207
|
+
}
|
|
3208
|
+
if (env.PERISCOPE_SSH_KEY_PATH) {
|
|
3209
|
+
config2.sshKeyPath = env.PERISCOPE_SSH_KEY_PATH;
|
|
3210
|
+
}
|
|
3211
|
+
if (env.PERISCOPE_ALLOW_EXTERNAL_KEY !== void 0) {
|
|
3212
|
+
config2.allowExternalKey = env.PERISCOPE_ALLOW_EXTERNAL_KEY === "true";
|
|
3213
|
+
}
|
|
3214
|
+
return config2;
|
|
3215
|
+
}
|
|
3216
|
+
static save(config2) {
|
|
3217
|
+
if (this.testConfig !== null) {
|
|
3218
|
+
this.testConfig = { ...config2 };
|
|
3219
|
+
return;
|
|
3220
|
+
}
|
|
3221
|
+
try {
|
|
3222
|
+
if (!fs8.existsSync(this.CONFIG_DIR)) {
|
|
3223
|
+
fs8.mkdirSync(this.CONFIG_DIR, { recursive: true });
|
|
3224
|
+
}
|
|
3225
|
+
const configPath = this.getConfigPath();
|
|
3226
|
+
fs8.writeFileSync(configPath, JSON.stringify(config2, null, 2));
|
|
3227
|
+
fs8.chmodSync(configPath, 384);
|
|
3228
|
+
} catch (error) {
|
|
3229
|
+
throw new Error(
|
|
3230
|
+
`Failed to save config: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
};
|
|
3235
|
+
|
|
3236
|
+
// src/lib/request-monitor.ts
|
|
3237
|
+
var HTTP_METHODS = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\s+(\S+)/i;
|
|
3238
|
+
var MAX_PEEK_BYTES = 256;
|
|
3239
|
+
function parseAndLogRequestLine(chunk, _tunnelName) {
|
|
3240
|
+
try {
|
|
3241
|
+
const str = chunk.toString(
|
|
3242
|
+
"utf8",
|
|
3243
|
+
0,
|
|
3244
|
+
Math.min(chunk.length, MAX_PEEK_BYTES)
|
|
3245
|
+
);
|
|
3246
|
+
const firstLine = str.split("\r\n")[0] || str.split("\n")[0];
|
|
3247
|
+
const match = firstLine?.match(HTTP_METHODS);
|
|
3248
|
+
if (match) {
|
|
3249
|
+
log.raw(chalk3.dim(` \u2190 ${match[1].toUpperCase()} ${match[2]}`));
|
|
3250
|
+
return true;
|
|
3251
|
+
}
|
|
3252
|
+
} catch {
|
|
3253
|
+
}
|
|
3254
|
+
return false;
|
|
3255
|
+
}
|
|
3256
|
+
function monitorStream(stream, tunnelName) {
|
|
3257
|
+
const originalPush = stream.push.bind(stream);
|
|
3258
|
+
stream.push = function(chunk, encoding) {
|
|
3259
|
+
if (chunk != null) {
|
|
3260
|
+
try {
|
|
3261
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3262
|
+
parseAndLogRequestLine(buf, tunnelName);
|
|
3263
|
+
} catch {
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
return originalPush(chunk, encoding);
|
|
3267
|
+
};
|
|
3268
|
+
}
|
|
3269
|
+
function setupRequestMonitor(session, tunnelName) {
|
|
3270
|
+
const config2 = ConfigManager.load();
|
|
3271
|
+
const monitorEnabled = config2.showRequestLog !== false;
|
|
3272
|
+
try {
|
|
3273
|
+
const pfs = session.activateService(PortForwardingService);
|
|
3274
|
+
pfs.onForwardedPortConnecting((e) => {
|
|
3275
|
+
if (e.isIncoming) {
|
|
3276
|
+
e.stream.on("error", (err) => {
|
|
3277
|
+
if (isSshChannelDisposedError(err)) {
|
|
3278
|
+
log.debug(
|
|
3279
|
+
`SSH stream closed for tunnel '${tunnelName}': ${err.message}`
|
|
3280
|
+
);
|
|
3281
|
+
} else {
|
|
3282
|
+
log.debug(
|
|
3283
|
+
`SSH stream error for tunnel '${tunnelName}': ${err.message}`
|
|
3284
|
+
);
|
|
3285
|
+
}
|
|
3286
|
+
});
|
|
3287
|
+
if (monitorEnabled) {
|
|
3288
|
+
monitorStream(e.stream, tunnelName);
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
});
|
|
3292
|
+
log.debug(
|
|
3293
|
+
monitorEnabled ? `Request monitor active for tunnel '${tunnelName}'` : `Stream error handler active for tunnel '${tunnelName}' (request logging disabled)`
|
|
3294
|
+
);
|
|
3295
|
+
} catch (error) {
|
|
3296
|
+
log.debug(
|
|
3297
|
+
`Could not activate request monitor: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
// src/lib/tunnel-manager.ts
|
|
3303
|
+
import * as net from "net";
|
|
3304
|
+
var PORT_CHECK_TIMEOUT_MS = 2e3;
|
|
3305
|
+
var PORT_MONITORING_INTERVAL_MS = 3e3;
|
|
3306
|
+
var DEFAULT_SSH_TUNNEL_PORT = 2222;
|
|
3307
|
+
var SSH_HOST_KEY_VALIDATION_TIMEOUT_MS = 1e4;
|
|
3308
|
+
var RECONNECT_INITIAL_DELAY_MS = 1e3;
|
|
3309
|
+
var RECONNECT_MAX_DELAY_MS = 3e4;
|
|
3310
|
+
var RECONNECT_BACKOFF_MULTIPLIER = 2;
|
|
3311
|
+
var RECONNECT_MAX_ATTEMPTS = 10;
|
|
3312
|
+
var TunnelManager = class {
|
|
3313
|
+
constructor(client3) {
|
|
3314
|
+
this.client = client3;
|
|
3315
|
+
this.clientConfig = this.client.getConfig();
|
|
3316
|
+
if (!this.clientConfig.serverUrl) {
|
|
3317
|
+
log.error("No server URL configured for tunnel manager");
|
|
3318
|
+
throw new Error("Server URL is not configured");
|
|
3319
|
+
}
|
|
3320
|
+
setupSecureCleanup();
|
|
3321
|
+
registerTunnelManager(this);
|
|
3322
|
+
}
|
|
3323
|
+
activeTunnels = /* @__PURE__ */ new Map();
|
|
3324
|
+
sshClients = /* @__PURE__ */ new Map();
|
|
3325
|
+
retryIntervals = /* @__PURE__ */ new Map();
|
|
3326
|
+
// Maps tunnel name to tunnel info for tracking local state
|
|
3327
|
+
tunnelInfoMap = /* @__PURE__ */ new Map();
|
|
3328
|
+
// Tracks active reconnection attempts per tunnel to prevent concurrent reconnects
|
|
3329
|
+
reconnecting = /* @__PURE__ */ new Map();
|
|
3330
|
+
// Tracks pending reconnection timeouts so they can be cancelled on cleanup
|
|
3331
|
+
reconnectTimeouts = /* @__PURE__ */ new Map();
|
|
3332
|
+
// Tracks tunnels being intentionally closed by the client so onClosed handler
|
|
3333
|
+
// can distinguish client-initiated closes from server rejections
|
|
3334
|
+
intentionalDisconnects = /* @__PURE__ */ new Set();
|
|
3335
|
+
clientConfig;
|
|
3336
|
+
// SSH key pair loaded once per TunnelManager instance (set in connect()).
|
|
3337
|
+
// Reusing the same KeyPair across reconnects avoids repeated disk reads and
|
|
3338
|
+
// ensures consistent auth if the key file is rotated while a tunnel is active.
|
|
3339
|
+
sshKeyPair = null;
|
|
3340
|
+
/**
|
|
3341
|
+
* Check if a local port is listening
|
|
3342
|
+
*/
|
|
3343
|
+
async isPortListening(port, host = "localhost") {
|
|
3344
|
+
return new Promise((resolve) => {
|
|
3345
|
+
const socket = new net.Socket();
|
|
3346
|
+
socket.setTimeout(PORT_CHECK_TIMEOUT_MS);
|
|
3347
|
+
socket.on("connect", () => {
|
|
3348
|
+
socket.destroy();
|
|
3349
|
+
resolve(true);
|
|
3350
|
+
});
|
|
3351
|
+
socket.on("timeout", () => {
|
|
3352
|
+
socket.destroy();
|
|
3353
|
+
resolve(false);
|
|
3354
|
+
});
|
|
3355
|
+
socket.on("error", () => {
|
|
3356
|
+
resolve(false);
|
|
3357
|
+
});
|
|
3358
|
+
socket.connect(port, host);
|
|
3359
|
+
});
|
|
3360
|
+
}
|
|
3361
|
+
/**
|
|
3362
|
+
* Start monitoring for local service availability and manage tunnel connection
|
|
3363
|
+
* TODO(ELF-122): Add connection queuing/debouncing to prevent race conditions
|
|
3364
|
+
* during rapid port state changes
|
|
3365
|
+
*/
|
|
3366
|
+
startPortMonitoring(tunnelInfo) {
|
|
3367
|
+
const tunnelName = tunnelInfo.name;
|
|
3368
|
+
const localPort = tunnelInfo.clientPort;
|
|
3369
|
+
if (this.retryIntervals.has(tunnelName)) {
|
|
3370
|
+
clearInterval(this.retryIntervals.get(tunnelName));
|
|
3371
|
+
}
|
|
3372
|
+
let lastServiceState = null;
|
|
3373
|
+
let isReconnecting = false;
|
|
3374
|
+
const interval = setInterval(async () => {
|
|
3375
|
+
try {
|
|
3376
|
+
const currentServiceState = await this.isPortListening(localPort);
|
|
3377
|
+
const tunnelIsActive = this.activeTunnels.has(tunnelName);
|
|
3378
|
+
if (lastServiceState !== null && lastServiceState !== currentServiceState) {
|
|
3379
|
+
if (currentServiceState && !tunnelIsActive && !isReconnecting && !this.reconnecting.get(tunnelName)) {
|
|
3380
|
+
log.debug(
|
|
3381
|
+
`Local service started on port ${localPort} - reconnecting tunnel`
|
|
3382
|
+
);
|
|
3383
|
+
isReconnecting = true;
|
|
3384
|
+
try {
|
|
3385
|
+
await this.createSSHConnection(tunnelInfo);
|
|
3386
|
+
} catch (error) {
|
|
3387
|
+
log.error(
|
|
3388
|
+
`Failed to reconnect tunnel: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
3389
|
+
);
|
|
3390
|
+
}
|
|
3391
|
+
isReconnecting = false;
|
|
3392
|
+
} else if (!currentServiceState && tunnelIsActive) {
|
|
3393
|
+
log.debug(
|
|
3394
|
+
`Local service stopped on port ${localPort} - disconnecting tunnel`
|
|
3395
|
+
);
|
|
3396
|
+
await this.disconnectTunnel(tunnelName);
|
|
3397
|
+
}
|
|
3398
|
+
} else if (lastServiceState === null) {
|
|
3399
|
+
if (currentServiceState) {
|
|
3400
|
+
log.debug(
|
|
3401
|
+
`Local service detected on port ${localPort} - tunnel active`
|
|
3402
|
+
);
|
|
3403
|
+
} else {
|
|
3404
|
+
log.debug(
|
|
3405
|
+
`No local service on port ${localPort} - tunnel will connect when service starts`
|
|
3406
|
+
);
|
|
3407
|
+
if (tunnelIsActive) {
|
|
3408
|
+
await this.disconnectTunnel(tunnelName);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
lastServiceState = currentServiceState;
|
|
3413
|
+
} catch (error) {
|
|
3414
|
+
log.error(
|
|
3415
|
+
`Error in port monitoring: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
3416
|
+
);
|
|
3417
|
+
}
|
|
3418
|
+
}, PORT_MONITORING_INTERVAL_MS);
|
|
3419
|
+
this.retryIntervals.set(tunnelName, interval);
|
|
3420
|
+
}
|
|
3421
|
+
/**
|
|
3422
|
+
* Disconnect tunnel without stopping the monitoring
|
|
3423
|
+
*/
|
|
3424
|
+
async disconnectTunnel(tunnelName) {
|
|
3425
|
+
const session = this.activeTunnels.get(tunnelName);
|
|
3426
|
+
if (session) {
|
|
3427
|
+
this.intentionalDisconnects.add(tunnelName);
|
|
3428
|
+
await session.close(
|
|
3429
|
+
SshDisconnectReason.byApplication,
|
|
3430
|
+
"Local service unavailable"
|
|
3431
|
+
);
|
|
3432
|
+
this.activeTunnels.delete(tunnelName);
|
|
3433
|
+
}
|
|
3434
|
+
const sshClient = this.sshClients.get(tunnelName);
|
|
3435
|
+
if (sshClient) {
|
|
3436
|
+
sshClient.dispose();
|
|
3437
|
+
this.sshClients.delete(tunnelName);
|
|
3438
|
+
}
|
|
3439
|
+
const tunnelInfo = this.tunnelInfoMap.get(tunnelName);
|
|
3440
|
+
if (tunnelInfo) {
|
|
3441
|
+
tunnelInfo.isConnected = false;
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
/**
|
|
3445
|
+
* Handle unexpected server disconnection by attempting reconnection with exponential backoff.
|
|
3446
|
+
* Only triggers when the disconnect was NOT initiated by the client (e.g., server restart).
|
|
3447
|
+
*/
|
|
3448
|
+
async handleServerDisconnect(tunnelName) {
|
|
3449
|
+
if (this.reconnecting.get(tunnelName)) {
|
|
3450
|
+
log.debug(`Reconnection already in progress for tunnel '${tunnelName}'`);
|
|
3451
|
+
return;
|
|
3452
|
+
}
|
|
3453
|
+
const tunnelInfo = this.tunnelInfoMap.get(tunnelName);
|
|
3454
|
+
if (!tunnelInfo) {
|
|
3455
|
+
log.debug(
|
|
3456
|
+
`Tunnel '${tunnelName}' no longer tracked, skipping reconnection`
|
|
3457
|
+
);
|
|
3458
|
+
return;
|
|
3459
|
+
}
|
|
3460
|
+
this.activeTunnels.delete(tunnelName);
|
|
3461
|
+
const sshClient = this.sshClients.get(tunnelName);
|
|
3462
|
+
if (sshClient) {
|
|
3463
|
+
sshClient.dispose();
|
|
3464
|
+
this.sshClients.delete(tunnelName);
|
|
3465
|
+
}
|
|
3466
|
+
tunnelInfo.isConnected = false;
|
|
3467
|
+
this.reconnecting.set(tunnelName, true);
|
|
3468
|
+
let attempt = 0;
|
|
3469
|
+
let delay = RECONNECT_INITIAL_DELAY_MS;
|
|
3470
|
+
const attemptReconnect = async () => {
|
|
3471
|
+
if (!this.tunnelInfoMap.has(tunnelName) || !this.reconnecting.get(tunnelName)) {
|
|
3472
|
+
log.debug(`Reconnection cancelled for tunnel '${tunnelName}'`);
|
|
3473
|
+
this.reconnecting.delete(tunnelName);
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
attempt++;
|
|
3477
|
+
log.info(
|
|
3478
|
+
`Reconnecting tunnel '${tunnelName}' (attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS})...`
|
|
3479
|
+
);
|
|
3480
|
+
const isServiceRunning = await this.isPortListening(
|
|
3481
|
+
tunnelInfo.clientPort
|
|
3482
|
+
);
|
|
3483
|
+
if (!isServiceRunning) {
|
|
3484
|
+
log.info(
|
|
3485
|
+
`Local service on port ${tunnelInfo.clientPort} is not running, deferring reconnection to port monitor`
|
|
3486
|
+
);
|
|
3487
|
+
this.reconnecting.delete(tunnelName);
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3490
|
+
try {
|
|
3491
|
+
await this.createSSHConnection(tunnelInfo);
|
|
3492
|
+
if (!this.reconnecting.get(tunnelName) || !this.tunnelInfoMap.has(tunnelName)) {
|
|
3493
|
+
log.debug(
|
|
3494
|
+
`Tunnel '${tunnelName}' was disconnected during reconnection, cleaning up`
|
|
3495
|
+
);
|
|
3496
|
+
const newSession = this.activeTunnels.get(tunnelName);
|
|
3497
|
+
if (newSession) {
|
|
3498
|
+
this.intentionalDisconnects.add(tunnelName);
|
|
3499
|
+
await newSession.close(SshDisconnectReason.byApplication);
|
|
3500
|
+
this.activeTunnels.delete(tunnelName);
|
|
3501
|
+
}
|
|
3502
|
+
const newClient = this.sshClients.get(tunnelName);
|
|
3503
|
+
if (newClient) {
|
|
3504
|
+
newClient.dispose();
|
|
3505
|
+
this.sshClients.delete(tunnelName);
|
|
3506
|
+
}
|
|
3507
|
+
this.reconnecting.delete(tunnelName);
|
|
3508
|
+
return;
|
|
3509
|
+
}
|
|
3510
|
+
log.success(`Tunnel '${tunnelName}' reconnected successfully`);
|
|
3511
|
+
this.reconnecting.delete(tunnelName);
|
|
3512
|
+
this.reconnectTimeouts.delete(tunnelName);
|
|
3513
|
+
return;
|
|
3514
|
+
} catch (error) {
|
|
3515
|
+
const classified = ErrorClassifier.classify(error, "tunnel-reconnect");
|
|
3516
|
+
const message = classified.type === "network" /* NETWORK */ ? "server unreachable (may still be restarting)" : classified.userMessage;
|
|
3517
|
+
log.warn(
|
|
3518
|
+
`Reconnection attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS} for tunnel '${tunnelName}': ${message}`
|
|
3519
|
+
);
|
|
3520
|
+
}
|
|
3521
|
+
if (attempt >= RECONNECT_MAX_ATTEMPTS) {
|
|
3522
|
+
log.error(
|
|
3523
|
+
`Failed to reconnect tunnel '${tunnelName}' after ${RECONNECT_MAX_ATTEMPTS} attempts. Port monitor will retry when server becomes available.`
|
|
3524
|
+
);
|
|
3525
|
+
this.reconnecting.delete(tunnelName);
|
|
3526
|
+
this.reconnectTimeouts.delete(tunnelName);
|
|
3527
|
+
return;
|
|
3528
|
+
}
|
|
3529
|
+
delay = Math.min(
|
|
3530
|
+
delay * RECONNECT_BACKOFF_MULTIPLIER,
|
|
3531
|
+
RECONNECT_MAX_DELAY_MS
|
|
3532
|
+
);
|
|
3533
|
+
log.debug(`Next reconnection attempt for '${tunnelName}' in ${delay}ms`);
|
|
3534
|
+
const timeout2 = setTimeout(() => {
|
|
3535
|
+
attemptReconnect();
|
|
3536
|
+
}, delay);
|
|
3537
|
+
this.reconnectTimeouts.set(tunnelName, timeout2);
|
|
3538
|
+
};
|
|
3539
|
+
log.warn(
|
|
3540
|
+
`Server connection lost for tunnel '${tunnelName}', will attempt reconnection...`
|
|
3541
|
+
);
|
|
3542
|
+
const timeout = setTimeout(() => {
|
|
3543
|
+
attemptReconnect();
|
|
3544
|
+
}, delay);
|
|
3545
|
+
this.reconnectTimeouts.set(tunnelName, timeout);
|
|
3546
|
+
}
|
|
3547
|
+
/**
|
|
3548
|
+
* Establishes SSH connection with authentication
|
|
3549
|
+
* TODO(ELF-123): Add configurable timeout for SSH connection establishment
|
|
3550
|
+
*/
|
|
3551
|
+
async createSSHConnection(tunnelInfo) {
|
|
3552
|
+
const name = tunnelInfo.name;
|
|
3553
|
+
const sshCredentials = await this.client.getSSHCredentials();
|
|
3554
|
+
if (sshCredentials.wildcardHostname) {
|
|
3555
|
+
tunnelInfo.wildcardHostname = sshCredentials.wildcardHostname;
|
|
3556
|
+
tunnelInfo.urlSeparator = sshCredentials.urlSeparator || ".";
|
|
3557
|
+
}
|
|
3558
|
+
if (sshCredentials.serverPort) {
|
|
3559
|
+
tunnelInfo.sshTunnelPort = sshCredentials.serverPort;
|
|
3560
|
+
}
|
|
3561
|
+
if (sshCredentials.slug) {
|
|
3562
|
+
tunnelInfo.slug = sshCredentials.slug;
|
|
3563
|
+
}
|
|
3564
|
+
if (!this.sshKeyPair) {
|
|
3565
|
+
throw new Error(
|
|
3566
|
+
"SSH key pair not loaded. Call connect() before createSSHConnection()."
|
|
3567
|
+
);
|
|
3568
|
+
}
|
|
3569
|
+
const keyPair = this.sshKeyPair;
|
|
3570
|
+
const publicKey = await SshKeyManager.exportPublicKey(keyPair);
|
|
3571
|
+
await this.client.registerPublicKey(publicKey);
|
|
3572
|
+
const sessionConfig = new SshSessionConfiguration();
|
|
3573
|
+
sessionConfig.addService(PortForwardingService2);
|
|
3574
|
+
const sshClient = new SshClient(sessionConfig);
|
|
3575
|
+
const baseHostname = new URL(this.clientConfig.serverUrl).hostname;
|
|
3576
|
+
const serverHostname = sshCredentials.sshHost || baseHostname;
|
|
3577
|
+
tunnelInfo.sshHost = serverHostname;
|
|
3578
|
+
const serverPort = tunnelInfo.sshTunnelPort;
|
|
3579
|
+
log.debug(`Connecting to SSH server: ${serverHostname}:${serverPort}`);
|
|
3580
|
+
const session = await sshClient.openSession(serverHostname, serverPort);
|
|
3581
|
+
session.onAuthenticating((e) => {
|
|
3582
|
+
log.trace(`Server authentication event - Type: ${e.authenticationType}`);
|
|
3583
|
+
const serverIdentity = {
|
|
3584
|
+
identity: { isAuthenticated: true, name: "server" }
|
|
3585
|
+
};
|
|
3586
|
+
if (e.authenticationType !== SshAuthenticationType.serverPublicKey) {
|
|
3587
|
+
e.authenticationPromise = Promise.reject(
|
|
3588
|
+
new Error(
|
|
3589
|
+
`Unexpected server authentication type: ${e.authenticationType}`
|
|
3590
|
+
)
|
|
3591
|
+
);
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
if (!e.publicKey) {
|
|
3595
|
+
e.authenticationPromise = Promise.reject(
|
|
3596
|
+
new Error("Server did not present a public key during SSH handshake")
|
|
3597
|
+
);
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3600
|
+
const pk = e.publicKey;
|
|
3601
|
+
log.trace(`Server public key algorithm: ${pk.keyAlgorithmName}`);
|
|
3602
|
+
e.authenticationPromise = (async () => {
|
|
3603
|
+
const keyBytes = await pk.getPublicKeyBytes(pk.keyAlgorithmName);
|
|
3604
|
+
if (!keyBytes) {
|
|
3605
|
+
throw new Error("Failed to read server public key bytes");
|
|
3606
|
+
}
|
|
3607
|
+
const base64KeyBytes = Buffer.from(keyBytes).toString("base64");
|
|
3608
|
+
try {
|
|
3609
|
+
let timeoutId;
|
|
3610
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
3611
|
+
timeoutId = setTimeout(
|
|
3612
|
+
() => reject(
|
|
3613
|
+
new Error(
|
|
3614
|
+
`timed out after ${SSH_HOST_KEY_VALIDATION_TIMEOUT_MS / 1e3} s`
|
|
3615
|
+
)
|
|
3616
|
+
),
|
|
3617
|
+
SSH_HOST_KEY_VALIDATION_TIMEOUT_MS
|
|
3618
|
+
);
|
|
3619
|
+
});
|
|
3620
|
+
try {
|
|
3621
|
+
await Promise.race([
|
|
3622
|
+
this.client.validateSshHostKey(base64KeyBytes),
|
|
3623
|
+
timeoutPromise
|
|
3624
|
+
]);
|
|
3625
|
+
} finally {
|
|
3626
|
+
clearTimeout(timeoutId);
|
|
3627
|
+
}
|
|
3628
|
+
log.trace("SSH host key verified via server API");
|
|
3629
|
+
} catch (error) {
|
|
3630
|
+
if (PeriscopeApi.ApiException.isApiException(error) && error.status === 422) {
|
|
3631
|
+
throw new Error(
|
|
3632
|
+
"SSH host key verification failed: the key received during the SSH handshake does not match the server key. This may indicate a MITM attack. Contact your administrator if the server was recently updated."
|
|
3633
|
+
);
|
|
3634
|
+
}
|
|
3635
|
+
throw new Error(
|
|
3636
|
+
`SSH host key validation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
3637
|
+
);
|
|
3638
|
+
}
|
|
3639
|
+
return serverIdentity;
|
|
3640
|
+
})();
|
|
3641
|
+
});
|
|
3642
|
+
const serverAuthenticated = await session.authenticateServer();
|
|
3643
|
+
if (!serverAuthenticated) {
|
|
3644
|
+
throw new Error("Server authentication failed");
|
|
3645
|
+
}
|
|
3646
|
+
let username = `${sshCredentials.email}:${tunnelInfo.name}:${tunnelInfo.clientPort}`;
|
|
3647
|
+
if (tunnelInfo.localScheme === "https") {
|
|
3648
|
+
username += `:https`;
|
|
3649
|
+
if (tunnelInfo.localHost) {
|
|
3650
|
+
username += `:${tunnelInfo.localHost}`;
|
|
3651
|
+
}
|
|
3652
|
+
} else if (tunnelInfo.localHost) {
|
|
3653
|
+
username += `:${tunnelInfo.localHost}`;
|
|
3654
|
+
}
|
|
3655
|
+
const credentials = {
|
|
3656
|
+
username,
|
|
3657
|
+
publicKeys: [keyPair]
|
|
3658
|
+
};
|
|
3659
|
+
const clientAuthenticated = await session.authenticateClient(credentials);
|
|
3660
|
+
if (!clientAuthenticated) {
|
|
3661
|
+
throw new Error(
|
|
3662
|
+
"SSH key rejected by server. Run 'periscope user key generate' to re-register your key."
|
|
3663
|
+
);
|
|
3664
|
+
}
|
|
3665
|
+
this.activeTunnels.set(name, session);
|
|
3666
|
+
this.sshClients.set(name, sshClient);
|
|
3667
|
+
setupRequestMonitor(session, name);
|
|
3668
|
+
session.onClosed((e) => {
|
|
3669
|
+
tunnelInfo.isConnected = false;
|
|
3670
|
+
if (this.intentionalDisconnects.has(name)) {
|
|
3671
|
+
this.intentionalDisconnects.delete(name);
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
if (e.reason === SshDisconnectReason.byApplication && e.message) {
|
|
3675
|
+
log.error(`Server rejected tunnel '${name}': ${e.message}`);
|
|
3676
|
+
this.intentionalDisconnects.add(name);
|
|
3677
|
+
this.disconnect(name).then(() => {
|
|
3678
|
+
exitOrThrow(1);
|
|
3679
|
+
}).catch(() => {
|
|
3680
|
+
exitOrThrow(1);
|
|
3681
|
+
});
|
|
3682
|
+
return;
|
|
3683
|
+
}
|
|
3684
|
+
if (e.reason !== SshDisconnectReason.none) {
|
|
3685
|
+
log.warn(
|
|
3686
|
+
`Tunnel '${name}' disconnected unexpectedly: ${e.message || e.reason}`
|
|
3687
|
+
);
|
|
3688
|
+
}
|
|
3689
|
+
this.handleServerDisconnect(name);
|
|
3690
|
+
});
|
|
3691
|
+
session.onDisconnected(() => {
|
|
3692
|
+
if (!tunnelInfo.isConnected) {
|
|
3693
|
+
return;
|
|
3694
|
+
}
|
|
3695
|
+
tunnelInfo.isConnected = false;
|
|
3696
|
+
log.warn(`Tunnel '${name}' transport disconnected`);
|
|
3697
|
+
this.handleServerDisconnect(name);
|
|
3698
|
+
});
|
|
3699
|
+
tunnelInfo.isConnected = true;
|
|
3700
|
+
if (isInteractiveMode()) {
|
|
3701
|
+
displayTunnelInfo(tunnelInfo, this.clientConfig.serverUrl, {
|
|
3702
|
+
isInteractive: true,
|
|
3703
|
+
tunnelName: name,
|
|
3704
|
+
showBackgroundStatus: true
|
|
3705
|
+
});
|
|
3706
|
+
} else {
|
|
3707
|
+
displayTunnelInfo(tunnelInfo, this.clientConfig.serverUrl);
|
|
3708
|
+
}
|
|
3709
|
+
return;
|
|
3710
|
+
}
|
|
3711
|
+
/**
|
|
3712
|
+
* Connect to establish an ephemeral SSH tunnel
|
|
3713
|
+
* @param name - Unique name for this tunnel
|
|
3714
|
+
* @param localPort - Local port to forward through the tunnel
|
|
3715
|
+
* @param localHost - Optional Host header override for local service
|
|
3716
|
+
* @param localScheme - Optional scheme for local service ("http" or "https")
|
|
3717
|
+
* @param sshKeyPath - Optional path to SSH private key file
|
|
3718
|
+
*/
|
|
3719
|
+
async connect(name, localPort, localHost, localScheme, sshKeyPath) {
|
|
3720
|
+
try {
|
|
3721
|
+
if (!name) {
|
|
3722
|
+
throw new Error("Tunnel name is required");
|
|
3723
|
+
}
|
|
3724
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
3725
|
+
throw new Error(
|
|
3726
|
+
"Tunnel name must contain only alphanumeric characters, hyphens, and underscores"
|
|
3727
|
+
);
|
|
3728
|
+
}
|
|
3729
|
+
if (!localPort || localPort <= 0 || localPort > 65535) {
|
|
3730
|
+
throw new Error("Valid local port is required (1-65535)");
|
|
3731
|
+
}
|
|
3732
|
+
if (localHost !== void 0 && !/^[a-zA-Z0-9.\-:[\]]+$/.test(localHost)) {
|
|
3733
|
+
throw new Error(
|
|
3734
|
+
"localHost contains invalid characters (allowed: alphanumeric, hyphens, dots, colons, square brackets)"
|
|
3735
|
+
);
|
|
3736
|
+
}
|
|
3737
|
+
if (localScheme !== void 0 && localScheme !== "http" && localScheme !== "https") {
|
|
3738
|
+
throw new Error('localScheme must be "http" or "https"');
|
|
3739
|
+
}
|
|
3740
|
+
if (!this.sshKeyPair) {
|
|
3741
|
+
this.sshKeyPair = await SshKeyManager.loadKeyPair(
|
|
3742
|
+
sshKeyPath ?? this.clientConfig.sshKeyPath
|
|
3743
|
+
);
|
|
3744
|
+
}
|
|
3745
|
+
const tunnelInfo = {
|
|
3746
|
+
name,
|
|
3747
|
+
clientPort: localPort,
|
|
3748
|
+
sshTunnelPort: DEFAULT_SSH_TUNNEL_PORT,
|
|
3749
|
+
isConnected: false,
|
|
3750
|
+
localHost,
|
|
3751
|
+
localScheme
|
|
3752
|
+
};
|
|
3753
|
+
this.tunnelInfoMap.set(name, tunnelInfo);
|
|
3754
|
+
log.info(
|
|
3755
|
+
`Establishing tunnel '${name}' for local port ${localPort} on ${this.clientConfig.serverUrl}`
|
|
3756
|
+
);
|
|
3757
|
+
const isServiceRunning = await this.isPortListening(localPort);
|
|
3758
|
+
if (!isServiceRunning) {
|
|
3759
|
+
log.warn(`Local service not detected on port ${localPort}`);
|
|
3760
|
+
log.warn(
|
|
3761
|
+
"Waiting for service - tunnel will connect when service becomes available"
|
|
3762
|
+
);
|
|
3763
|
+
this.startPortMonitoring(tunnelInfo);
|
|
3764
|
+
return tunnelInfo;
|
|
3765
|
+
}
|
|
3766
|
+
log.debug(
|
|
3767
|
+
`Local service detected on port ${localPort} - establishing tunnel connection`
|
|
3768
|
+
);
|
|
3769
|
+
await this.createSSHConnection(tunnelInfo);
|
|
3770
|
+
this.startPortMonitoring(tunnelInfo);
|
|
3771
|
+
return tunnelInfo;
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
this.tunnelInfoMap.delete(name);
|
|
3774
|
+
if (error instanceof SshKeyNotFoundError) {
|
|
3775
|
+
throw error;
|
|
3776
|
+
}
|
|
3777
|
+
throw new Error(
|
|
3778
|
+
`Failed to establish tunnel: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
3779
|
+
);
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
/**
|
|
3783
|
+
* Get list of active tunnels managed by this CLI session
|
|
3784
|
+
*/
|
|
3785
|
+
getActiveTunnels() {
|
|
3786
|
+
return Array.from(this.tunnelInfoMap.values());
|
|
3787
|
+
}
|
|
3788
|
+
/**
|
|
3789
|
+
* Disconnect the SSH connection for a tunnel
|
|
3790
|
+
*/
|
|
3791
|
+
async disconnect(name) {
|
|
3792
|
+
this.reconnecting.delete(name);
|
|
3793
|
+
const reconnectTimeout = this.reconnectTimeouts.get(name);
|
|
3794
|
+
if (reconnectTimeout) {
|
|
3795
|
+
clearTimeout(reconnectTimeout);
|
|
3796
|
+
this.reconnectTimeouts.delete(name);
|
|
3797
|
+
}
|
|
3798
|
+
const interval = this.retryIntervals.get(name);
|
|
3799
|
+
if (interval) {
|
|
3800
|
+
clearInterval(interval);
|
|
3801
|
+
this.retryIntervals.delete(name);
|
|
3802
|
+
}
|
|
3803
|
+
const session = this.activeTunnels.get(name);
|
|
3804
|
+
if (session) {
|
|
3805
|
+
this.intentionalDisconnects.add(name);
|
|
3806
|
+
await session.close(
|
|
3807
|
+
SshDisconnectReason.byApplication,
|
|
3808
|
+
"Tunnel stopped by user"
|
|
3809
|
+
);
|
|
3810
|
+
this.activeTunnels.delete(name);
|
|
3811
|
+
}
|
|
3812
|
+
const sshClient = this.sshClients.get(name);
|
|
3813
|
+
if (sshClient) {
|
|
3814
|
+
sshClient.dispose();
|
|
3815
|
+
this.sshClients.delete(name);
|
|
3816
|
+
}
|
|
3817
|
+
this.tunnelInfoMap.delete(name);
|
|
3818
|
+
log.info(`Disconnected tunnel '${name}'`);
|
|
3819
|
+
}
|
|
3820
|
+
async stopAll() {
|
|
3821
|
+
for (const [, timeout] of this.reconnectTimeouts) {
|
|
3822
|
+
clearTimeout(timeout);
|
|
3823
|
+
}
|
|
3824
|
+
this.reconnectTimeouts.clear();
|
|
3825
|
+
this.reconnecting.clear();
|
|
3826
|
+
for (const [, interval] of this.retryIntervals) {
|
|
3827
|
+
clearInterval(interval);
|
|
3828
|
+
}
|
|
3829
|
+
this.retryIntervals.clear();
|
|
3830
|
+
for (const [name, session] of this.activeTunnels) {
|
|
3831
|
+
this.intentionalDisconnects.add(name);
|
|
3832
|
+
await session.close(
|
|
3833
|
+
SshDisconnectReason.byApplication,
|
|
3834
|
+
"All tunnels stopped"
|
|
3835
|
+
);
|
|
3836
|
+
}
|
|
3837
|
+
this.activeTunnels.clear();
|
|
3838
|
+
for (const [, sshClient] of this.sshClients) {
|
|
3839
|
+
sshClient.dispose();
|
|
3840
|
+
}
|
|
3841
|
+
this.sshClients.clear();
|
|
3842
|
+
this.sshKeyPair = null;
|
|
3843
|
+
this.tunnelInfoMap.clear();
|
|
3844
|
+
}
|
|
3845
|
+
/**
|
|
3846
|
+
* Cleanup and unregister this tunnel manager
|
|
3847
|
+
*/
|
|
3848
|
+
async dispose() {
|
|
3849
|
+
await this.stopAll();
|
|
3850
|
+
unregisterTunnelManager(this);
|
|
3851
|
+
}
|
|
3852
|
+
};
|
|
3853
|
+
|
|
3854
|
+
// src/commands/tunnel.ts
|
|
3855
|
+
import * as fs9 from "node:fs";
|
|
3856
|
+
import * as os5 from "node:os";
|
|
3857
|
+
init_telemetry();
|
|
3858
|
+
init_readline_instance();
|
|
3859
|
+
|
|
3860
|
+
// src/commands/base-command.ts
|
|
3861
|
+
init_readline_instance();
|
|
3862
|
+
|
|
3863
|
+
// src/lib/terms.ts
|
|
3864
|
+
var TERMS_VERSION = "2026-02-14";
|
|
3865
|
+
var TERMS_TEXT = `
|
|
3866
|
+
PERISCOPE TERMS OF SERVICE
|
|
3867
|
+
Version: ${TERMS_VERSION}
|
|
3868
|
+
|
|
3869
|
+
Copyright (c) 2024-2026 Elf 5. All rights reserved.
|
|
3870
|
+
|
|
3871
|
+
By using Periscope ("the Service"), you agree to the following terms:
|
|
3872
|
+
|
|
3873
|
+
1. ACCEPTANCE
|
|
3874
|
+
By accessing or using Periscope, you agree to be bound by these Terms
|
|
3875
|
+
of Service. If you do not agree, you may not use the Service.
|
|
3876
|
+
|
|
3877
|
+
2. BETA PROGRAM
|
|
3878
|
+
IMPORTANT: Periscope is currently in beta. By participating, you acknowledge:
|
|
3879
|
+
- Beta products may contain bugs, errors, or incomplete features
|
|
3880
|
+
- We may collect feedback and usage data to improve our products
|
|
3881
|
+
- Beta access is provided "as is" without warranties of any kind
|
|
3882
|
+
- We reserve the right to modify or terminate the beta at any time without notice
|
|
3883
|
+
- The service may experience downtime, interruptions, or changes during the beta period
|
|
3884
|
+
|
|
3885
|
+
3. SERVICE DESCRIPTION
|
|
3886
|
+
Periscope provides secure SSH tunnel services that allow you to expose
|
|
3887
|
+
local development services through publicly accessible URLs.
|
|
3888
|
+
|
|
3889
|
+
4. ACCOUNT SECURITY
|
|
3890
|
+
Periscope uses email-based passcode authentication. You are responsible for:
|
|
3891
|
+
- Maintaining the security of the email address associated with your account
|
|
3892
|
+
- Not sharing authentication passcodes sent to your email
|
|
3893
|
+
- Promptly notifying us if you receive unexpected passcodes or suspect unauthorized access
|
|
3894
|
+
- Ensuring your email address remains current and accessible
|
|
3895
|
+
- All activities that occur using passcodes sent to your email address
|
|
3896
|
+
|
|
3897
|
+
We are not responsible for unauthorized access resulting from compromise of your
|
|
3898
|
+
email account, sharing of passcodes, or use of an insecure or shared email address.
|
|
3899
|
+
|
|
3900
|
+
5. ACCEPTABLE USE
|
|
3901
|
+
You agree to use Periscope only for lawful purposes. You shall not:
|
|
3902
|
+
- Use the Service to transmit harmful, illegal, or offensive content
|
|
3903
|
+
- Violate any applicable laws or regulations
|
|
3904
|
+
- Infringe upon the rights of others
|
|
3905
|
+
- Distribute malicious software or engage in harmful activities
|
|
3906
|
+
- Attempt to gain unauthorized access to other systems through tunnels
|
|
3907
|
+
- Use the Service to circumvent network security policies
|
|
3908
|
+
- Share your credentials or tunnel access with unauthorized parties
|
|
3909
|
+
- Interfere with the proper functioning of our services
|
|
3910
|
+
|
|
3911
|
+
6. DATA AND PRIVACY
|
|
3912
|
+
- Tunnel traffic passes through Periscope infrastructure
|
|
3913
|
+
- We collect minimal usage data (tunnel names, connection times, user identity)
|
|
3914
|
+
- We do not inspect or store the content of your tunnel traffic
|
|
3915
|
+
- Your authentication data is managed by your identity provider
|
|
3916
|
+
- Your privacy is important to us. Our collection and use of personal information
|
|
3917
|
+
is governed by our Privacy Policy
|
|
3918
|
+
|
|
3919
|
+
Data Access and Disclosure:
|
|
3920
|
+
We reserve the right to access, preserve, and disclose your account information
|
|
3921
|
+
and data if required by law or if we believe such action is necessary to:
|
|
3922
|
+
(a) comply with legal process or government requests; (b) enforce these Terms;
|
|
3923
|
+
(c) respond to claims of violation of third-party rights; or (d) protect the
|
|
3924
|
+
rights, property, or safety of Elf 5, our users, or the public.
|
|
3925
|
+
|
|
3926
|
+
7. DISCLAIMERS AND LIMITATIONS
|
|
3927
|
+
Disclaimer of Warranties:
|
|
3928
|
+
Our services are provided "as is" and "as available" without warranties of any kind,
|
|
3929
|
+
either express or implied, including but not limited to warranties of merchantability,
|
|
3930
|
+
fitness for a particular purpose, or non-infringement.
|
|
3931
|
+
|
|
3932
|
+
Limitation of Liability:
|
|
3933
|
+
To the maximum extent permitted by law, Elf 5 shall not be liable for any indirect,
|
|
3934
|
+
incidental, special, consequential, or punitive damages, or any loss of profits or
|
|
3935
|
+
revenues, whether arising from:
|
|
3936
|
+
- Your use or inability to use our services
|
|
3937
|
+
- Services, applications, or data you expose through Periscope tunnels
|
|
3938
|
+
- Unauthorized access to your tunnels or services due to your configuration choices
|
|
3939
|
+
- Security vulnerabilities in services you make accessible through our platform
|
|
3940
|
+
- Data breaches or exposure of sensitive information through tunnels you create
|
|
3941
|
+
- Any actions taken by third parties who access services through your tunnels
|
|
3942
|
+
|
|
3943
|
+
User Responsibility:
|
|
3944
|
+
You are solely responsible for the security, configuration, and content of any
|
|
3945
|
+
services you expose through Periscope. You acknowledge that creating publicly
|
|
3946
|
+
accessible tunnels to local services carries inherent security risks, and you
|
|
3947
|
+
assume all such risks.
|
|
3948
|
+
|
|
3949
|
+
Liability Cap:
|
|
3950
|
+
In no event shall Elf 5's total aggregate liability exceed the greater of:
|
|
3951
|
+
(i) $100 USD or (ii) the total fees paid by you to Elf 5 in the twelve (12)
|
|
3952
|
+
months immediately preceding the claim.
|
|
3953
|
+
|
|
3954
|
+
8. TERMINATION
|
|
3955
|
+
We may terminate or suspend your access to the Service at any time, with or
|
|
3956
|
+
without cause or notice. Upon termination, your right to use our services will
|
|
3957
|
+
cease immediately.
|
|
3958
|
+
|
|
3959
|
+
9. CHANGES TO TERMS
|
|
3960
|
+
We reserve the right to modify these Terms at any time. We will notify you of
|
|
3961
|
+
any changes by updating the "Version" date. Your continued use of our services
|
|
3962
|
+
after such modifications constitutes acceptance of the updated Terms.
|
|
3963
|
+
|
|
3964
|
+
For questions, contact: hello@elf5.com
|
|
3965
|
+
`;
|
|
3966
|
+
|
|
3967
|
+
// src/commands/base-command.ts
|
|
3968
|
+
init_telemetry();
|
|
3969
|
+
var BaseCommand = class {
|
|
3970
|
+
/**
|
|
3971
|
+
* Common error handler for all commands
|
|
3972
|
+
* Uses ErrorClassifier for structured error handling based on HTTP status codes and error patterns
|
|
3973
|
+
* @param action - Action constant from the command's Actions object (e.g., TunnelCommand.Action.CREATE)
|
|
3974
|
+
* @param error - The error to handle
|
|
3975
|
+
*/
|
|
3976
|
+
static handleError(action, error) {
|
|
3977
|
+
log.debug(`handleError(${action}):`, error);
|
|
3978
|
+
const classified = ErrorClassifier.classify(error, action);
|
|
3979
|
+
log.debug(`Classified as: ${classified.type}`);
|
|
3980
|
+
trackException(error, {
|
|
3981
|
+
action,
|
|
3982
|
+
errorType: classified.type,
|
|
3983
|
+
userMessage: classified.userMessage
|
|
3984
|
+
});
|
|
3985
|
+
log.error(classified.userMessage);
|
|
3986
|
+
if (classified.suggestedActions.length > 0) {
|
|
3987
|
+
log.blank();
|
|
3988
|
+
log.info("Troubleshooting:");
|
|
3989
|
+
classified.suggestedActions.forEach((tip) => log.info(` \u2022 ${tip}`));
|
|
3990
|
+
}
|
|
3991
|
+
exitOrThrow(1);
|
|
3992
|
+
}
|
|
3993
|
+
/**
|
|
3994
|
+
* Display a warning that the account is pending approval.
|
|
3995
|
+
*/
|
|
3996
|
+
static warnPendingApproval() {
|
|
3997
|
+
log.warn("Your account is pending approval by an administrator.");
|
|
3998
|
+
log.warn("You will be notified when your account has been approved.");
|
|
3999
|
+
log.warn("Most commands will be unavailable until your account is active.");
|
|
4000
|
+
}
|
|
4001
|
+
/**
|
|
4002
|
+
* Core authentication logic.
|
|
4003
|
+
* Device code auth is hidden behind PERISCOPE_ENABLE_DEVICE_CODE=true for internal use.
|
|
4004
|
+
* Returns the account status string if available.
|
|
4005
|
+
*/
|
|
4006
|
+
static async authenticateWithChoice(client3, prompt = "select_account") {
|
|
4007
|
+
log.info("Authentication required...");
|
|
4008
|
+
const deviceCodeEnabled = process.env.PERISCOPE_ENABLE_DEVICE_CODE === "true";
|
|
4009
|
+
let useBrowser = true;
|
|
4010
|
+
if (deviceCodeEnabled) {
|
|
4011
|
+
const rl = getReadlineInterface();
|
|
4012
|
+
useBrowser = await new Promise((resolve, reject) => {
|
|
4013
|
+
log.blank();
|
|
4014
|
+
log.info("Choose authentication method:");
|
|
4015
|
+
log.info("1. Browser authentication (recommended)");
|
|
4016
|
+
log.info("2. Device code authentication");
|
|
4017
|
+
try {
|
|
4018
|
+
rl.question(
|
|
4019
|
+
"Enter your choice (1 or 2) [default: 1]: ",
|
|
4020
|
+
(answer) => resolve((answer.trim() || "1") === "1")
|
|
4021
|
+
);
|
|
4022
|
+
} catch (error) {
|
|
4023
|
+
reject(error);
|
|
4024
|
+
}
|
|
4025
|
+
});
|
|
4026
|
+
}
|
|
4027
|
+
log.info(
|
|
4028
|
+
useBrowser ? `Starting browser authentication (${prompt})...` : "Starting device code authentication..."
|
|
4029
|
+
);
|
|
4030
|
+
const authResult = useBrowser ? await client3.authenticateInteractive(prompt) : await client3.authenticate();
|
|
4031
|
+
log.success("Authentication successful!");
|
|
4032
|
+
log.success("Successfully authenticated");
|
|
4033
|
+
log.info(` Token expires: ${authResult.expiresOn.toLocaleString()}`);
|
|
4034
|
+
log.info(
|
|
4035
|
+
useBrowser ? ` Authentication method: Browser (${prompt})` : " Authentication method: Device Code"
|
|
4036
|
+
);
|
|
4037
|
+
const method = useBrowser ? "browser" : "device_code";
|
|
4038
|
+
try {
|
|
4039
|
+
const status = await client3.getUserStatus();
|
|
4040
|
+
if (status === AccountStatus.PENDING_APPROVAL) {
|
|
4041
|
+
log.blank();
|
|
4042
|
+
this.warnPendingApproval();
|
|
4043
|
+
}
|
|
4044
|
+
return { status, method };
|
|
4045
|
+
} catch {
|
|
4046
|
+
log.debug("Could not determine account status after authentication");
|
|
4047
|
+
return { status: null, method };
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
/**
|
|
4051
|
+
* Load config, validate server URL, init telemetry, and create client.
|
|
4052
|
+
* Shared entry point for all command paths that need a PeriscopeClient.
|
|
4053
|
+
* Accepts an optional pre-loaded config to avoid double-loading.
|
|
4054
|
+
*/
|
|
4055
|
+
static async initClient(existingConfig) {
|
|
4056
|
+
const config2 = existingConfig ?? ConfigManager.load();
|
|
4057
|
+
if (!config2.serverUrl) {
|
|
4058
|
+
log.error(
|
|
4059
|
+
"Server URL not configured. Run: periscope config set --server <url>"
|
|
4060
|
+
);
|
|
4061
|
+
exitOrThrow(1);
|
|
4062
|
+
}
|
|
4063
|
+
return { client: new PeriscopeClient(config2), config: config2 };
|
|
4064
|
+
}
|
|
4065
|
+
/**
|
|
4066
|
+
* Check client version compatibility with the server.
|
|
4067
|
+
* Blocks on incompatible, warns on outdated, silent on compatible/null.
|
|
4068
|
+
*/
|
|
4069
|
+
static async checkVersionCompatibility(serverUrl) {
|
|
4070
|
+
try {
|
|
4071
|
+
const packageJson = await Promise.resolve().then(() => (init_package(), package_exports));
|
|
4072
|
+
const clientVersion = packageJson.default.version;
|
|
4073
|
+
const response = await fetch(
|
|
4074
|
+
`${serverUrl}/api/configuration?clientVersion=${encodeURIComponent(clientVersion)}`
|
|
4075
|
+
);
|
|
4076
|
+
if (!response.ok) {
|
|
4077
|
+
return;
|
|
4078
|
+
}
|
|
4079
|
+
const config2 = await response.json();
|
|
4080
|
+
if (config2.compatibilityStatus === "incompatible") {
|
|
4081
|
+
log.blank();
|
|
4082
|
+
log.error("CLIENT VERSION INCOMPATIBLE");
|
|
4083
|
+
log.blank();
|
|
4084
|
+
log.error(
|
|
4085
|
+
config2.compatibilityMessage || `Your client version (${clientVersion}) is incompatible with this server.`
|
|
4086
|
+
);
|
|
4087
|
+
log.error(`Minimum required version: ${config2.minimumVersion}`);
|
|
4088
|
+
log.error(`Latest version: ${config2.latestVersion}`);
|
|
4089
|
+
log.blank();
|
|
4090
|
+
log.error("Please upgrade:");
|
|
4091
|
+
log.error(" npm update -g @elf5/periscope");
|
|
4092
|
+
exitOrThrow(1);
|
|
4093
|
+
} else if (config2.compatibilityStatus === "outdated") {
|
|
4094
|
+
log.blank();
|
|
4095
|
+
log.warn(
|
|
4096
|
+
config2.compatibilityMessage || `Your client version (${clientVersion}) is outdated.`
|
|
4097
|
+
);
|
|
4098
|
+
log.warn(`Recommended version: ${config2.recommendedVersion}`);
|
|
4099
|
+
log.warn(`Latest version: ${config2.latestVersion}`);
|
|
4100
|
+
log.warn("Consider upgrading: npm update -g @elf5/periscope");
|
|
4101
|
+
log.blank();
|
|
4102
|
+
}
|
|
4103
|
+
} catch (error) {
|
|
4104
|
+
log.debug("Version compatibility check failed:", error);
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
/**
|
|
4108
|
+
* Common setup for all commands - handles config loading and authentication
|
|
4109
|
+
*/
|
|
4110
|
+
static async setupClient(actionDescription) {
|
|
4111
|
+
const { client: client3, config: config2 } = await this.initClient();
|
|
4112
|
+
await this.checkVersionCompatibility(config2.serverUrl);
|
|
4113
|
+
let status = null;
|
|
4114
|
+
let authMethod = null;
|
|
4115
|
+
if (!await client3.isAuthenticated()) {
|
|
4116
|
+
({ status, method: authMethod } = await this.authenticateWithChoice(client3));
|
|
4117
|
+
if (actionDescription) {
|
|
4118
|
+
log.info(actionDescription);
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
let cachedUser = null;
|
|
4122
|
+
if (status === null) {
|
|
4123
|
+
cachedUser = await client3.getCurrentUser();
|
|
4124
|
+
if (!cachedUser.status) {
|
|
4125
|
+
throw new Error("Server did not return account status");
|
|
4126
|
+
}
|
|
4127
|
+
status = cachedUser.status;
|
|
4128
|
+
} else {
|
|
4129
|
+
try {
|
|
4130
|
+
cachedUser = await client3.getCurrentUser();
|
|
4131
|
+
} catch {
|
|
4132
|
+
}
|
|
4133
|
+
}
|
|
4134
|
+
try {
|
|
4135
|
+
const cs = await client3.getTelemetryConnectionString();
|
|
4136
|
+
if (cs) await initTelemetry(cs);
|
|
4137
|
+
setUserContext(cachedUser?.email);
|
|
4138
|
+
if (authMethod) trackEvent("auth_login", { method: authMethod });
|
|
4139
|
+
} catch {
|
|
4140
|
+
}
|
|
4141
|
+
if (status === AccountStatus.PENDING_APPROVAL) {
|
|
4142
|
+
log.blank();
|
|
4143
|
+
this.warnPendingApproval();
|
|
4144
|
+
log.blank();
|
|
4145
|
+
log.info("Available commands while pending:");
|
|
4146
|
+
log.info(
|
|
4147
|
+
" periscope status Show server, auth, and SSH key status"
|
|
4148
|
+
);
|
|
4149
|
+
log.info(" periscope auth logout Sign out");
|
|
4150
|
+
exitOrThrow(1);
|
|
4151
|
+
}
|
|
4152
|
+
await this.ensureTermsAccepted(client3);
|
|
4153
|
+
return { client: client3, config: config2 };
|
|
4154
|
+
}
|
|
4155
|
+
/**
|
|
4156
|
+
* Check if user has accepted the current Terms of Service.
|
|
4157
|
+
* If not, display embedded TOS and prompt for acceptance.
|
|
4158
|
+
*/
|
|
4159
|
+
static async ensureTermsAccepted(client3) {
|
|
4160
|
+
try {
|
|
4161
|
+
const termsStatus = await client3.getTermsStatus();
|
|
4162
|
+
if (termsStatus.accepted) {
|
|
4163
|
+
return;
|
|
4164
|
+
}
|
|
4165
|
+
log.blank();
|
|
4166
|
+
log.info("Welcome to Periscope!");
|
|
4167
|
+
log.blank();
|
|
4168
|
+
log.info(
|
|
4169
|
+
"Before you get started, please review and accept our Terms of Service."
|
|
4170
|
+
);
|
|
4171
|
+
log.blank();
|
|
4172
|
+
console.log(TERMS_TEXT);
|
|
4173
|
+
log.blank();
|
|
4174
|
+
const accepted = await this.promptTermsAcceptance();
|
|
4175
|
+
if (!accepted) {
|
|
4176
|
+
log.error("You must accept the Terms of Service to use Periscope.");
|
|
4177
|
+
exitOrThrow(1);
|
|
4178
|
+
}
|
|
4179
|
+
await client3.acceptTerms(TERMS_VERSION);
|
|
4180
|
+
log.success("Terms of Service accepted.");
|
|
4181
|
+
log.blank();
|
|
4182
|
+
} catch (error) {
|
|
4183
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
4184
|
+
if (errorMessage.includes("404") || errorMessage.includes("Auth manager not initialized")) {
|
|
4185
|
+
return;
|
|
4186
|
+
}
|
|
4187
|
+
throw error;
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
/**
|
|
4191
|
+
* Prompt user to accept or decline Terms of Service.
|
|
4192
|
+
*/
|
|
4193
|
+
static async promptTermsAcceptance() {
|
|
4194
|
+
const rl = getReadlineInterface();
|
|
4195
|
+
return new Promise((resolve) => {
|
|
4196
|
+
rl.question("Do you accept the Terms of Service? [y/N] ", (answer) => {
|
|
4197
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
4198
|
+
});
|
|
4199
|
+
});
|
|
4200
|
+
}
|
|
4201
|
+
};
|
|
4202
|
+
|
|
4203
|
+
// src/commands/tunnel.ts
|
|
4204
|
+
var SshKeySetupError = class extends Error {
|
|
4205
|
+
constructor(cause) {
|
|
4206
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
4207
|
+
super(`SSH key setup failed: ${msg}`);
|
|
4208
|
+
this.cause = cause;
|
|
4209
|
+
this.name = "SshKeySetupError";
|
|
4210
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
4211
|
+
}
|
|
4212
|
+
};
|
|
4213
|
+
var TunnelCommand = class _TunnelCommand extends BaseCommand {
|
|
4214
|
+
/** Action constants for type-safe error handling */
|
|
4215
|
+
static Action = {
|
|
4216
|
+
CONNECT: "connect to tunnel",
|
|
4217
|
+
KEY_SETUP: "set up SSH key"
|
|
4218
|
+
};
|
|
4219
|
+
/**
|
|
4220
|
+
* Wizard invoked when no local SSH key is found.
|
|
4221
|
+
* By default, auto-generates a new key pair without prompting. When
|
|
4222
|
+
* allowExternalKey is true (future enterprise feature), presents a menu
|
|
4223
|
+
* that also allows providing a path to an existing key.
|
|
4224
|
+
*
|
|
4225
|
+
* Returns the resolved key path to use for the retry, or null if the user
|
|
4226
|
+
* cancels. Throws SshKeySetupError if key generation or registration fails.
|
|
4227
|
+
*/
|
|
4228
|
+
static async handleMissingKeyWizard(client3, customKeyPath) {
|
|
4229
|
+
if (!process.stdin.isTTY) {
|
|
4230
|
+
log.error(
|
|
4231
|
+
"No SSH key found. Run `periscope user key generate` to generate and register one, or provide an existing key path with --key."
|
|
4232
|
+
);
|
|
4233
|
+
return null;
|
|
4234
|
+
}
|
|
4235
|
+
const defaultPath = SshKeyManager.getDefaultKeyPath();
|
|
4236
|
+
const config2 = ConfigManager.load();
|
|
4237
|
+
if (config2.allowExternalKey) {
|
|
4238
|
+
return this.handleMissingKeyMenu(client3, customKeyPath, defaultPath);
|
|
4239
|
+
}
|
|
4240
|
+
const targetPath = customKeyPath ?? defaultPath;
|
|
4241
|
+
log.blank();
|
|
4242
|
+
log.info(`No SSH key found. Generating new key at ${targetPath}...`);
|
|
4243
|
+
try {
|
|
4244
|
+
const keyPair = await SshKeyManager.generateKeyPair(targetPath);
|
|
4245
|
+
const publicKey = await SshKeyManager.exportPublicKey(keyPair);
|
|
4246
|
+
await client3.registerPublicKey(publicKey);
|
|
4247
|
+
} catch (err) {
|
|
4248
|
+
throw new SshKeySetupError(err);
|
|
4249
|
+
}
|
|
4250
|
+
log.success("SSH key generated and registered successfully.");
|
|
4251
|
+
if (targetPath !== defaultPath) {
|
|
4252
|
+
config2.sshKeyPath = targetPath;
|
|
4253
|
+
ConfigManager.save(config2);
|
|
4254
|
+
log.info(`Key path saved to configuration.`);
|
|
4255
|
+
}
|
|
4256
|
+
return targetPath;
|
|
4257
|
+
}
|
|
4258
|
+
/**
|
|
4259
|
+
* Full menu wizard for missing SSH key. Only shown when allowExternalKey
|
|
4260
|
+
* is enabled in configuration. Allows generating a new key or providing
|
|
4261
|
+
* a path to an existing one.
|
|
4262
|
+
*/
|
|
4263
|
+
static async handleMissingKeyMenu(client3, customKeyPath, defaultPath) {
|
|
4264
|
+
log.blank();
|
|
4265
|
+
log.warn("No SSH key found for tunnel authentication.");
|
|
4266
|
+
log.blank();
|
|
4267
|
+
log.info("Options:");
|
|
4268
|
+
log.info(
|
|
4269
|
+
` 1. Generate a new SSH key at ${customKeyPath ?? defaultPath} (recommended)`
|
|
4270
|
+
);
|
|
4271
|
+
log.info(" 2. Use an existing SSH key from a different path");
|
|
4272
|
+
log.info(" 3. Cancel");
|
|
4273
|
+
const rl = getReadlineInterface();
|
|
4274
|
+
const choice = await new Promise((resolve) => {
|
|
4275
|
+
rl.question("Enter your choice [default: 1]: ", (answer) => {
|
|
4276
|
+
resolve(answer.trim() || "1");
|
|
4277
|
+
});
|
|
4278
|
+
});
|
|
4279
|
+
if (choice === "1") {
|
|
4280
|
+
const targetPath = customKeyPath ?? defaultPath;
|
|
4281
|
+
log.info(`Generating SSH key at ${targetPath}...`);
|
|
4282
|
+
try {
|
|
4283
|
+
const keyPair = await SshKeyManager.generateKeyPair(targetPath);
|
|
4284
|
+
const publicKey = await SshKeyManager.exportPublicKey(keyPair);
|
|
4285
|
+
await client3.registerPublicKey(publicKey);
|
|
4286
|
+
} catch (err) {
|
|
4287
|
+
throw new SshKeySetupError(err);
|
|
4288
|
+
}
|
|
4289
|
+
log.success("SSH key generated and registered successfully.");
|
|
4290
|
+
if (targetPath !== defaultPath) {
|
|
4291
|
+
const config2 = ConfigManager.load();
|
|
4292
|
+
config2.sshKeyPath = targetPath;
|
|
4293
|
+
ConfigManager.save(config2);
|
|
4294
|
+
log.info(`Key path saved to configuration.`);
|
|
4295
|
+
}
|
|
4296
|
+
return targetPath;
|
|
4297
|
+
} else if (choice === "2") {
|
|
4298
|
+
const existingPath = await new Promise((resolve) => {
|
|
4299
|
+
rl.question("Enter the full path to your SSH private key: ", (answer) => {
|
|
4300
|
+
resolve(answer.trim());
|
|
4301
|
+
});
|
|
4302
|
+
});
|
|
4303
|
+
if (!existingPath) {
|
|
4304
|
+
log.error("No path provided.");
|
|
4305
|
+
return null;
|
|
4306
|
+
}
|
|
4307
|
+
const expandedPath = existingPath.replace(/^~(?=\/|$)/, os5.homedir());
|
|
4308
|
+
if (!fs9.existsSync(expandedPath)) {
|
|
4309
|
+
log.error(`No key file found at ${expandedPath}.`);
|
|
4310
|
+
return null;
|
|
4311
|
+
}
|
|
4312
|
+
log.info(`Registering public key from ${expandedPath}...`);
|
|
4313
|
+
try {
|
|
4314
|
+
const publicKey = await SshKeyManager.getPublicKeyString(expandedPath);
|
|
4315
|
+
await client3.registerPublicKey(publicKey);
|
|
4316
|
+
} catch (err) {
|
|
4317
|
+
throw new SshKeySetupError(err);
|
|
4318
|
+
}
|
|
4319
|
+
log.success("SSH key registered successfully.");
|
|
4320
|
+
const config2 = ConfigManager.load();
|
|
4321
|
+
config2.sshKeyPath = expandedPath;
|
|
4322
|
+
ConfigManager.save(config2);
|
|
4323
|
+
log.info(`Key path saved to configuration.`);
|
|
4324
|
+
return expandedPath;
|
|
4325
|
+
} else if (choice === "3") {
|
|
4326
|
+
log.info("Cancelled.");
|
|
4327
|
+
return null;
|
|
4328
|
+
} else {
|
|
4329
|
+
log.error(`Invalid choice: "${choice}". Please enter 1, 2, or 3.`);
|
|
4330
|
+
return null;
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
static parseTarget(target) {
|
|
4334
|
+
let url;
|
|
4335
|
+
try {
|
|
4336
|
+
url = new URL(target);
|
|
4337
|
+
} catch {
|
|
4338
|
+
throw new Error(
|
|
4339
|
+
`Invalid --target "${target}". Expected a URL like http://localhost:3000 or https://myapp.local:8443`
|
|
4340
|
+
);
|
|
4341
|
+
}
|
|
4342
|
+
if (url.pathname !== "/" || url.search !== "") {
|
|
4343
|
+
throw new Error(
|
|
4344
|
+
`--target must not include a path or query string (e.g., use "http://localhost:3000" not "${target}")`
|
|
4345
|
+
);
|
|
4346
|
+
}
|
|
4347
|
+
const scheme = url.protocol.slice(0, -1);
|
|
4348
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
4349
|
+
throw new Error(
|
|
4350
|
+
`Unsupported scheme "${scheme}" in --target. Only "http" and "https" are currently supported.`
|
|
4351
|
+
);
|
|
4352
|
+
}
|
|
4353
|
+
const hostname = url.hostname;
|
|
4354
|
+
const port = url.port ? parseInt(url.port, 10) : scheme === "https" ? 443 : 80;
|
|
4355
|
+
const localScheme = scheme === "https" ? "https" : void 0;
|
|
4356
|
+
const isLoopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
4357
|
+
let localHost;
|
|
4358
|
+
if (!isLoopback) {
|
|
4359
|
+
const isStandardPort = scheme === "http" && port === 80 || scheme === "https" && port === 443;
|
|
4360
|
+
localHost = isStandardPort ? hostname : `${hostname}:${port}`;
|
|
4361
|
+
}
|
|
4362
|
+
return { port, localHost, localScheme };
|
|
4363
|
+
}
|
|
4364
|
+
static async connect(name, target, sshKeyPath) {
|
|
4365
|
+
const { port, localHost, localScheme } = target ? this.parseTarget(target) : { port: 80, localHost: void 0, localScheme: void 0 };
|
|
4366
|
+
log.info(`Establishing tunnel '${name}' for local port ${port}...`);
|
|
4367
|
+
if (localHost) {
|
|
4368
|
+
log.info(`Host header override: ${localHost}`);
|
|
4369
|
+
}
|
|
4370
|
+
try {
|
|
4371
|
+
const { client: client3 } = await this.setupClient(
|
|
4372
|
+
`Establishing tunnel '${name}'...`
|
|
4373
|
+
);
|
|
4374
|
+
const tunnelManager = new TunnelManager(client3);
|
|
4375
|
+
let resolvedKeyPath = sshKeyPath;
|
|
4376
|
+
try {
|
|
4377
|
+
await tunnelManager.connect(
|
|
4378
|
+
name,
|
|
4379
|
+
port,
|
|
4380
|
+
localHost,
|
|
4381
|
+
localScheme,
|
|
4382
|
+
resolvedKeyPath
|
|
4383
|
+
);
|
|
4384
|
+
} catch (innerError) {
|
|
4385
|
+
if (innerError instanceof SshKeyNotFoundError) {
|
|
4386
|
+
const wizardResult = await this.handleMissingKeyWizard(
|
|
4387
|
+
client3,
|
|
4388
|
+
sshKeyPath
|
|
4389
|
+
);
|
|
4390
|
+
if (wizardResult === null) {
|
|
4391
|
+
closeReadline();
|
|
4392
|
+
exitOrThrow(1);
|
|
4393
|
+
}
|
|
4394
|
+
resolvedKeyPath = wizardResult;
|
|
4395
|
+
closeReadline();
|
|
4396
|
+
await tunnelManager.connect(
|
|
4397
|
+
name,
|
|
4398
|
+
port,
|
|
4399
|
+
localHost,
|
|
4400
|
+
localScheme,
|
|
4401
|
+
resolvedKeyPath
|
|
4402
|
+
);
|
|
4403
|
+
} else {
|
|
4404
|
+
throw innerError;
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
trackEvent("tunnel_connect", {
|
|
4408
|
+
tunnelName: name,
|
|
4409
|
+
localPort: port.toString()
|
|
4410
|
+
});
|
|
4411
|
+
const keepAlive = setInterval(() => {
|
|
4412
|
+
}, 1e3 * 60 * 60);
|
|
4413
|
+
process.once("exit", () => clearInterval(keepAlive));
|
|
4414
|
+
await new Promise(() => {
|
|
4415
|
+
});
|
|
4416
|
+
} catch (error) {
|
|
4417
|
+
if (error instanceof SshKeySetupError) {
|
|
4418
|
+
closeReadline();
|
|
4419
|
+
this.handleError(_TunnelCommand.Action.KEY_SETUP, error.cause);
|
|
4420
|
+
} else {
|
|
4421
|
+
this.handleError(_TunnelCommand.Action.CONNECT, error);
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
};
|
|
4426
|
+
|
|
4427
|
+
// src/commands/config.ts
|
|
4428
|
+
var ConfigCommand = class _ConfigCommand extends BaseCommand {
|
|
4429
|
+
/** Action constants for type-safe error handling */
|
|
4430
|
+
static Action = {
|
|
4431
|
+
SET: "update configuration",
|
|
4432
|
+
SHOW: "load configuration"
|
|
4433
|
+
};
|
|
4434
|
+
static async set(options) {
|
|
4435
|
+
try {
|
|
4436
|
+
const config2 = ConfigManager.load();
|
|
4437
|
+
let hasChanges = false;
|
|
4438
|
+
if (!options.server && options.requestLog === void 0) {
|
|
4439
|
+
log.warn("No configuration options provided.");
|
|
4440
|
+
log.header("Available configuration options:");
|
|
4441
|
+
log.info(" --server <url> Set the Periscope server URL");
|
|
4442
|
+
log.info(
|
|
4443
|
+
" --request-log <bool> Show live HTTP request log during tunnel sessions (true/false)"
|
|
4444
|
+
);
|
|
4445
|
+
log.header("Examples:");
|
|
4446
|
+
log.info(" periscope config set --server https://periscope.elf5.com");
|
|
4447
|
+
log.info(" periscope config set --request-log false");
|
|
4448
|
+
log.blank();
|
|
4449
|
+
log.info("To view current configuration, use: periscope config show");
|
|
4450
|
+
exitOrThrow(1);
|
|
4451
|
+
}
|
|
4452
|
+
if (options.server) {
|
|
4453
|
+
try {
|
|
4454
|
+
new URL(options.server);
|
|
4455
|
+
config2.serverUrl = options.server;
|
|
4456
|
+
log.success(`Server URL set to: ${options.server}`);
|
|
4457
|
+
hasChanges = true;
|
|
4458
|
+
} catch {
|
|
4459
|
+
log.error("Invalid server URL format");
|
|
4460
|
+
exitOrThrow(1);
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
if (options.requestLog !== void 0) {
|
|
4464
|
+
config2.showRequestLog = options.requestLog !== "false";
|
|
4465
|
+
log.success(`Request log set to: ${config2.showRequestLog}`);
|
|
4466
|
+
hasChanges = true;
|
|
4467
|
+
}
|
|
4468
|
+
if (hasChanges) {
|
|
4469
|
+
ConfigManager.save(config2);
|
|
4470
|
+
log.info("Configuration saved successfully");
|
|
4471
|
+
}
|
|
4472
|
+
} catch (error) {
|
|
4473
|
+
this.handleError(_ConfigCommand.Action.SET, error);
|
|
4474
|
+
}
|
|
4475
|
+
}
|
|
4476
|
+
static async show() {
|
|
4477
|
+
try {
|
|
4478
|
+
const config2 = ConfigManager.load();
|
|
4479
|
+
log.header("Periscope Configuration:");
|
|
4480
|
+
log.separator(50);
|
|
4481
|
+
_ConfigCommand.showConfigValue(
|
|
4482
|
+
"Server URL ",
|
|
4483
|
+
config2.serverUrl,
|
|
4484
|
+
"PERISCOPE_SERVER_URL"
|
|
4485
|
+
);
|
|
4486
|
+
_ConfigCommand.showConfigValue(
|
|
4487
|
+
"Request Log ",
|
|
4488
|
+
String(config2.showRequestLog ?? true),
|
|
4489
|
+
"PERISCOPE_SHOW_REQUEST_LOG"
|
|
4490
|
+
);
|
|
4491
|
+
log.info("Config file: " + ConfigManager.getConfigPath());
|
|
4492
|
+
log.separator(50);
|
|
4493
|
+
} catch (error) {
|
|
4494
|
+
this.handleError(_ConfigCommand.Action.SHOW, error);
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
static showConfigValue(label, value, envVar) {
|
|
4498
|
+
const envValue = process.env[envVar];
|
|
4499
|
+
const isFromEnv = envValue !== void 0 && envValue !== "";
|
|
4500
|
+
const displayValue = value || "Not configured";
|
|
4501
|
+
const source = isFromEnv ? " (from env)" : "";
|
|
4502
|
+
log.info(`${label}: ${displayValue}${source}`);
|
|
4503
|
+
}
|
|
4504
|
+
};
|
|
4505
|
+
|
|
4506
|
+
// src/commands/status.ts
|
|
4507
|
+
init_telemetry();
|
|
4508
|
+
var StatusCommand = class _StatusCommand extends BaseCommand {
|
|
4509
|
+
static Action = {
|
|
4510
|
+
CHECK: "check status"
|
|
4511
|
+
};
|
|
4512
|
+
static async check() {
|
|
4513
|
+
try {
|
|
4514
|
+
const config2 = ConfigManager.load();
|
|
4515
|
+
log.header("Periscope Status:");
|
|
4516
|
+
log.separator();
|
|
4517
|
+
if (!config2.serverUrl) {
|
|
4518
|
+
log.info("Server URL: Not configured");
|
|
4519
|
+
log.info("Run: periscope config set --server <server-url>");
|
|
4520
|
+
log.separator();
|
|
4521
|
+
return;
|
|
4522
|
+
}
|
|
4523
|
+
log.info("Server URL: " + config2.serverUrl);
|
|
4524
|
+
const { client: client3 } = await this.initClient(config2);
|
|
4525
|
+
try {
|
|
4526
|
+
const health = await client3.checkHealth();
|
|
4527
|
+
log.info(
|
|
4528
|
+
"Server: " + (health.healthy ? "\u25CF Healthy" : "\u25CF Unhealthy")
|
|
4529
|
+
);
|
|
4530
|
+
if (health.version) {
|
|
4531
|
+
log.info("Server Version: " + health.version);
|
|
4532
|
+
}
|
|
4533
|
+
} catch {
|
|
4534
|
+
log.info("Server: \u25CF Unreachable");
|
|
4535
|
+
}
|
|
4536
|
+
const packageJson = await Promise.resolve().then(() => (init_package(), package_exports));
|
|
4537
|
+
log.info("Client Version: " + packageJson.default.version);
|
|
4538
|
+
try {
|
|
4539
|
+
const serverConfig = await getServerConfig(config2.serverUrl);
|
|
4540
|
+
log.info("Auth Provider: " + (serverConfig.authProvider || "Default"));
|
|
4541
|
+
} catch {
|
|
4542
|
+
}
|
|
4543
|
+
const isAuthenticated = await client3.isAuthenticated();
|
|
4544
|
+
log.info(
|
|
4545
|
+
"Auth: " + (isAuthenticated ? "\u25CF Authenticated" : "\u25CF Not authenticated")
|
|
4546
|
+
);
|
|
4547
|
+
const keyInfo = SshKeyManager.getKeyInfo(config2.sshKeyPath);
|
|
4548
|
+
log.info(
|
|
4549
|
+
"SSH Key: " + (keyInfo.exists ? `\u25CF Present (${keyInfo.path})` : "\u25CF Not generated")
|
|
4550
|
+
);
|
|
4551
|
+
if (!keyInfo.exists) {
|
|
4552
|
+
log.info("Run: periscope user key generate");
|
|
4553
|
+
}
|
|
4554
|
+
if (isAuthenticated) {
|
|
4555
|
+
let isPendingApproval = false;
|
|
4556
|
+
try {
|
|
4557
|
+
const accountStatus = await client3.getUserStatus();
|
|
4558
|
+
if (accountStatus) {
|
|
4559
|
+
isPendingApproval = accountStatus === AccountStatus.PENDING_APPROVAL;
|
|
4560
|
+
const statusDisplay = isPendingApproval ? "\u23F3 Pending Approval" : accountStatus === AccountStatus.ACTIVE ? "\u25CF Active" : accountStatus;
|
|
4561
|
+
log.info("Account: " + statusDisplay);
|
|
4562
|
+
}
|
|
4563
|
+
} catch {
|
|
4564
|
+
}
|
|
4565
|
+
try {
|
|
4566
|
+
const sshCreds = await client3.getSSHCredentials();
|
|
4567
|
+
if (sshCreds.slug) {
|
|
4568
|
+
log.info("User Slug: " + sshCreds.slug);
|
|
4569
|
+
log.info(
|
|
4570
|
+
"Tunnel Format: {name}-" + sshCreds.slug + "." + (sshCreds.wildcardHostname || "domain")
|
|
4571
|
+
);
|
|
4572
|
+
}
|
|
4573
|
+
if (keyInfo.exists && sshCreds.keyGeneratedAt) {
|
|
4574
|
+
const registeredDate = new Date(sshCreds.keyGeneratedAt);
|
|
4575
|
+
if (!isNaN(registeredDate.getTime())) {
|
|
4576
|
+
log.info("Key Registered: " + registeredDate.toLocaleString());
|
|
4577
|
+
}
|
|
4578
|
+
}
|
|
4579
|
+
} catch {
|
|
4580
|
+
}
|
|
4581
|
+
if (isPendingApproval) {
|
|
4582
|
+
log.blank();
|
|
4583
|
+
log.warn("Your account is pending approval by an administrator.");
|
|
4584
|
+
log.warn(
|
|
4585
|
+
"Most commands will be unavailable until your account is active."
|
|
4586
|
+
);
|
|
4587
|
+
}
|
|
4588
|
+
} else {
|
|
4589
|
+
log.info("Run: periscope auth login");
|
|
4590
|
+
}
|
|
4591
|
+
log.info(
|
|
4592
|
+
"Request Log: " + (config2.showRequestLog !== false ? "\u25CF Enabled" : "\u25CB Disabled")
|
|
4593
|
+
);
|
|
4594
|
+
trackEvent("status_check", { authenticated: isAuthenticated.toString() });
|
|
4595
|
+
log.separator();
|
|
4596
|
+
} catch (error) {
|
|
4597
|
+
this.handleError(_StatusCommand.Action.CHECK, error);
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4600
|
+
};
|
|
4601
|
+
|
|
4602
|
+
// src/commands/auth.ts
|
|
4603
|
+
init_telemetry();
|
|
4604
|
+
var AuthCommand = class _AuthCommand extends BaseCommand {
|
|
4605
|
+
/** Action constants for type-safe error handling */
|
|
4606
|
+
static Action = {
|
|
4607
|
+
LOGIN: "authenticate",
|
|
4608
|
+
LOGOUT: "logout"
|
|
4609
|
+
};
|
|
4610
|
+
static async login(options = {}) {
|
|
4611
|
+
log.info("Initializing authentication...");
|
|
4612
|
+
try {
|
|
4613
|
+
const { client: client3, config: config2 } = await this.initClient();
|
|
4614
|
+
log.debug("Starting authentication...");
|
|
4615
|
+
await this.authenticateWithChoice(client3, options.prompt);
|
|
4616
|
+
log.debug("Authentication complete");
|
|
4617
|
+
log.debug("Ensuring SSH key pair...");
|
|
4618
|
+
const keyPair = await SshKeyManager.ensureKeyPair(config2.sshKeyPath);
|
|
4619
|
+
const publicKey = await SshKeyManager.exportPublicKey(keyPair);
|
|
4620
|
+
log.debug("Registering SSH public key with server...");
|
|
4621
|
+
await client3.registerPublicKey(publicKey);
|
|
4622
|
+
log.success("SSH key registered with server.");
|
|
4623
|
+
} catch (error) {
|
|
4624
|
+
log.debug("Login failed with error:", error);
|
|
4625
|
+
this.handleError(_AuthCommand.Action.LOGIN, error);
|
|
4626
|
+
}
|
|
4627
|
+
}
|
|
4628
|
+
static async logout() {
|
|
4629
|
+
log.info("Clearing authentication data...");
|
|
4630
|
+
try {
|
|
4631
|
+
const config2 = ConfigManager.load();
|
|
4632
|
+
if (!config2.serverUrl) {
|
|
4633
|
+
log.warn("No server URL configured - nothing to clear");
|
|
4634
|
+
return;
|
|
4635
|
+
}
|
|
4636
|
+
const { client: client3 } = await this.initClient(config2);
|
|
4637
|
+
await client3.logout();
|
|
4638
|
+
log.success("Logged out successfully");
|
|
4639
|
+
log.success("Authentication data cleared");
|
|
4640
|
+
trackEvent("auth_logout");
|
|
4641
|
+
} catch (error) {
|
|
4642
|
+
this.handleError(_AuthCommand.Action.LOGOUT, error);
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
};
|
|
4646
|
+
|
|
4647
|
+
// src/commands/user.ts
|
|
4648
|
+
init_telemetry();
|
|
4649
|
+
var UserCommand = class _UserCommand extends BaseCommand {
|
|
4650
|
+
/** Action constants for type-safe error handling */
|
|
4651
|
+
static Action = {
|
|
4652
|
+
KEY_GENERATE: "generate SSH key",
|
|
4653
|
+
UPDATE_SLUG: "update user slug"
|
|
4654
|
+
};
|
|
4655
|
+
/**
|
|
4656
|
+
* Generate a new SSH key pair and register it with the server.
|
|
4657
|
+
* Overwrites any existing key at the configured path.
|
|
4658
|
+
*/
|
|
4659
|
+
static async keyGenerate() {
|
|
4660
|
+
try {
|
|
4661
|
+
const { client: client3, config: config2 } = await this.setupClient(
|
|
4662
|
+
"Generating SSH key..."
|
|
4663
|
+
);
|
|
4664
|
+
log.info("Generating new ECDSA P-256 SSH key pair...");
|
|
4665
|
+
const keyPair = await SshKeyManager.generateKeyPair(config2.sshKeyPath);
|
|
4666
|
+
const publicKey = await SshKeyManager.exportPublicKey(keyPair);
|
|
4667
|
+
await client3.registerPublicKey(publicKey);
|
|
4668
|
+
const keyInfo = SshKeyManager.getKeyInfo(config2.sshKeyPath);
|
|
4669
|
+
log.success("SSH key generated and registered.");
|
|
4670
|
+
log.info("Private key:", keyInfo.path);
|
|
4671
|
+
log.info("Public key:", keyInfo.pubKeyPath);
|
|
4672
|
+
trackEvent("user_key_generate");
|
|
4673
|
+
} catch (error) {
|
|
4674
|
+
this.handleError(_UserCommand.Action.KEY_GENERATE, error);
|
|
4675
|
+
}
|
|
4676
|
+
}
|
|
4677
|
+
/**
|
|
4678
|
+
* Update the user's slug for tunnel namespacing (delegates to server update).
|
|
4679
|
+
*/
|
|
4680
|
+
static async updateSlug(newSlug) {
|
|
4681
|
+
if (!/^[a-zA-Z]{6}$/.test(newSlug)) {
|
|
4682
|
+
log.error("Invalid slug format");
|
|
4683
|
+
log.info("Slug must be exactly 6 alphabetic characters (a-z, A-Z)");
|
|
4684
|
+
log.info("Example: periscope user slug myslug");
|
|
4685
|
+
return;
|
|
4686
|
+
}
|
|
4687
|
+
try {
|
|
4688
|
+
const { client: client3 } = await this.setupClient();
|
|
4689
|
+
const normalizedSlug = newSlug.toLowerCase();
|
|
4690
|
+
log.info(`Updating user slug to: ${normalizedSlug}`);
|
|
4691
|
+
await client3.updateSlug({ slug: normalizedSlug });
|
|
4692
|
+
log.success("Slug updated successfully!");
|
|
4693
|
+
log.info(`Your tunnels will now use: {name}-${normalizedSlug}.{domain}`);
|
|
4694
|
+
trackEvent("auth_update_slug");
|
|
4695
|
+
} catch (error) {
|
|
4696
|
+
this.handleError(_UserCommand.Action.UPDATE_SLUG, error);
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
};
|
|
4700
|
+
|
|
4701
|
+
// src/interactive.ts
|
|
4702
|
+
init_readline_instance();
|
|
4703
|
+
init_process_lifecycle();
|
|
4704
|
+
var InteractiveMode = class _InteractiveMode {
|
|
4705
|
+
static instance = null;
|
|
4706
|
+
constructor() {
|
|
4707
|
+
_InteractiveMode.instance = this;
|
|
4708
|
+
initializeReadline(true, (line) => this.tabCompleter(line));
|
|
4709
|
+
this.setupLineHandler();
|
|
4710
|
+
this.setupSignalHandling();
|
|
4711
|
+
}
|
|
4712
|
+
get rl() {
|
|
4713
|
+
return getReadlineInterface();
|
|
4714
|
+
}
|
|
4715
|
+
setupLineHandler() {
|
|
4716
|
+
this.rl.on("line", (line) => this.handleCommand(line.trim()));
|
|
4717
|
+
}
|
|
4718
|
+
/**
|
|
4719
|
+
* Tab completion for interactive mode.
|
|
4720
|
+
* Maps the command tree so Tab expands to the next token at each level.
|
|
4721
|
+
*/
|
|
4722
|
+
tabCompleter(line) {
|
|
4723
|
+
const trimmed = line.trimEnd();
|
|
4724
|
+
const parts = trimmed.split(" ");
|
|
4725
|
+
const topLevel = [
|
|
4726
|
+
"auth",
|
|
4727
|
+
"config",
|
|
4728
|
+
"connect",
|
|
4729
|
+
"status",
|
|
4730
|
+
"user",
|
|
4731
|
+
"help",
|
|
4732
|
+
"clear",
|
|
4733
|
+
"exit",
|
|
4734
|
+
"quit"
|
|
4735
|
+
];
|
|
4736
|
+
let candidates;
|
|
4737
|
+
if (parts.length <= 1) {
|
|
4738
|
+
candidates = topLevel;
|
|
4739
|
+
} else {
|
|
4740
|
+
switch (parts[0]) {
|
|
4741
|
+
case "auth":
|
|
4742
|
+
candidates = ["auth login", "auth logout"];
|
|
4743
|
+
break;
|
|
4744
|
+
case "config":
|
|
4745
|
+
if (parts[1] === "set" && parts.length >= 3) {
|
|
4746
|
+
candidates = ["config set --server", "config set --request-log"];
|
|
4747
|
+
} else {
|
|
4748
|
+
candidates = ["config show", "config set"];
|
|
4749
|
+
}
|
|
4750
|
+
break;
|
|
4751
|
+
case "user":
|
|
4752
|
+
if (parts[1] === "key") {
|
|
4753
|
+
candidates = ["user key generate"];
|
|
4754
|
+
} else {
|
|
4755
|
+
candidates = ["user slug", "user key"];
|
|
4756
|
+
}
|
|
4757
|
+
break;
|
|
4758
|
+
default:
|
|
4759
|
+
return [[], line];
|
|
4760
|
+
}
|
|
4761
|
+
}
|
|
4762
|
+
const hits = candidates.filter((c) => c.startsWith(trimmed));
|
|
4763
|
+
return [hits, line];
|
|
4764
|
+
}
|
|
4765
|
+
setupSignalHandling() {
|
|
4766
|
+
this.rl.on("SIGINT", async () => {
|
|
4767
|
+
log.info("Exiting interactive mode...");
|
|
4768
|
+
await this.close();
|
|
4769
|
+
await gracefulExit(0);
|
|
4770
|
+
});
|
|
4771
|
+
}
|
|
4772
|
+
async start() {
|
|
4773
|
+
process.env.PERISCOPE_INTERACTIVE = "true";
|
|
4774
|
+
log.header("\u{1F52D} Periscope Interactive CLI");
|
|
4775
|
+
log.info('Type "help" for available commands or "exit" to quit.');
|
|
4776
|
+
log.blank();
|
|
4777
|
+
this.rl.prompt();
|
|
4778
|
+
}
|
|
4779
|
+
async handleCommand(input) {
|
|
4780
|
+
if (!input) {
|
|
4781
|
+
this.rl.prompt();
|
|
4782
|
+
return;
|
|
4783
|
+
}
|
|
4784
|
+
const [mainCommand, ...args] = input.split(" ");
|
|
4785
|
+
try {
|
|
4786
|
+
switch (mainCommand.toLowerCase()) {
|
|
4787
|
+
case "help":
|
|
4788
|
+
this.showHelp();
|
|
4789
|
+
break;
|
|
4790
|
+
case "exit":
|
|
4791
|
+
case "quit":
|
|
4792
|
+
log.raw("Goodbye! \u{1F44B}");
|
|
4793
|
+
process.exit(0);
|
|
4794
|
+
break;
|
|
4795
|
+
case "auth":
|
|
4796
|
+
await this.handleAuthCommand(args);
|
|
4797
|
+
break;
|
|
4798
|
+
case "config":
|
|
4799
|
+
await this.handleConfigCommand(args);
|
|
4800
|
+
break;
|
|
4801
|
+
case "connect":
|
|
4802
|
+
await this.handleConnectCommand(args);
|
|
4803
|
+
break;
|
|
4804
|
+
case "status":
|
|
4805
|
+
await StatusCommand.check();
|
|
4806
|
+
break;
|
|
4807
|
+
case "user":
|
|
4808
|
+
await this.handleUserCommand(args);
|
|
4809
|
+
break;
|
|
4810
|
+
case "clear":
|
|
4811
|
+
console.clear();
|
|
4812
|
+
break;
|
|
4813
|
+
default:
|
|
4814
|
+
log.error(`Unknown command: ${mainCommand}`);
|
|
4815
|
+
log.info('Type "help" for available commands.');
|
|
4816
|
+
break;
|
|
4817
|
+
}
|
|
4818
|
+
} catch (error) {
|
|
4819
|
+
log.error(
|
|
4820
|
+
`Error: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
4821
|
+
);
|
|
4822
|
+
}
|
|
4823
|
+
this.rl.prompt();
|
|
4824
|
+
}
|
|
4825
|
+
showHelp() {
|
|
4826
|
+
log.header("Available Commands:");
|
|
4827
|
+
log.separator(50);
|
|
4828
|
+
log.info("Authentication:");
|
|
4829
|
+
log.info(" auth login - Authenticate (choose method)");
|
|
4830
|
+
log.info(" auth logout - Clear authentication data");
|
|
4831
|
+
log.header("User:");
|
|
4832
|
+
log.info(" user slug <new-slug> - Update user slug (6 letters)");
|
|
4833
|
+
log.info(
|
|
4834
|
+
" user key generate - Generate and register a new SSH key"
|
|
4835
|
+
);
|
|
4836
|
+
log.header("Configuration:");
|
|
4837
|
+
log.info(" config show - Show current configuration");
|
|
4838
|
+
log.info(" config set --server <url> - Set server URL");
|
|
4839
|
+
log.info(
|
|
4840
|
+
" config set --request-log <bool> - Show request log (true/false)"
|
|
4841
|
+
);
|
|
4842
|
+
log.header("Tunnels:");
|
|
4843
|
+
log.info(
|
|
4844
|
+
" connect <name> [--target <target>] [--key <path>] - Establish SSH tunnel"
|
|
4845
|
+
);
|
|
4846
|
+
log.header("General:");
|
|
4847
|
+
log.info(
|
|
4848
|
+
" status - Show server, auth, and SSH key status"
|
|
4849
|
+
);
|
|
4850
|
+
log.info(" clear - Clear the screen");
|
|
4851
|
+
log.info(" help - Show this help message");
|
|
4852
|
+
log.info(" exit, quit - Exit interactive mode");
|
|
4853
|
+
log.separator(50);
|
|
4854
|
+
log.info("Use Tab for command completion");
|
|
4855
|
+
}
|
|
4856
|
+
async handleAuthCommand(args) {
|
|
4857
|
+
const [subCommand] = args;
|
|
4858
|
+
switch (subCommand) {
|
|
4859
|
+
case "login":
|
|
4860
|
+
await AuthCommand.login();
|
|
4861
|
+
break;
|
|
4862
|
+
case "logout":
|
|
4863
|
+
await AuthCommand.logout();
|
|
4864
|
+
break;
|
|
4865
|
+
default:
|
|
4866
|
+
log.error("Invalid auth command. Available: login, logout");
|
|
4867
|
+
break;
|
|
4868
|
+
}
|
|
4869
|
+
}
|
|
4870
|
+
async handleUserCommand(args) {
|
|
4871
|
+
const [subCommand, ...options] = args;
|
|
4872
|
+
switch (subCommand) {
|
|
4873
|
+
case "slug": {
|
|
4874
|
+
const newSlug = options[0];
|
|
4875
|
+
if (!newSlug) {
|
|
4876
|
+
log.error("Please provide a new slug.");
|
|
4877
|
+
log.info("Usage: user slug <new-slug>");
|
|
4878
|
+
break;
|
|
4879
|
+
}
|
|
4880
|
+
await UserCommand.updateSlug(newSlug);
|
|
4881
|
+
break;
|
|
4882
|
+
}
|
|
4883
|
+
case "key": {
|
|
4884
|
+
const keySubCommand = options[0];
|
|
4885
|
+
if (keySubCommand === "generate") {
|
|
4886
|
+
await UserCommand.keyGenerate();
|
|
4887
|
+
} else {
|
|
4888
|
+
log.error("Invalid key command. Available: generate");
|
|
4889
|
+
log.info("Usage: user key generate");
|
|
4890
|
+
}
|
|
4891
|
+
break;
|
|
4892
|
+
}
|
|
4893
|
+
default:
|
|
4894
|
+
log.error("Invalid user command. Available: slug, key");
|
|
4895
|
+
break;
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
async handleConfigCommand(args) {
|
|
4899
|
+
const [subCommand, ...options] = args;
|
|
4900
|
+
switch (subCommand) {
|
|
4901
|
+
case "show":
|
|
4902
|
+
await ConfigCommand.show();
|
|
4903
|
+
break;
|
|
4904
|
+
case "set": {
|
|
4905
|
+
const configOptions = {};
|
|
4906
|
+
for (let i = 0; i < options.length; i += 2) {
|
|
4907
|
+
const option = options[i];
|
|
4908
|
+
const value = options[i + 1];
|
|
4909
|
+
switch (option) {
|
|
4910
|
+
case "--server":
|
|
4911
|
+
case "-s":
|
|
4912
|
+
configOptions.server = value;
|
|
4913
|
+
break;
|
|
4914
|
+
case "--request-log":
|
|
4915
|
+
configOptions.requestLog = value;
|
|
4916
|
+
break;
|
|
4917
|
+
}
|
|
4918
|
+
}
|
|
4919
|
+
await ConfigCommand.set(configOptions);
|
|
4920
|
+
break;
|
|
4921
|
+
}
|
|
4922
|
+
default:
|
|
4923
|
+
log.error("Invalid config command. Available: show, set");
|
|
4924
|
+
break;
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
async handleConnectCommand(args) {
|
|
4928
|
+
if (args.length === 0) {
|
|
4929
|
+
log.error("Please specify a tunnel name.");
|
|
4930
|
+
log.info("Usage: connect <name> [--target <target>] [--key <path>]");
|
|
4931
|
+
return;
|
|
4932
|
+
}
|
|
4933
|
+
const name = args[0];
|
|
4934
|
+
let target;
|
|
4935
|
+
let sshKeyPath;
|
|
4936
|
+
for (let i = 1; i < args.length; i += 2) {
|
|
4937
|
+
const option = args[i];
|
|
4938
|
+
const value = args[i + 1];
|
|
4939
|
+
switch (option) {
|
|
4940
|
+
case "--target":
|
|
4941
|
+
target = value;
|
|
4942
|
+
break;
|
|
4943
|
+
case "--key":
|
|
4944
|
+
sshKeyPath = value;
|
|
4945
|
+
break;
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
await TunnelCommand.connect(name, target, sshKeyPath);
|
|
4949
|
+
}
|
|
4950
|
+
/**
|
|
4951
|
+
* Trigger a new prompt (useful for background operations)
|
|
4952
|
+
*/
|
|
4953
|
+
static triggerPrompt() {
|
|
4954
|
+
if (_InteractiveMode.instance) {
|
|
4955
|
+
const rl = getReadlineInterface();
|
|
4956
|
+
if (rl) {
|
|
4957
|
+
rl.prompt();
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4960
|
+
}
|
|
4961
|
+
async close() {
|
|
4962
|
+
_InteractiveMode.instance = null;
|
|
4963
|
+
closeReadline();
|
|
4964
|
+
await performSecureCleanup();
|
|
4965
|
+
}
|
|
4966
|
+
};
|
|
4967
|
+
export {
|
|
4968
|
+
Auth0AuthManager,
|
|
4969
|
+
ConfigManager,
|
|
4970
|
+
InteractiveMode,
|
|
4971
|
+
MsalAuthManager,
|
|
4972
|
+
PeriscopeClient,
|
|
4973
|
+
TunnelManager,
|
|
4974
|
+
setupSecureCleanup
|
|
4975
|
+
};
|
|
4976
|
+
//# sourceMappingURL=index.js.map
|