@mkterswingman/5mghost-yonder 0.0.1

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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Headless cookie auto-refresh.
3
+ *
4
+ * Uses Playwright to open YouTube in a headless browser with the existing
5
+ * browser-profile. If Google session is still valid, YouTube cookies are
6
+ * extracted and saved automatically — no user interaction needed.
7
+ *
8
+ * If Google session has expired too, returns false so the caller can
9
+ * prompt the user for interactive login.
10
+ */
11
+ import { existsSync } from "node:fs";
12
+ import { PATHS, ensureConfigDir } from "./config.js";
13
+ import { detectBrowserChannel, hasYouTubeSession, saveCookiesToDisk, } from "../cli/setupCookies.js";
14
+ const HEADLESS_TIMEOUT_MS = 30_000;
15
+ /**
16
+ * Attempt to refresh YouTube cookies headlessly using existing browser profile.
17
+ *
18
+ * @returns true if cookies were refreshed, false if Google session expired
19
+ * (needs interactive login).
20
+ * @throws if Playwright is not installed or browser can't launch.
21
+ */
22
+ export async function tryHeadlessRefresh() {
23
+ // No browser profile = never logged in = can't refresh
24
+ if (!existsSync(PATHS.browserProfile)) {
25
+ return false;
26
+ }
27
+ ensureConfigDir();
28
+ // Dynamic import to keep serve startup fast
29
+ let chromium;
30
+ try {
31
+ const pw = await import("playwright");
32
+ chromium = pw.chromium;
33
+ }
34
+ catch {
35
+ return false; // Playwright not installed — can't refresh
36
+ }
37
+ const channel = await detectBrowserChannel(chromium);
38
+ let context;
39
+ try {
40
+ context = await chromium.launchPersistentContext(PATHS.browserProfile, {
41
+ headless: true,
42
+ channel,
43
+ args: ["--disable-blink-features=AutomationControlled"],
44
+ });
45
+ }
46
+ catch {
47
+ return false; // Browser launch failed
48
+ }
49
+ try {
50
+ const page = context.pages()[0] ?? (await context.newPage());
51
+ await page.goto("https://www.youtube.com", { timeout: HEADLESS_TIMEOUT_MS });
52
+ await page.waitForLoadState("domcontentloaded");
53
+ const cookies = await context.cookies("https://www.youtube.com");
54
+ if (!hasYouTubeSession(cookies)) {
55
+ // Google session expired — can't auto-refresh
56
+ await context.close().catch(() => { });
57
+ return false;
58
+ }
59
+ // Wait a moment for all cookies to settle
60
+ await new Promise((r) => setTimeout(r, 1500));
61
+ const finalCookies = await context.cookies("https://www.youtube.com");
62
+ saveCookiesToDisk(finalCookies);
63
+ await context.close().catch(() => { });
64
+ return true;
65
+ }
66
+ catch {
67
+ await context.close().catch(() => { });
68
+ return false;
69
+ }
70
+ }
@@ -0,0 +1,18 @@
1
+ export interface CookieEntry {
2
+ name: string;
3
+ value: string;
4
+ domain: string;
5
+ path: string;
6
+ secure: boolean;
7
+ httpOnly: boolean;
8
+ expires: number;
9
+ }
10
+ export declare function cookiesToNetscape(cookies: CookieEntry[]): string;
11
+ export declare function hasSIDCookies(cookiesPath: string): boolean;
12
+ /**
13
+ * Check if YouTube session cookies have expired by parsing Netscape cookie
14
+ * file timestamps (field 5, Unix epoch seconds).
15
+ *
16
+ * Returns true if: file missing, no session cookies, or ALL session cookies expired.
17
+ */
18
+ export declare function areCookiesExpired(cookiesPath: string): boolean;
@@ -0,0 +1,78 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ export function cookiesToNetscape(cookies) {
3
+ const lines = [
4
+ "# Netscape HTTP Cookie File",
5
+ "# https://curl.haxx.se/rfc/cookie_spec.html",
6
+ "# This is a generated file! Do not edit.",
7
+ "",
8
+ ];
9
+ for (const c of cookies) {
10
+ const domain = c.domain.startsWith(".") ? c.domain : c.domain;
11
+ const includeSubdomains = c.domain.startsWith(".") ? "TRUE" : "FALSE";
12
+ const secure = c.secure ? "TRUE" : "FALSE";
13
+ const expiry = Math.floor(c.expires);
14
+ lines.push(`${domain}\t${includeSubdomains}\t${c.path}\t${secure}\t${expiry}\t${c.name}\t${c.value}`);
15
+ }
16
+ return lines.join("\n") + "\n";
17
+ }
18
+ export function hasSIDCookies(cookiesPath) {
19
+ if (!existsSync(cookiesPath))
20
+ return false;
21
+ try {
22
+ const content = readFileSync(cookiesPath, "utf8");
23
+ return content.includes("SID") && content.includes(".youtube.com");
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /**
30
+ * Cookie names that indicate a valid YouTube session.
31
+ * If at least one of these is present and not expired, cookies are valid.
32
+ */
33
+ const SESSION_COOKIE_PATTERNS = [
34
+ "SID", "HSID", "SSID", "APISID", "SAPISID",
35
+ "__Secure-1PSID", "__Secure-3PSID",
36
+ "__Secure-1PSIDTS", "__Secure-3PSIDTS",
37
+ "LOGIN_INFO",
38
+ ];
39
+ /**
40
+ * Check if YouTube session cookies have expired by parsing Netscape cookie
41
+ * file timestamps (field 5, Unix epoch seconds).
42
+ *
43
+ * Returns true if: file missing, no session cookies, or ALL session cookies expired.
44
+ */
45
+ export function areCookiesExpired(cookiesPath) {
46
+ if (!existsSync(cookiesPath))
47
+ return true;
48
+ try {
49
+ const content = readFileSync(cookiesPath, "utf8");
50
+ const nowSec = Date.now() / 1000;
51
+ let foundAnySession = false;
52
+ let foundAnyValid = false;
53
+ for (const line of content.split("\n")) {
54
+ if (line.startsWith("#") || !line.trim())
55
+ continue;
56
+ const fields = line.split("\t");
57
+ if (fields.length < 7)
58
+ continue;
59
+ const name = fields[5];
60
+ if (!SESSION_COOKIE_PATTERNS.includes(name))
61
+ continue;
62
+ foundAnySession = true;
63
+ const expiry = Number(fields[4]);
64
+ // expiry 0 = session cookie (no expiration) — treat as valid
65
+ if (expiry === 0 || expiry >= nowSec) {
66
+ foundAnyValid = true;
67
+ break; // At least one valid session cookie — not expired
68
+ }
69
+ }
70
+ // No session cookies at all = need login; all expired = need refresh
71
+ if (!foundAnySession)
72
+ return true;
73
+ return !foundAnyValid;
74
+ }
75
+ catch {
76
+ return true;
77
+ }
78
+ }
@@ -0,0 +1,12 @@
1
+ export interface LauncherSourceOptions {
2
+ packageSpec?: string;
3
+ npmCacheDir?: string;
4
+ }
5
+ export interface LauncherCommand {
6
+ command: string;
7
+ args: string[];
8
+ }
9
+ export declare function buildLauncherCommand(launcherPath?: string): LauncherCommand;
10
+ export declare function isRepairableNpxFailure(stderr: string): boolean;
11
+ export declare function buildLauncherSource(options?: LauncherSourceOptions): string;
12
+ export declare function writeLauncherFile(options?: LauncherSourceOptions): string;
@@ -0,0 +1,82 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { PATHS } from "./config.js";
4
+ const DEFAULT_PACKAGE_SPEC = "@mkterswingman/5mghost-yonder@latest";
5
+ export function buildLauncherCommand(launcherPath = PATHS.launcherJs) {
6
+ return {
7
+ command: "node",
8
+ args: [launcherPath, "serve"],
9
+ };
10
+ }
11
+ export function isRepairableNpxFailure(stderr) {
12
+ const lower = stderr.toLowerCase();
13
+ const referencesNpxDir = lower.includes("_npx/") || lower.includes("_npx\\");
14
+ return referencesNpxDir && (lower.includes("enotempty") || lower.includes("rename"));
15
+ }
16
+ export function buildLauncherSource(options = {}) {
17
+ const packageSpec = options.packageSpec ?? DEFAULT_PACKAGE_SPEC;
18
+ const npmCacheDir = options.npmCacheDir ?? PATHS.npmCacheDir;
19
+ return `#!/usr/bin/env node
20
+ import { existsSync, mkdirSync, renameSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { spawnSync } from "node:child_process";
23
+
24
+ const packageSpec = ${JSON.stringify(packageSpec)};
25
+ const npmCacheDir = ${JSON.stringify(npmCacheDir)};
26
+ const args = process.argv.slice(2);
27
+ const targetArgs = args.length > 0 ? args : ["serve"];
28
+
29
+ function isRepairableNpxFailure(stderr) {
30
+ const lower = stderr.toLowerCase();
31
+ return (lower.includes("_npx/") || lower.includes("_npx\\"))
32
+ && (lower.includes("enotempty") || lower.includes("rename"));
33
+ }
34
+
35
+ function runNpx(subArgs, captureStdErrOnly = false) {
36
+ mkdirSync(npmCacheDir, { recursive: true });
37
+ const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
38
+ return spawnSync(npxBin, ["--yes", packageSpec, ...subArgs], {
39
+ env: { ...process.env, npm_config_cache: npmCacheDir },
40
+ // Why: probe runs before MCP starts; stdout must stay silent or it will corrupt stdio transport.
41
+ stdio: captureStdErrOnly ? ["ignore", "ignore", "pipe"] : "inherit",
42
+ });
43
+ }
44
+
45
+ function rotateNpxDir() {
46
+ const npxDir = join(npmCacheDir, "_npx");
47
+ if (!existsSync(npxDir)) return false;
48
+ const backup = \`\${npxDir}.bad.\${new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14)}\`;
49
+ renameSync(npxDir, backup);
50
+ process.stderr.write(\`[yt-mcp launcher] repaired corrupted npx cache: \${backup}\\n\`);
51
+ return true;
52
+ }
53
+
54
+ function ensurePackageReady() {
55
+ const first = runNpx(["version"], true);
56
+ if ((first.status ?? 1) === 0) return;
57
+
58
+ const stderr = first.stderr ? String(first.stderr) : "";
59
+ if (!isRepairableNpxFailure(stderr) || !rotateNpxDir()) {
60
+ if (stderr) process.stderr.write(stderr);
61
+ process.exit(first.status ?? 1);
62
+ }
63
+
64
+ const second = runNpx(["version"], true);
65
+ const secondStderr = second.stderr ? String(second.stderr) : "";
66
+ if ((second.status ?? 1) !== 0) {
67
+ if (secondStderr) process.stderr.write(secondStderr);
68
+ process.exit(second.status ?? 1);
69
+ }
70
+ }
71
+
72
+ ensurePackageReady();
73
+ const finalRun = runNpx(targetArgs, false);
74
+ process.exit(finalRun.status ?? 0);
75
+ `;
76
+ }
77
+ export function writeLauncherFile(options = {}) {
78
+ mkdirSync(dirname(PATHS.launcherJs), { recursive: true });
79
+ const source = buildLauncherSource(options);
80
+ writeFileSync(PATHS.launcherJs, source, { encoding: "utf8", mode: 0o755 });
81
+ return PATHS.launcherJs;
82
+ }
@@ -0,0 +1,7 @@
1
+ export declare const MCP_REGISTER_TIMEOUT_MS = 5000;
2
+ export type RegistrationFailureKind = "already_exists" | "authentication" | "timeout" | "other";
3
+ export interface RegistrationFailure {
4
+ kind: RegistrationFailureKind;
5
+ output: string;
6
+ }
7
+ export declare function classifyRegistrationFailure(err: unknown): RegistrationFailure;
@@ -0,0 +1,23 @@
1
+ export const MCP_REGISTER_TIMEOUT_MS = 5000;
2
+ function readOutput(err) {
3
+ if (!(err instanceof Error))
4
+ return "";
5
+ const stderr = "stderr" in err ? String(err.stderr ?? "") : "";
6
+ const stdout = "stdout" in err ? String(err.stdout ?? "") : "";
7
+ return `${stderr}${stdout}`;
8
+ }
9
+ export function classifyRegistrationFailure(err) {
10
+ const output = readOutput(err);
11
+ const lower = output.toLowerCase();
12
+ if (lower.includes("already exists")) {
13
+ return { kind: "already_exists", output };
14
+ }
15
+ if (lower.includes("waiting for authentication")) {
16
+ return { kind: "authentication", output };
17
+ }
18
+ if (err instanceof Error &&
19
+ ("code" in err && err.code === "ETIMEDOUT")) {
20
+ return { kind: "timeout", output };
21
+ }
22
+ return { kind: "other", output };
23
+ }
@@ -0,0 +1,5 @@
1
+ export declare function resolveVideoInput(input: string): string | null;
2
+ export declare function normalizeVideoInputs(inputs: string[]): {
3
+ resolvedIds: string[];
4
+ invalidInputs: string[];
5
+ };
@@ -0,0 +1,55 @@
1
+ const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
2
+ function toValidVideoId(value) {
3
+ const v = value.trim();
4
+ return VIDEO_ID_RE.test(v) ? v : null;
5
+ }
6
+ function extractFromUrl(raw) {
7
+ let url;
8
+ try {
9
+ url = new URL(raw.trim());
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ const host = url.hostname.toLowerCase();
15
+ const normalizedHost = host.startsWith("www.") ? host.slice(4) : host;
16
+ if (normalizedHost === "youtu.be") {
17
+ const id = url.pathname.split("/").filter(Boolean)[0] ?? "";
18
+ return toValidVideoId(id);
19
+ }
20
+ if (normalizedHost === "youtube.com" || normalizedHost === "m.youtube.com") {
21
+ if (url.pathname === "/watch") {
22
+ return toValidVideoId(url.searchParams.get("v") ?? "");
23
+ }
24
+ const parts = url.pathname.split("/").filter(Boolean);
25
+ if (parts.length >= 2 && ["shorts", "live", "embed"].includes(parts[0])) {
26
+ return toValidVideoId(parts[1]);
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ export function resolveVideoInput(input) {
32
+ const candidate = input.trim();
33
+ if (!candidate) {
34
+ return null;
35
+ }
36
+ const directId = toValidVideoId(candidate);
37
+ return directId ?? extractFromUrl(candidate);
38
+ }
39
+ export function normalizeVideoInputs(inputs) {
40
+ const resolvedIds = [];
41
+ const invalidInputs = [];
42
+ const seen = new Set();
43
+ for (const raw of inputs) {
44
+ const parsedId = resolveVideoInput(raw);
45
+ if (!parsedId) {
46
+ invalidInputs.push(raw);
47
+ continue;
48
+ }
49
+ if (!seen.has(parsedId)) {
50
+ seen.add(parsedId);
51
+ resolvedIds.push(parsedId);
52
+ }
53
+ }
54
+ return { resolvedIds, invalidInputs };
55
+ }
@@ -0,0 +1,7 @@
1
+ export interface YtDlpResult {
2
+ exitCode: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ durationMs: number;
6
+ }
7
+ export declare function runYtDlp(args: string[], timeoutMs?: number): Promise<YtDlpResult>;
@@ -0,0 +1,52 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { PATHS } from "./config.js";
4
+ import { getYtDlpPath } from "./ytdlpPath.js";
5
+ export function runYtDlp(args, timeoutMs = 45_000) {
6
+ return new Promise((resolve, reject) => {
7
+ const start = Date.now();
8
+ const finalArgs = ["--force-ipv4", "--no-warnings", ...args];
9
+ if (existsSync(PATHS.cookiesTxt)) {
10
+ finalArgs.push("--cookies", PATHS.cookiesTxt);
11
+ }
12
+ const proc = spawn(getYtDlpPath(), finalArgs, {
13
+ stdio: ["ignore", "pipe", "pipe"],
14
+ });
15
+ const stdoutChunks = [];
16
+ const stderrChunks = [];
17
+ proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
18
+ proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
19
+ let settled = false;
20
+ const timer = setTimeout(() => {
21
+ if (!settled) {
22
+ settled = true;
23
+ proc.kill("SIGKILL");
24
+ resolve({
25
+ exitCode: -1,
26
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
27
+ stderr: `yt-dlp timed out after ${timeoutMs}ms`,
28
+ durationMs: Date.now() - start,
29
+ });
30
+ }
31
+ }, timeoutMs);
32
+ proc.on("close", (code) => {
33
+ clearTimeout(timer);
34
+ if (!settled) {
35
+ settled = true;
36
+ resolve({
37
+ exitCode: code ?? 1,
38
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
39
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
40
+ durationMs: Date.now() - start,
41
+ });
42
+ }
43
+ });
44
+ proc.on("error", (err) => {
45
+ clearTimeout(timer);
46
+ if (!settled) {
47
+ settled = true;
48
+ reject(err);
49
+ }
50
+ });
51
+ });
52
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Resolve the yt-dlp binary path.
3
+ *
4
+ * Priority:
5
+ * 1. YT_DLP_PATH env var (explicit override)
6
+ * 2. Bundled binary at <pkg>/bin/yt-dlp (downloaded by postinstall)
7
+ * 3. "yt-dlp" — fall back to system PATH
8
+ */
9
+ /**
10
+ * Returns the absolute path to the yt-dlp binary, or the bare command name
11
+ * "yt-dlp" if only available on system PATH.
12
+ */
13
+ export declare function getYtDlpPath(): string;
14
+ export interface YtDlpVersionInfo {
15
+ version: string;
16
+ source: "bundled" | "system" | "env";
17
+ }
18
+ /**
19
+ * Get the version string and source of the resolved yt-dlp binary.
20
+ * Returns null if yt-dlp is not available at all.
21
+ *
22
+ * Uses a 30s timeout because macOS Gatekeeper performs a network verification
23
+ * on first run of ad-hoc signed binaries (~15-20s). The result is cached by
24
+ * the OS, so subsequent calls return instantly.
25
+ */
26
+ export declare function getYtDlpVersion(binPath?: string): YtDlpVersionInfo | null;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Resolve the yt-dlp binary path.
3
+ *
4
+ * Priority:
5
+ * 1. YT_DLP_PATH env var (explicit override)
6
+ * 2. Bundled binary at <pkg>/bin/yt-dlp (downloaded by postinstall)
7
+ * 3. "yt-dlp" — fall back to system PATH
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ // From dist/utils/ → package root is ../../
15
+ const pkgRoot = join(__dirname, "..", "..");
16
+ function bundledPath() {
17
+ const name = process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp";
18
+ return join(pkgRoot, "bin", name);
19
+ }
20
+ /**
21
+ * Returns the absolute path to the yt-dlp binary, or the bare command name
22
+ * "yt-dlp" if only available on system PATH.
23
+ */
24
+ export function getYtDlpPath() {
25
+ // 1. Explicit env override
26
+ const envPath = process.env.YT_DLP_PATH;
27
+ if (envPath && existsSync(envPath))
28
+ return envPath;
29
+ // 2. Bundled binary
30
+ const bundled = bundledPath();
31
+ if (existsSync(bundled))
32
+ return bundled;
33
+ // 3. System PATH
34
+ return "yt-dlp";
35
+ }
36
+ /**
37
+ * Get the version string and source of the resolved yt-dlp binary.
38
+ * Returns null if yt-dlp is not available at all.
39
+ *
40
+ * Uses a 30s timeout because macOS Gatekeeper performs a network verification
41
+ * on first run of ad-hoc signed binaries (~15-20s). The result is cached by
42
+ * the OS, so subsequent calls return instantly.
43
+ */
44
+ export function getYtDlpVersion(binPath) {
45
+ const resolved = binPath ?? getYtDlpPath();
46
+ let source;
47
+ if (process.env.YT_DLP_PATH && resolved === process.env.YT_DLP_PATH) {
48
+ source = "env";
49
+ }
50
+ else if (resolved === bundledPath()) {
51
+ source = "bundled";
52
+ }
53
+ else {
54
+ source = "system";
55
+ }
56
+ try {
57
+ // macOS Gatekeeper: try clearing quarantine xattr before first execution.
58
+ // This helps in some environments; in others the OS re-applies it.
59
+ if (process.platform === "darwin" && resolved !== "yt-dlp") {
60
+ try {
61
+ execFileSync("xattr", ["-cr", resolved], { stdio: "ignore" });
62
+ }
63
+ catch {
64
+ // non-fatal
65
+ }
66
+ }
67
+ const ver = execFileSync(resolved, ["--version"], {
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ timeout: 30_000,
70
+ })
71
+ .toString()
72
+ .trim();
73
+ return { version: ver, source };
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@mkterswingman/5mghost-yonder",
3
+ "version": "0.0.1",
4
+ "description": "Internal MCP client with local data tools and remote API proxy",
5
+ "type": "module",
6
+ "bin": {
7
+ "yt-mcp": "dist/cli/index.js"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/server.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json",
14
+ "dev": "tsx src/cli/index.ts",
15
+ "start": "node dist/cli/index.js",
16
+ "postinstall": "node scripts/download-ytdlp.mjs"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.28.0",
20
+ "playwright": "^1.58.0",
21
+ "zod": "^4.3.6"
22
+ },
23
+ "optionalDependencies": {
24
+ "puppeteer-core": "^24.0.0"
25
+ },
26
+ "keywords": [
27
+ "mkterswingman",
28
+ "ghost",
29
+ "cli"
30
+ ],
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "files": [
36
+ "dist/",
37
+ "scripts/download-ytdlp.mjs",
38
+ "README.md"
39
+ ],
40
+ "devDependencies": {
41
+ "typescript": "^5.9.3"
42
+ }
43
+ }