@f5-sales-demo/xcsh 21.45.2 → 21.45.3
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/package.json +8 -8
- package/src/internal-urls/api-catalog-qmd-index.generated.ts +2 -2
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/locales/en.json +2 -1
- package/src/modes/components/auth-link-presenter.ts +38 -11
- package/src/modes/controllers/selector-controller.ts +52 -11
- package/src/modes/oauth-manual-input.ts +16 -0
- package/src/slash-commands/builtin-registry.ts +18 -1
- package/src/tui/hyperlink.ts +14 -1
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "21.45.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "21.45.3",
|
|
21
|
+
"commit": "f2bcda523f9298bf318b85466aff6e44e8ff5872",
|
|
22
|
+
"shortCommit": "f2bcda5",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v21.45.
|
|
25
|
-
"commitDate": "2026-09-
|
|
26
|
-
"buildDate": "2026-09-25T15:
|
|
24
|
+
"tag": "v21.45.3",
|
|
25
|
+
"commitDate": "2026-09-25T15:11:11Z",
|
|
26
|
+
"buildDate": "2026-09-25T15:52:20.005Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.45.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/f2bcda523f9298bf318b85466aff6e44e8ff5872",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.45.3"
|
|
33
33
|
};
|
package/src/locales/en.json
CHANGED
|
@@ -159,11 +159,12 @@
|
|
|
159
159
|
"commands.fork.description": "Create a new fork from a previous message",
|
|
160
160
|
"commands.tree.description": "Navigate session tree (switch branches)",
|
|
161
161
|
"commands.login.description": "Login with OAuth provider",
|
|
162
|
-
"commands.login.inlineHint": "[provider|redirect URL]",
|
|
162
|
+
"commands.login.inlineHint": "[provider|redirect URL|copy]",
|
|
163
163
|
"commands.login.alreadyInProgressFor": "OAuth login already in progress for {provider}. Paste the redirect URL with /login <url>.",
|
|
164
164
|
"commands.login.alreadyInProgress": "OAuth login already in progress. Paste the redirect URL with /login <url>.",
|
|
165
165
|
"commands.login.callbackReceived": "OAuth callback received; completing login…",
|
|
166
166
|
"commands.login.noCallbackWaiting": "No OAuth login is waiting for a manual callback.",
|
|
167
|
+
"commands.login.noUrlToCopy": "No OAuth sign-in URL is waiting to copy.",
|
|
167
168
|
"commands.logout.description": "Logout from OAuth provider",
|
|
168
169
|
"commands.mcp.description": "Manage MCP servers (add, list, remove, test)",
|
|
169
170
|
"commands.mcp.sub.add.description": "Add a new MCP server",
|
|
@@ -1,33 +1,60 @@
|
|
|
1
|
-
import { type Container, Text } from "@f5-sales-demo/pi-tui";
|
|
2
|
-
import { recoveryUrlHyperlink } from "../../tui/hyperlink";
|
|
1
|
+
import { type Component, type Container, TERMINAL, Text, visibleWidth, wrapTextWithAnsi } from "@f5-sales-demo/pi-tui";
|
|
2
|
+
import { recoveryUrlHyperlink, safeRecoveryUrl } from "../../tui/hyperlink";
|
|
3
3
|
import { theme } from "../theme/theme";
|
|
4
4
|
|
|
5
5
|
type AuthLinkContainer = Pick<Container, "addChild">;
|
|
6
6
|
|
|
7
|
+
/** Wrap plain glyphs first, then attach the complete target to each physical row. */
|
|
8
|
+
class RecoveryLinkText implements Component {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly url: string,
|
|
11
|
+
private readonly label: string,
|
|
12
|
+
) {}
|
|
13
|
+
invalidate(): void {}
|
|
14
|
+
|
|
15
|
+
render(width: number): string[] {
|
|
16
|
+
const margin = width >= 3 ? " " : "";
|
|
17
|
+
const contentWidth = Math.max(1, width - margin.length * 2);
|
|
18
|
+
return wrapTextWithAnsi(this.label, contentWidth).map(segment => {
|
|
19
|
+
const linked = theme.fg("accent", recoveryUrlHyperlink(this.url, segment));
|
|
20
|
+
const line = `${margin}${linked}${margin}`;
|
|
21
|
+
return line + " ".repeat(Math.max(0, width - visibleWidth(line)));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
export interface AuthLinkPresenterOptions {
|
|
8
27
|
platform?: NodeJS.Platform;
|
|
9
28
|
}
|
|
10
29
|
|
|
11
|
-
/** Present a browser authorization target
|
|
30
|
+
/** Present a browser authorization target with exact links on every wrapped row. */
|
|
12
31
|
export function presentAuthLink(
|
|
13
32
|
container: AuthLinkContainer,
|
|
14
33
|
url: string,
|
|
15
34
|
options: AuthLinkPresenterOptions = {},
|
|
16
35
|
): void {
|
|
36
|
+
const target = safeRecoveryUrl(url);
|
|
37
|
+
if (!target) {
|
|
38
|
+
container.addChild(new Text(theme.fg("warning", "Sign-in URL unavailable: invalid HTTP(S) target."), 1, 0));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
17
41
|
const platform = options.platform ?? process.platform;
|
|
18
42
|
const clickHint = platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
|
|
19
|
-
const hyperlink = recoveryUrlHyperlink(
|
|
20
|
-
container.addChild(new
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
new
|
|
24
|
-
|
|
43
|
+
const hyperlink = recoveryUrlHyperlink(target, "Open sign-in page");
|
|
44
|
+
container.addChild(new RecoveryLinkText(target, "Open sign-in page"));
|
|
45
|
+
container.addChild(new Text(theme.fg("dim", `(${clickHint})`), 1, 0));
|
|
46
|
+
if (!TERMINAL.hyperlinks || hyperlink === "Open sign-in page") {
|
|
47
|
+
container.addChild(new RecoveryLinkText(target, target));
|
|
48
|
+
container.addChild(new Text(theme.fg("dim", "The full URL is visible above for manual recovery."), 1, 0));
|
|
49
|
+
}
|
|
25
50
|
}
|
|
26
51
|
|
|
27
52
|
/** Render device verification details so they remain usable without hyperlink or clipboard support. */
|
|
28
53
|
export function presentDeviceCode(container: AuthLinkContainer, url: string, userCode: string): void {
|
|
29
|
-
const
|
|
30
|
-
container.addChild(new
|
|
54
|
+
const target = safeRecoveryUrl(url);
|
|
55
|
+
if (target) container.addChild(new RecoveryLinkText(target, target));
|
|
56
|
+
else
|
|
57
|
+
container.addChild(new Text(theme.fg("warning", "Verification URL unavailable: invalid HTTP(S) target."), 1, 0));
|
|
31
58
|
container.addChild(new Text(theme.fg("warning", `One-time code: ${theme.bold(userCode)}`), 1, 0));
|
|
32
59
|
container.addChild(
|
|
33
60
|
new Text(theme.fg("dim", "Press c at the prompt to copy; the code remains readable above."), 1, 0),
|
|
@@ -40,7 +40,7 @@ import { createContextEnv } from "../../services/context-env";
|
|
|
40
40
|
import { type SessionInfo, SessionManager } from "../../session/session-manager";
|
|
41
41
|
import { FileSessionStorage } from "../../session/session-storage";
|
|
42
42
|
import { isSearchProviderPreference, setPreferredImageProvider, setPreferredSearchProvider } from "../../tools";
|
|
43
|
-
import { applyHyperlinkSetting } from "../../tui/hyperlink";
|
|
43
|
+
import { applyHyperlinkSetting, safeRecoveryUrl } from "../../tui/hyperlink";
|
|
44
44
|
import { setSessionTerminalTitle } from "../../utils/title-generator";
|
|
45
45
|
import { AgentDashboard } from "../components/agent-dashboard";
|
|
46
46
|
import { AssistantMessageComponent } from "../components/assistant-message";
|
|
@@ -1980,6 +1980,8 @@ export class SelectorController {
|
|
|
1980
1980
|
CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider) || providerId === "openai-codex";
|
|
1981
1981
|
const shouldOpenBrowser = providerId !== "openai-codex" || openAICodexMethod === "browser";
|
|
1982
1982
|
let endAuthorizationWait: (() => void) | undefined;
|
|
1983
|
+
let authorizationUrl: string | undefined;
|
|
1984
|
+
let authorizationKind: "browser" | "device" = "browser";
|
|
1983
1985
|
|
|
1984
1986
|
const abort = new AbortController();
|
|
1985
1987
|
let closeAuthFrame: (() => void) | undefined;
|
|
@@ -1997,10 +1999,22 @@ export class SelectorController {
|
|
|
1997
1999
|
});
|
|
1998
2000
|
input.onEscape = cancel;
|
|
1999
2001
|
input.onSubmit = () => {
|
|
2000
|
-
const
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
.
|
|
2002
|
+
const typed = input.getValue().trim();
|
|
2003
|
+
if (authorizationKind !== "device" && authorizationUrl && /^(?:c|copy)$/iu.test(typed)) {
|
|
2004
|
+
const url = authorizationUrl;
|
|
2005
|
+
input.setValue("");
|
|
2006
|
+
void reviewClipboardAction(this.ctx, {
|
|
2007
|
+
title: "sign-in URL copy",
|
|
2008
|
+
identity: `login:${providerId}:authorization-url`,
|
|
2009
|
+
label: "Current sign-in URL",
|
|
2010
|
+
success: "Sign-in URL copied to the local clipboard.",
|
|
2011
|
+
reopen: "Enter c at the login prompt to review it again.",
|
|
2012
|
+
current: () => !abort.signal.aborted && authFrame?.input === input && authorizationUrl === url,
|
|
2013
|
+
resolveText: () => (authorizationUrl === url ? url : undefined),
|
|
2014
|
+
});
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
const value = typed.replace(/^\/login\s+/, "");
|
|
2004
2018
|
if (value) manualInput.submit(value);
|
|
2005
2019
|
input.setValue("");
|
|
2006
2020
|
};
|
|
@@ -2030,11 +2044,21 @@ export class SelectorController {
|
|
|
2030
2044
|
kind?: "browser" | "device";
|
|
2031
2045
|
userCode?: string;
|
|
2032
2046
|
}) => {
|
|
2047
|
+
authorizationUrl = safeRecoveryUrl(info.url);
|
|
2048
|
+
authorizationKind = info.kind ?? "browser";
|
|
2049
|
+
if (useManualInput) {
|
|
2050
|
+
manualInput.clearAuthorizationUrl(providerId);
|
|
2051
|
+
if (authorizationUrl) manualInput.setAuthorizationUrl(providerId, authorizationUrl);
|
|
2052
|
+
}
|
|
2033
2053
|
const frame = showAuthFrame();
|
|
2034
2054
|
frame.content.clear();
|
|
2035
2055
|
if (info.kind === "device" && info.userCode) presentDeviceCode(frame.content, info.url, info.userCode);
|
|
2036
2056
|
else presentAuthLink(frame.content, info.url);
|
|
2037
2057
|
if (info.instructions) frame.content.addChild(new Text(theme.fg("muted", info.instructions), 0, 0));
|
|
2058
|
+
if (info.kind !== "device" && authorizationUrl)
|
|
2059
|
+
frame.content.addChild(
|
|
2060
|
+
new Text(theme.fg("dim", "Type c at the prompt to copy the exact sign-in URL."), 0, 0),
|
|
2061
|
+
);
|
|
2038
2062
|
if (useManualInput) frame.content.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 0, 0));
|
|
2039
2063
|
this.ctx.ui.requestRender();
|
|
2040
2064
|
if (shouldOpenBrowser)
|
|
@@ -2055,17 +2079,24 @@ export class SelectorController {
|
|
|
2055
2079
|
};
|
|
2056
2080
|
input.onSubmit = () => {
|
|
2057
2081
|
const value = input.getValue();
|
|
2058
|
-
|
|
2082
|
+
const copyText = prompt.copyText ?? (authorizationKind === "device" ? undefined : authorizationUrl);
|
|
2083
|
+
if (copyText && /^(?:c|copy)$/i.test(value.trim())) {
|
|
2084
|
+
const copyingCode = Boolean(prompt.copyText);
|
|
2059
2085
|
void reviewClipboardAction(this.ctx, {
|
|
2060
2086
|
title: "login recovery copy",
|
|
2061
2087
|
identity: `login:${providerId}:prompt`,
|
|
2062
|
-
label: "One-time login recovery value",
|
|
2063
|
-
success:
|
|
2088
|
+
label: copyingCode ? "One-time login recovery value" : "Current sign-in URL",
|
|
2089
|
+
success: copyingCode
|
|
2090
|
+
? "One-time login recovery value copied to the local clipboard."
|
|
2091
|
+
: "Sign-in URL copied to the local clipboard.",
|
|
2064
2092
|
reopen: "Enter copy at the login prompt to review it again.",
|
|
2065
|
-
current: () =>
|
|
2066
|
-
|
|
2093
|
+
current: () =>
|
|
2094
|
+
!abort.signal.aborted &&
|
|
2095
|
+
frame.input === input &&
|
|
2096
|
+
(copyingCode || authorizationUrl === copyText),
|
|
2097
|
+
resolveText: () => (copyingCode || authorizationUrl === copyText ? copyText : undefined),
|
|
2067
2098
|
}).then(outcome => {
|
|
2068
|
-
if (outcome === "copied" || outcome === "requested") {
|
|
2099
|
+
if (copyingCode && (outcome === "copied" || outcome === "requested")) {
|
|
2069
2100
|
restoreWaiting();
|
|
2070
2101
|
resolve("");
|
|
2071
2102
|
} else {
|
|
@@ -2108,6 +2139,7 @@ export class SelectorController {
|
|
|
2108
2139
|
} else this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2109
2140
|
} finally {
|
|
2110
2141
|
closeAuthFrame?.();
|
|
2142
|
+
manualInput.clearAuthorizationUrl(providerId);
|
|
2111
2143
|
if (useManualInput) {
|
|
2112
2144
|
manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
|
|
2113
2145
|
}
|
|
@@ -2161,7 +2193,15 @@ export class SelectorController {
|
|
|
2161
2193
|
try {
|
|
2162
2194
|
await authStorage.login("google-vertex", {
|
|
2163
2195
|
onAuth: info => {
|
|
2196
|
+
const authorizationUrl = safeRecoveryUrl(info.url);
|
|
2197
|
+
this.ctx.oauthManualInput.clearAuthorizationUrl("google-vertex");
|
|
2198
|
+
if (authorizationUrl)
|
|
2199
|
+
this.ctx.oauthManualInput.setAuthorizationUrl("google-vertex", authorizationUrl);
|
|
2164
2200
|
presentAuthLink(this.ctx.chatContainer, info.url);
|
|
2201
|
+
if (authorizationUrl)
|
|
2202
|
+
this.ctx.chatContainer.addChild(
|
|
2203
|
+
new Text(theme.fg("dim", "Tip: Use /login copy to copy the exact sign-in URL."), 1, 0),
|
|
2204
|
+
);
|
|
2165
2205
|
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", VERTEX_MANUAL_LOGIN_TIP), 1, 0));
|
|
2166
2206
|
if (!isHeadlessTerminal(runtime.environment)) {
|
|
2167
2207
|
const launch = this.#launchHttpUrl(info.url);
|
|
@@ -2182,6 +2222,7 @@ export class SelectorController {
|
|
|
2182
2222
|
onManualCodeInput: () => this.ctx.oauthManualInput.waitForInput("google-vertex"),
|
|
2183
2223
|
});
|
|
2184
2224
|
} finally {
|
|
2225
|
+
this.ctx.oauthManualInput.clearAuthorizationUrl("google-vertex");
|
|
2185
2226
|
endAuthorizationWait?.();
|
|
2186
2227
|
}
|
|
2187
2228
|
accessToken = await authStorage.getApiKey("google-vertex");
|
|
@@ -6,11 +6,25 @@ type PendingInput = {
|
|
|
6
6
|
|
|
7
7
|
export class OAuthManualInputManager {
|
|
8
8
|
#pending?: PendingInput;
|
|
9
|
+
#authorization?: { providerId: string; url: string };
|
|
10
|
+
|
|
11
|
+
setAuthorizationUrl(providerId: string, url: string): void {
|
|
12
|
+
this.#authorization = { providerId, url };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get authorizationUrl(): string | undefined {
|
|
16
|
+
return this.#pending?.providerId === this.#authorization?.providerId ? this.#authorization?.url : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
clearAuthorizationUrl(providerId: string): void {
|
|
20
|
+
if (this.#authorization?.providerId === providerId) this.#authorization = undefined;
|
|
21
|
+
}
|
|
9
22
|
|
|
10
23
|
waitForInput(providerId: string): Promise<string> {
|
|
11
24
|
if (this.#pending) {
|
|
12
25
|
this.clear("Manual OAuth input superseded by a new login");
|
|
13
26
|
}
|
|
27
|
+
if (this.#authorization?.providerId !== providerId) this.#authorization = undefined;
|
|
14
28
|
|
|
15
29
|
const { promise, resolve, reject } = Promise.withResolvers<string>();
|
|
16
30
|
this.#pending = { providerId, resolve, reject };
|
|
@@ -21,11 +35,13 @@ export class OAuthManualInputManager {
|
|
|
21
35
|
if (!this.#pending) return false;
|
|
22
36
|
const { resolve } = this.#pending;
|
|
23
37
|
this.#pending = undefined;
|
|
38
|
+
this.#authorization = undefined;
|
|
24
39
|
resolve(input);
|
|
25
40
|
return true;
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
clear(reason = "Manual OAuth input cleared"): void {
|
|
44
|
+
this.#authorization = undefined;
|
|
29
45
|
if (!this.#pending) return;
|
|
30
46
|
const { reject } = this.#pending;
|
|
31
47
|
this.#pending = undefined;
|
|
@@ -35,6 +35,7 @@ import { runReviewedAction } from "../modes/components/reviewed-action-dialog";
|
|
|
35
35
|
import { getLoginOptions } from "../modes/controllers/login-options";
|
|
36
36
|
import { theme } from "../modes/theme/theme";
|
|
37
37
|
import type { InteractiveModeContext } from "../modes/types";
|
|
38
|
+
import { reviewClipboardAction } from "../modes/utils/clipboard-action";
|
|
38
39
|
import { personProfileService } from "../person-profile/service";
|
|
39
40
|
import { resolveRemoteThreadId } from "../remote-control/thread-identity";
|
|
40
41
|
import { createContextEnv } from "../services/context-env";
|
|
@@ -893,9 +894,25 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
|
|
|
893
894
|
description: t("commands.login.description"),
|
|
894
895
|
inlineHint: t("commands.login.inlineHint"),
|
|
895
896
|
allowArgs: true,
|
|
896
|
-
handle: (command, runtime) => {
|
|
897
|
+
handle: async (command, runtime) => {
|
|
897
898
|
const manualInput = runtime.ctx.oauthManualInput;
|
|
898
899
|
const args = command.args.trim();
|
|
900
|
+
if (args.toLowerCase() === "copy") {
|
|
901
|
+
const url = manualInput.authorizationUrl;
|
|
902
|
+
if (url) {
|
|
903
|
+
await reviewClipboardAction(runtime.ctx, {
|
|
904
|
+
title: "sign-in URL copy",
|
|
905
|
+
identity: `login:${manualInput.pendingProviderId}:authorization-url`,
|
|
906
|
+
label: "Current sign-in URL",
|
|
907
|
+
success: "Sign-in URL copied to the local clipboard.",
|
|
908
|
+
reopen: "Run /login copy while this login is waiting to review it again.",
|
|
909
|
+
current: () => manualInput.authorizationUrl === url,
|
|
910
|
+
resolveText: () => manualInput.authorizationUrl,
|
|
911
|
+
});
|
|
912
|
+
} else runtime.ctx.showWarning(t("commands.login.noUrlToCopy"));
|
|
913
|
+
runtime.ctx.editor.setText("");
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
899
916
|
if (args.length > 0) {
|
|
900
917
|
const matchedProvider = getLoginOptions().find(provider => provider.id === args);
|
|
901
918
|
if (matchedProvider) {
|
package/src/tui/hyperlink.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { settings } from "../config/settings";
|
|
|
5
5
|
|
|
6
6
|
const DETECTED_TERMINAL_HYPERLINKS = TERMINAL.hyperlinks;
|
|
7
7
|
let policyGeneration = 0;
|
|
8
|
+
let activeMode: HyperlinkMode = "auto";
|
|
8
9
|
const OSC8_RE = /\x1b\]8;[^\x07\x1b]*(?:\x07|\x1b\\)/gu;
|
|
9
10
|
const UNSAFE_RE = /[\x00-\x1f\x7f-\x9f]/u;
|
|
10
11
|
type HyperlinkMode = "off" | "auto" | "always";
|
|
@@ -28,6 +29,7 @@ export function isHyperlinkEnabled(): boolean {
|
|
|
28
29
|
|
|
29
30
|
export function applyHyperlinkSetting(mode?: unknown): void {
|
|
30
31
|
const resolved = mode === "off" || mode === "auto" || mode === "always" ? mode : settings.get("tui.hyperlinks");
|
|
32
|
+
activeMode = resolved;
|
|
31
33
|
const enabled = resolveHyperlinkMode(resolved);
|
|
32
34
|
if (TERMINAL.hyperlinks !== enabled) policyGeneration++;
|
|
33
35
|
setTerminalHyperlinks(enabled);
|
|
@@ -62,8 +64,19 @@ export function urlHyperlink(target: string, label: string): string {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
export function recoveryUrlHyperlink(target: string, label: string): string {
|
|
67
|
+
const uri = safeRecoveryUrl(target);
|
|
68
|
+
// Authentication links must remain actionable in nested terminals whose
|
|
69
|
+
// capability detection is incomplete. Preserve the explicit user opt-out.
|
|
70
|
+
if (activeMode === "off" || !uri) return label;
|
|
71
|
+
return `\x1b]8;;${uri}\x07${label.replace(OSC8_RE, "")}\x1b]8;;\x07`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function safeRecoveryUrl(target: string): string | undefined {
|
|
65
75
|
const normalized = /^www\./iu.test(target) ? `https://${target}` : target;
|
|
66
|
-
|
|
76
|
+
const uri = safeAbsoluteUri(normalized);
|
|
77
|
+
if (!uri) return undefined;
|
|
78
|
+
const protocol = new URL(uri).protocol;
|
|
79
|
+
return protocol === "http:" || protocol === "https:" ? uri : undefined;
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
export function fileHyperlink(target: string, label: string, cwd = process.cwd()): string {
|