@mangomagic/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/bin/mangomagic.mjs +8 -0
- package/package.json +28 -0
- package/src/api.mjs +27 -0
- package/src/auth/device-flow.mjs +73 -0
- package/src/auth/token-store.mjs +30 -0
- package/src/config.mjs +6 -0
- package/src/index.mjs +98 -0
- package/src/mcp/server.mjs +78 -0
- package/src/ui/splash.mjs +243 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @mangomagic/cli
|
|
2
|
+
|
|
3
|
+
The MangoMagic command-line companion.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx -y @mangomagic/cli login
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
You'll get a short code, your browser will open, you approve the device, and
|
|
10
|
+
the CLI plays the MangoMagic splash to confirm you're in. The token is cached
|
|
11
|
+
at `~/.mangomagic/credentials.json` (mode 0600) so the next runs skip auth.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
| Command | What it does |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `mangomagic login` | Browser-based device-code sign-in + splash. |
|
|
18
|
+
| `mangomagic logout` | Forget the cached token. |
|
|
19
|
+
| `mangomagic whoami` | Show the cached identity. |
|
|
20
|
+
| `mangomagic splash` | Replay the splash animation. `--static`, `--loop` supported. |
|
|
21
|
+
| `mangomagic mcp` | Run as a stdio MCP server. Wire into Claude Desktop / Cursor. |
|
|
22
|
+
| `mangomagic mcp-config` | Print MCP client JSON for copy-paste. |
|
|
23
|
+
|
|
24
|
+
## Use as an MCP server
|
|
25
|
+
|
|
26
|
+
Add to Claude Desktop's `claude_desktop_config.json` (or Cursor's MCP settings):
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"mangomagic": {
|
|
32
|
+
"command": "npx",
|
|
33
|
+
"args": ["-y", "@mangomagic/cli", "mcp"]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The MCP server uses the same token cached by `mangomagic login`, so make sure
|
|
40
|
+
you sign in first.
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
Node 18 or later. The splash uses true-color ANSI; on terminals without it,
|
|
45
|
+
the CLI falls back to a plain banner.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MangoMagic CLI entrypoint. Pure-Node, ESM, zero deps (MCP server uses
|
|
3
|
+
// @modelcontextprotocol/sdk loaded lazily on demand).
|
|
4
|
+
import { run } from "../src/index.mjs";
|
|
5
|
+
run(process.argv.slice(2)).catch((err) => {
|
|
6
|
+
process.stderr.write(`\nmangomagic: ${err?.message ?? err}\n`);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mangomagic/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MangoMagic CLI — sign in, manage episodes, and expose MangoMagic to MCP clients (Claude Desktop, Cursor).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mangomagic": "bin/mangomagic.mjs"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node ./bin/mangomagic.mjs"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.0.4"
|
|
25
|
+
},
|
|
26
|
+
"keywords": ["mangomagic", "cli", "mcp", "podcast"],
|
|
27
|
+
"license": "MIT"
|
|
28
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { FN_BASE, SUPABASE_ANON_KEY } from "./config.mjs";
|
|
2
|
+
import { loadToken, clearToken } from "./auth/token-store.mjs";
|
|
3
|
+
|
|
4
|
+
// Helper: call any Supabase edge function with the user's PAT.
|
|
5
|
+
export async function apiCall(path, { method = "POST", body, query } = {}) {
|
|
6
|
+
const creds = loadToken();
|
|
7
|
+
if (!creds) throw new Error("Not signed in. Run `mangomagic login` first.");
|
|
8
|
+
const qs = query ? "?" + new URLSearchParams(query).toString() : "";
|
|
9
|
+
const res = await fetch(`${FN_BASE}/${path}${qs}`, {
|
|
10
|
+
method,
|
|
11
|
+
headers: {
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
"Authorization": `Bearer ${creds.token}`,
|
|
14
|
+
"apikey": SUPABASE_ANON_KEY,
|
|
15
|
+
"x-mangomagic-cli": "1",
|
|
16
|
+
},
|
|
17
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
18
|
+
});
|
|
19
|
+
const text = await res.text();
|
|
20
|
+
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
|
21
|
+
if (res.status === 401) {
|
|
22
|
+
clearToken();
|
|
23
|
+
throw new Error("Your CLI token was rejected. Run `mangomagic login` again.");
|
|
24
|
+
}
|
|
25
|
+
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
|
|
26
|
+
return json;
|
|
27
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Browser-device OAuth-style flow against the cli-device-authorize / cli-device-poll edge fns.
|
|
2
|
+
import { FN_BASE, SUPABASE_ANON_KEY } from "../config.mjs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { hostname, platform } from "node:os";
|
|
5
|
+
|
|
6
|
+
function openBrowser(url) {
|
|
7
|
+
const cmd = process.platform === "darwin" ? "open"
|
|
8
|
+
: process.platform === "win32" ? "cmd"
|
|
9
|
+
: "xdg-open";
|
|
10
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
11
|
+
try {
|
|
12
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
13
|
+
child.unref();
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function call(path, body) {
|
|
21
|
+
const res = await fetch(`${FN_BASE}/${path}`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
"Authorization": `Bearer ${SUPABASE_ANON_KEY}`,
|
|
26
|
+
"apikey": SUPABASE_ANON_KEY,
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify(body ?? {}),
|
|
29
|
+
});
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
let json; try { json = JSON.parse(text); } catch { json = { error: text }; }
|
|
32
|
+
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
|
|
33
|
+
return json;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
37
|
+
|
|
38
|
+
export async function deviceLogin({ onCode, onWaiting, openInBrowser = true } = {}) {
|
|
39
|
+
const client_name = `${hostname()} (${platform()})`;
|
|
40
|
+
const start = await call("cli-device-authorize", { client_name });
|
|
41
|
+
|
|
42
|
+
onCode?.(start);
|
|
43
|
+
if (openInBrowser) openBrowser(start.verification_uri_complete);
|
|
44
|
+
|
|
45
|
+
const deadline = Date.now() + (start.expires_in ?? 600) * 1000;
|
|
46
|
+
let interval = (start.interval ?? 2) * 1000;
|
|
47
|
+
|
|
48
|
+
while (Date.now() < deadline) {
|
|
49
|
+
await sleep(interval);
|
|
50
|
+
onWaiting?.();
|
|
51
|
+
let poll;
|
|
52
|
+
try {
|
|
53
|
+
poll = await call("cli-device-poll", { device_code: start.device_code });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// transient — back off slightly
|
|
56
|
+
interval = Math.min(interval + 1000, 8000);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (poll.status === "approved") {
|
|
60
|
+
return {
|
|
61
|
+
token: poll.token,
|
|
62
|
+
userId: poll.user_id,
|
|
63
|
+
patId: poll.pat_id,
|
|
64
|
+
approvedAt: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (poll.status === "expired" || poll.status === "denied" || poll.status === "unknown") {
|
|
68
|
+
throw new Error(`device flow ${poll.status}. Run \`mangomagic login\` again.`);
|
|
69
|
+
}
|
|
70
|
+
// pending → keep polling
|
|
71
|
+
}
|
|
72
|
+
throw new Error("device flow timed out. Run `mangomagic login` again.");
|
|
73
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Token cache at ~/.mangomagic/credentials.json (mode 0600).
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, chmodSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const dir = join(homedir(), ".mangomagic");
|
|
7
|
+
const file = join(dir, "credentials.json");
|
|
8
|
+
|
|
9
|
+
export function loadToken() {
|
|
10
|
+
if (!existsSync(file)) return null;
|
|
11
|
+
try {
|
|
12
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
13
|
+
if (raw && typeof raw.token === "string") return raw;
|
|
14
|
+
return null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function saveToken(payload) {
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
writeFileSync(file, JSON.stringify(payload, null, 2));
|
|
23
|
+
try { chmodSync(file, 0o600); } catch { /* windows */ }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clearToken() {
|
|
27
|
+
if (existsSync(file)) unlinkSync(file);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function credentialsPath() { return file; }
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Public config baked into the CLI. Both values are publishable (anon key + project ref).
|
|
2
|
+
export const SUPABASE_URL = "https://wrjrteflzlknniuluyxh.supabase.co";
|
|
3
|
+
export const SUPABASE_ANON_KEY =
|
|
4
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndyanJ0ZWZsemxrbm5pdWx1eXhoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjY1OTQ1MTQsImV4cCI6MjA4MjE3MDUxNH0.pFYXrSFjeHGXEAui54hoQbU0JQPaarnjAy-GeDIk1qM";
|
|
5
|
+
export const APP_ORIGIN = "https://mangomagic.live";
|
|
6
|
+
export const FN_BASE = `${SUPABASE_URL}/functions/v1`;
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { deviceLogin } from "./auth/device-flow.mjs";
|
|
2
|
+
import { loadToken, saveToken, clearToken, credentialsPath } from "./auth/token-store.mjs";
|
|
3
|
+
import { playSplash } from "./ui/splash.mjs";
|
|
4
|
+
|
|
5
|
+
const GOLD = "\x1b[38;2;241;171;28m";
|
|
6
|
+
const DIM = "\x1b[2m";
|
|
7
|
+
const BOLD = "\x1b[1m";
|
|
8
|
+
const RESET = "\x1b[0m";
|
|
9
|
+
|
|
10
|
+
function help() {
|
|
11
|
+
process.stdout.write(`
|
|
12
|
+
${BOLD}mangomagic${RESET} ${DIM}— sign into MangoMagic and bring your account into your terminal.${RESET}
|
|
13
|
+
|
|
14
|
+
${GOLD}mangomagic login${RESET} Authorize this terminal (opens your browser).
|
|
15
|
+
${GOLD}mangomagic logout${RESET} Forget the token cached on this machine.
|
|
16
|
+
${GOLD}mangomagic whoami${RESET} Show the cached identity.
|
|
17
|
+
${GOLD}mangomagic splash${RESET} Play the MangoMagic splash animation.
|
|
18
|
+
${GOLD}mangomagic mcp${RESET} Run as a stdio MCP server (for Claude Desktop / Cursor).
|
|
19
|
+
${GOLD}mangomagic mcp-config${RESET} Print MCP client config for copy-paste.
|
|
20
|
+
${GOLD}mangomagic help${RESET} This message.
|
|
21
|
+
|
|
22
|
+
Token cache: ${credentialsPath()}
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function login() {
|
|
27
|
+
if (loadToken()) {
|
|
28
|
+
process.stdout.write(`${DIM}You're already signed in. Run \`mangomagic logout\` first to switch accounts.${RESET}\n`);
|
|
29
|
+
await playSplash({ greetingLine: "Welcome back to MangoMagic." });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await deviceLogin({
|
|
34
|
+
onCode: (start) => {
|
|
35
|
+
process.stdout.write(`
|
|
36
|
+
${BOLD}Sign in to MangoMagic${RESET}
|
|
37
|
+
|
|
38
|
+
Visit: ${GOLD}${start.verification_uri_complete}${RESET}
|
|
39
|
+
Code: ${BOLD}${start.user_code}${RESET}
|
|
40
|
+
|
|
41
|
+
${DIM}Waiting for approval...${RESET}
|
|
42
|
+
`);
|
|
43
|
+
},
|
|
44
|
+
onWaiting: () => { process.stdout.write("."); },
|
|
45
|
+
}).then(async (creds) => {
|
|
46
|
+
saveToken(creds);
|
|
47
|
+
process.stdout.write("\n\n");
|
|
48
|
+
await playSplash({ greetingLine: "You're in. Welcome to MangoMagic." });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function logout() {
|
|
53
|
+
clearToken();
|
|
54
|
+
process.stdout.write("Signed out.\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function whoami() {
|
|
58
|
+
const t = loadToken();
|
|
59
|
+
if (!t) { process.stdout.write("Not signed in.\n"); return; }
|
|
60
|
+
process.stdout.write(`Signed in (user ${t.userId ?? "?"}), approved ${t.approvedAt}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function mcpServer() {
|
|
64
|
+
// Lazy-load so users who only run `login` don't pay the import cost.
|
|
65
|
+
const { startMcpServer } = await import("./mcp/server.mjs");
|
|
66
|
+
await startMcpServer();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mcpConfig() {
|
|
70
|
+
const cfg = {
|
|
71
|
+
mcpServers: {
|
|
72
|
+
mangomagic: {
|
|
73
|
+
command: "npx",
|
|
74
|
+
args: ["-y", "@mangomagic/cli", "mcp"],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
process.stdout.write(JSON.stringify(cfg, null, 2) + "\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function run(argv) {
|
|
82
|
+
const cmd = argv[0] ?? (loadToken() ? "splash" : "login");
|
|
83
|
+
switch (cmd) {
|
|
84
|
+
case "login": return login();
|
|
85
|
+
case "logout": return logout();
|
|
86
|
+
case "whoami": return whoami();
|
|
87
|
+
case "splash": return playSplash({ mode: argv.includes("--loop") ? "loop" : argv.includes("--static") ? "static" : "anim" });
|
|
88
|
+
case "mcp": return mcpServer();
|
|
89
|
+
case "mcp-config": return mcpConfig();
|
|
90
|
+
case "help":
|
|
91
|
+
case "--help":
|
|
92
|
+
case "-h": return help();
|
|
93
|
+
default:
|
|
94
|
+
process.stderr.write(`Unknown command: ${cmd}\n`);
|
|
95
|
+
help();
|
|
96
|
+
process.exit(2);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Stdio MCP server exposing a small set of MangoMagic tools to clients like
|
|
2
|
+
// Claude Desktop and Cursor. All tools call existing edge functions using the
|
|
3
|
+
// user's CLI PAT cached at ~/.mangomagic/credentials.json.
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import {
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
ListToolsRequestSchema,
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { apiCall } from "../api.mjs";
|
|
11
|
+
import { loadToken } from "../auth/token-store.mjs";
|
|
12
|
+
|
|
13
|
+
const TOOLS = [
|
|
14
|
+
{
|
|
15
|
+
name: "list_episodes",
|
|
16
|
+
description: "List the signed-in user's MangoMagic episodes (most recent first).",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: { limit: { type: "integer", minimum: 1, maximum: 50, default: 10 } },
|
|
20
|
+
},
|
|
21
|
+
handler: async ({ limit = 10 }) => apiCall("cli-list-episodes", { body: { limit } }),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "get_episode",
|
|
25
|
+
description: "Get full details for one episode (transcript, summary, takeaways) by ID or slug.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: { episode: { type: "string", description: "Episode id or slug" } },
|
|
29
|
+
required: ["episode"],
|
|
30
|
+
},
|
|
31
|
+
handler: async ({ episode }) => apiCall("cli-get-episode", { body: { episode } }),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "search_episodes",
|
|
35
|
+
description: "Full-text search across the signed-in user's episodes.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: { query: { type: "string" } },
|
|
39
|
+
required: ["query"],
|
|
40
|
+
},
|
|
41
|
+
handler: async ({ query }) => apiCall("cli-search-episodes", { body: { query } }),
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export async function startMcpServer() {
|
|
46
|
+
if (!loadToken()) {
|
|
47
|
+
process.stderr.write("MangoMagic MCP: not signed in. Run `npx mangomagic login` first.\n");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const server = new Server(
|
|
52
|
+
{ name: "mangomagic", version: "0.1.0" },
|
|
53
|
+
{ capabilities: { tools: {} } },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
57
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
61
|
+
const tool = TOOLS.find(t => t.name === req.params.name);
|
|
62
|
+
if (!tool) {
|
|
63
|
+
return { isError: true, content: [{ type: "text", text: `Unknown tool ${req.params.name}` }] };
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const result = await tool.handler(req.params.arguments ?? {});
|
|
67
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return {
|
|
70
|
+
isError: true,
|
|
71
|
+
content: [{ type: "text", text: err?.message ?? String(err) }],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const transport = new StdioServerTransport();
|
|
77
|
+
await server.connect(transport);
|
|
78
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// TypeScript-ish JS port of mango_banner.py. Pure-Node, zero deps.
|
|
2
|
+
// Renders to process.stdout. Falls back to a plain banner if the terminal
|
|
3
|
+
// can't do truecolor or isn't a TTY.
|
|
4
|
+
|
|
5
|
+
const ICON = [
|
|
6
|
+
"⣀ ",
|
|
7
|
+
"⣿⣷⣦⣄⡀ ",
|
|
8
|
+
"⣿⣿⣿⣿⣿⣷⣦⣄⡀ ",
|
|
9
|
+
"⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄",
|
|
10
|
+
"⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⠁",
|
|
11
|
+
"⣿⣿⣿⣿⣿⠿⠛⠁ ",
|
|
12
|
+
"⠿⠛⠁ ",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const WORDMARK = [
|
|
16
|
+
"███╗ ███╗ █████╗ ███╗ ██╗ ██████╗ ██████╗ ",
|
|
17
|
+
"████╗ ████║██╔══██╗████╗ ██║██╔════╝ ██╔═══██╗",
|
|
18
|
+
"██╔████╔██║███████║██╔██╗ ██║██║ ███╗██║ ██║",
|
|
19
|
+
"██║╚██╔╝██║██╔══██║██║╚██╗██║██║ ██║██║ ██║",
|
|
20
|
+
"██║ ╚═╝ ██║██║ ██║██║ ╚████║╚██████╔╝╚██████╔╝",
|
|
21
|
+
"╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const SUBMARK = "M A G I C";
|
|
25
|
+
const TAGLINE = "Amplifying Human Signal :: by ManyMangoes";
|
|
26
|
+
|
|
27
|
+
const ICON_X = 2;
|
|
28
|
+
const ICON_GAP = 3;
|
|
29
|
+
const WORD_X = ICON_X + 11 + ICON_GAP;
|
|
30
|
+
|
|
31
|
+
const GOLD = [241, 171, 28];
|
|
32
|
+
const HOT = [255, 232, 150];
|
|
33
|
+
const DEEP = [140, 95, 14];
|
|
34
|
+
const WHITEC = [255, 255, 255];
|
|
35
|
+
const DIMC = [110, 78, 12];
|
|
36
|
+
|
|
37
|
+
const RESET = "\x1b[0m";
|
|
38
|
+
|
|
39
|
+
const rgb = (c) => `\x1b[38;2;${c[0]};${c[1]};${c[2]}m`;
|
|
40
|
+
const lerp = (a, b, t) => [
|
|
41
|
+
Math.round(a[0] + (b[0] - a[0]) * t),
|
|
42
|
+
Math.round(a[1] + (b[1] - a[1]) * t),
|
|
43
|
+
Math.round(a[2] + (b[2] - a[2]) * t),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Chars use codepoints, not byte length.
|
|
47
|
+
const chars = (s) => Array.from(s);
|
|
48
|
+
|
|
49
|
+
const TAU = Math.PI * 2;
|
|
50
|
+
const rand = (a, b) => a + Math.random() * (b - a);
|
|
51
|
+
const randInt = (a, b) => Math.floor(rand(a, b + 1));
|
|
52
|
+
|
|
53
|
+
function supportsColor() {
|
|
54
|
+
if (process.env.NO_COLOR) return false;
|
|
55
|
+
if (!process.stdout.isTTY) return false;
|
|
56
|
+
if (process.env.TERM === "dumb") return false;
|
|
57
|
+
// truecolor support
|
|
58
|
+
return /(?:truecolor|24bit)/i.test(process.env.COLORTERM ?? "") || true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class Star {
|
|
62
|
+
constructor(w, h) {
|
|
63
|
+
this.x = randInt(0, w - 1);
|
|
64
|
+
this.y = randInt(0, h - 1);
|
|
65
|
+
this.depth = Math.random();
|
|
66
|
+
this.phase = rand(0, TAU);
|
|
67
|
+
this.ch = "✦✧*+·.".charAt(randInt(0, 5));
|
|
68
|
+
}
|
|
69
|
+
colour(frame) {
|
|
70
|
+
const tw = 0.5 + 0.5 * Math.sin(frame * 0.4 + this.phase);
|
|
71
|
+
const base = lerp(DIMC, WHITEC, this.depth);
|
|
72
|
+
return [lerp(DEEP, base, tw), tw > 0.35];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class Shooter {
|
|
77
|
+
constructor(w, h) {
|
|
78
|
+
this.x = randInt(Math.floor(w / 2), w - 1);
|
|
79
|
+
this.y = randInt(0, Math.max(1, Math.floor(h / 2)));
|
|
80
|
+
this.life = 0;
|
|
81
|
+
this.len = randInt(4, 7);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shimmer(col, row, frame, lit = false) {
|
|
86
|
+
const band = (frame * 1.8) % 60;
|
|
87
|
+
const d = Math.abs((col + row * 1.3) - band);
|
|
88
|
+
if (lit) {
|
|
89
|
+
const pulse = 0.5 + 0.5 * Math.sin(frame * 0.5);
|
|
90
|
+
return lerp(GOLD, HOT, 0.4 + 0.6 * pulse);
|
|
91
|
+
}
|
|
92
|
+
if (d < 2.5) return lerp(GOLD, HOT, 1 - d / 2.5);
|
|
93
|
+
if (d < 6) return lerp(HOT, GOLD, (d - 2.5) / 3.5);
|
|
94
|
+
return GOLD;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function layout() {
|
|
98
|
+
const width = Math.min(process.stdout.columns || 80, 80);
|
|
99
|
+
const iconH = ICON.length, wordH = WORDMARK.length;
|
|
100
|
+
const blockH = Math.max(iconH, wordH + 1);
|
|
101
|
+
const height = blockH + 5;
|
|
102
|
+
const top = 2;
|
|
103
|
+
const iconTop = top + Math.floor((blockH - iconH) / 2);
|
|
104
|
+
const wordTop = top + Math.floor((blockH - (wordH + 1)) / 2);
|
|
105
|
+
return { width, height, iconTop, wordTop };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function inLogo(col, row, iconTop, wordTop) {
|
|
109
|
+
if (row >= iconTop && row < iconTop + ICON.length && col >= ICON_X && col < ICON_X + 12) return true;
|
|
110
|
+
if (row >= wordTop && row < wordTop + WORDMARK.length + 1 && col >= WORD_X && col < WORD_X + 48) return true;
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function render(width, height, iconTop, wordTop, stars, shooters, frame) {
|
|
115
|
+
const grid = Array.from({ length: height }, () => new Array(width).fill(" "));
|
|
116
|
+
const colr = Array.from({ length: height }, () => new Array(width).fill(null));
|
|
117
|
+
|
|
118
|
+
for (const s of stars) {
|
|
119
|
+
if (inLogo(s.x, s.y, iconTop, wordTop)) continue;
|
|
120
|
+
const [c, on] = s.colour(frame);
|
|
121
|
+
if (on) { grid[s.y][s.x] = s.ch; colr[s.y][s.x] = c; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const sh of shooters) {
|
|
125
|
+
for (let k = 0; k < sh.len; k++) {
|
|
126
|
+
const x = sh.x - k, y = sh.y + Math.floor(k / 2);
|
|
127
|
+
if (x >= 0 && x < width && y >= 0 && y < height && !inLogo(x, y, iconTop, wordTop)) {
|
|
128
|
+
const t = 1 - k / sh.len;
|
|
129
|
+
grid[y][x] = k === 0 ? "✦" : (k % 2 === 0 ? "─" : "╲");
|
|
130
|
+
colr[y][x] = lerp(GOLD, WHITEC, t);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (let r = 0; r < ICON.length; r++) {
|
|
136
|
+
const line = chars(ICON[r]);
|
|
137
|
+
const ry = iconTop + r;
|
|
138
|
+
const lastNonSpace = line.join("").trimEnd().length;
|
|
139
|
+
for (let c = 0; c < line.length; c++) {
|
|
140
|
+
const ch = line[c];
|
|
141
|
+
if (ch === " ") continue;
|
|
142
|
+
const cx = ICON_X + c;
|
|
143
|
+
const lit = (c >= lastNonSpace - 3) && (r === 3 || r === 4);
|
|
144
|
+
grid[ry][cx] = ch;
|
|
145
|
+
colr[ry][cx] = shimmer(cx, ry, frame, lit);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (let r = 0; r < WORDMARK.length; r++) {
|
|
150
|
+
const line = chars(WORDMARK[r]);
|
|
151
|
+
const ry = wordTop + r;
|
|
152
|
+
for (let c = 0; c < line.length; c++) {
|
|
153
|
+
const ch = line[c];
|
|
154
|
+
if (ch === " ") continue;
|
|
155
|
+
const cx = WORD_X + c;
|
|
156
|
+
grid[ry][cx] = ch;
|
|
157
|
+
colr[ry][cx] = shimmer(cx, ry, frame);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sy = wordTop + WORDMARK.length;
|
|
162
|
+
const sx = WORD_X + 30;
|
|
163
|
+
for (let c = 0; c < SUBMARK.length; c++) {
|
|
164
|
+
const ch = SUBMARK[c];
|
|
165
|
+
if (ch === " ") continue;
|
|
166
|
+
if (sx + c < width) {
|
|
167
|
+
grid[sy][sx + c] = ch;
|
|
168
|
+
colr[sy][sx + c] = lerp(GOLD, DEEP, 0.3);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const lines = [];
|
|
173
|
+
for (let y = 0; y < height; y++) {
|
|
174
|
+
const buf = [];
|
|
175
|
+
let last = null;
|
|
176
|
+
for (let x = 0; x < width; x++) {
|
|
177
|
+
const ch = grid[y][x];
|
|
178
|
+
if (ch === " ") { buf.push(" "); last = null; continue; }
|
|
179
|
+
const c = colr[y][x] ?? GOLD;
|
|
180
|
+
if (!last || c[0] !== last[0] || c[1] !== last[1] || c[2] !== last[2]) {
|
|
181
|
+
buf.push(rgb(c));
|
|
182
|
+
last = c;
|
|
183
|
+
}
|
|
184
|
+
buf.push(ch);
|
|
185
|
+
}
|
|
186
|
+
buf.push(RESET);
|
|
187
|
+
lines.push(buf.join(""));
|
|
188
|
+
}
|
|
189
|
+
return lines.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
193
|
+
|
|
194
|
+
export async function playSplash({ mode = "anim", greetingLine = null } = {}) {
|
|
195
|
+
if (!supportsColor()) {
|
|
196
|
+
for (const l of WORDMARK) process.stdout.write(l + "\n");
|
|
197
|
+
process.stdout.write("M A G I C\n");
|
|
198
|
+
process.stdout.write(TAGLINE + "\n");
|
|
199
|
+
if (greetingLine) process.stdout.write("\n" + greetingLine + "\n");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const { width, height, iconTop, wordTop } = layout();
|
|
204
|
+
const stars = Array.from(
|
|
205
|
+
{ length: Math.max(20, Math.floor((width * height) / 26)) },
|
|
206
|
+
() => new Star(width, height),
|
|
207
|
+
);
|
|
208
|
+
let shooters = [];
|
|
209
|
+
const tag = ` ${rgb(lerp(GOLD, DEEP, 0.25))}${TAGLINE}${RESET}`;
|
|
210
|
+
const greet = greetingLine ? `\n ${rgb(lerp(GOLD, HOT, 0.5))}${greetingLine}${RESET}\n` : "";
|
|
211
|
+
|
|
212
|
+
const renderSettled = () => render(width, height, iconTop, wordTop, stars, [], 7);
|
|
213
|
+
|
|
214
|
+
if (mode === "static") {
|
|
215
|
+
process.stdout.write(renderSettled() + "\n\n" + tag + greet + "\n");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const fps = 14;
|
|
220
|
+
const total = mode === "loop" ? Number.MAX_SAFE_INTEGER : Math.floor(2.8 * fps);
|
|
221
|
+
|
|
222
|
+
process.stdout.write("\x1b[?25l"); // hide cursor
|
|
223
|
+
let interrupted = false;
|
|
224
|
+
const onSig = () => { interrupted = true; };
|
|
225
|
+
process.on("SIGINT", onSig);
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
for (let f = 0; f < total && !interrupted; f++) {
|
|
229
|
+
if (Math.random() < 0.08 && shooters.length < 2) shooters.push(new Shooter(width, height));
|
|
230
|
+
for (const sh of shooters) sh.life += 1;
|
|
231
|
+
shooters = shooters.filter(s => s.life < 6);
|
|
232
|
+
process.stdout.write("\x1b[H\x1b[2J");
|
|
233
|
+
process.stdout.write(render(width, height, iconTop, wordTop, stars, shooters, f));
|
|
234
|
+
process.stdout.write("\n\n" + tag + greet + "\n");
|
|
235
|
+
await sleep(1000 / fps);
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
process.off("SIGINT", onSig);
|
|
239
|
+
process.stdout.write("\x1b[H\x1b[2J");
|
|
240
|
+
process.stdout.write(renderSettled());
|
|
241
|
+
process.stdout.write("\n\n" + tag + greet + "\n\x1b[?25h");
|
|
242
|
+
}
|
|
243
|
+
}
|