@three333/termbuddy 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.
@@ -0,0 +1,52 @@
1
+ import {useEffect} from 'react';
2
+ import dgram from 'node:dgram';
3
+ import {UDP_PORT, DISCOVERY_VERSION} from '../constants.js';
4
+ import type {DiscoveryPacket} from '../protocol.js';
5
+ import {getBroadcastTargets} from '../net/index.js';
6
+
7
+ type Options =
8
+ | {enabled: false}
9
+ | {enabled: true; hostName: string; roomName: string; tcpPort?: number | null; intervalMs?: number};
10
+
11
+ export function useBroadcaster(options: Options) {
12
+ const depKey = options.enabled
13
+ ? `${options.hostName}|${options.roomName}|${options.tcpPort ?? ''}|${options.intervalMs ?? 1000}`
14
+ : 'disabled';
15
+
16
+ useEffect(() => {
17
+ if (!options.enabled) return;
18
+ if (!options.tcpPort) return;
19
+
20
+ const socket = dgram.createSocket('udp4');
21
+ socket.on('error', () => {});
22
+
23
+ socket.bind(() => {
24
+ socket.setBroadcast(true);
25
+ });
26
+
27
+ const targets = getBroadcastTargets();
28
+
29
+ const send = () => {
30
+ const packet: DiscoveryPacket = {
31
+ type: 'termbuddy_discovery',
32
+ version: DISCOVERY_VERSION,
33
+ hostName: options.hostName,
34
+ roomName: options.roomName,
35
+ tcpPort: options.tcpPort!,
36
+ sentAt: Date.now()
37
+ };
38
+ const msg = Buffer.from(JSON.stringify(packet));
39
+ for (const address of targets) {
40
+ socket.send(msg, UDP_PORT, address);
41
+ }
42
+ };
43
+
44
+ send();
45
+ const id = setInterval(send, options.intervalMs ?? 1000);
46
+
47
+ return () => {
48
+ clearInterval(id);
49
+ socket.close();
50
+ };
51
+ }, [depKey]);
52
+ }
@@ -0,0 +1,42 @@
1
+ import {useCallback, useEffect, useRef, useState} from 'react';
2
+
3
+ function formatMMSS(totalSeconds: number) {
4
+ const m = Math.floor(totalSeconds / 60);
5
+ const s = totalSeconds % 60;
6
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
7
+ }
8
+
9
+ export function useCountdown() {
10
+ const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
11
+ const timerRef = useRef<NodeJS.Timeout | null>(null);
12
+
13
+ const start = useCallback((minutes: number) => {
14
+ const seconds = Math.max(1, Math.floor(minutes * 60));
15
+ setRemainingSeconds(seconds);
16
+ if (timerRef.current) clearInterval(timerRef.current);
17
+ timerRef.current = setInterval(() => {
18
+ setRemainingSeconds((prev) => {
19
+ if (prev === null) return null;
20
+ if (prev <= 1) return null;
21
+ return prev - 1;
22
+ });
23
+ }, 1000);
24
+ }, []);
25
+
26
+ useEffect(() => {
27
+ if (remainingSeconds !== null) return;
28
+ if (timerRef.current) clearInterval(timerRef.current);
29
+ timerRef.current = null;
30
+ }, [remainingSeconds]);
31
+
32
+ useEffect(() => {
33
+ return () => {
34
+ if (timerRef.current) clearInterval(timerRef.current);
35
+ };
36
+ }, []);
37
+
38
+ return {
39
+ start,
40
+ label: remainingSeconds === null ? null : formatMMSS(remainingSeconds)
41
+ };
42
+ }
@@ -0,0 +1,60 @@
1
+ import {useEffect, useState} from 'react';
2
+ import dgram from 'node:dgram';
3
+ import {UDP_PORT, DISCOVERY_VERSION} from '../constants.js';
4
+ import type {DiscoveryPacket} from '../protocol.js';
5
+ import type {DiscoveredRoom} from '../types.js';
6
+
7
+ function safeParse(msg: Buffer): DiscoveryPacket | null {
8
+ try {
9
+ const parsed = JSON.parse(msg.toString('utf8')) as DiscoveryPacket;
10
+ if (parsed?.type !== 'termbuddy_discovery') return null;
11
+ if (parsed?.version !== DISCOVERY_VERSION) return null;
12
+ if (!parsed.hostName || !parsed.roomName || !parsed.tcpPort) return null;
13
+ return parsed;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function useScanner(options?: {staleAfterMs?: number}): DiscoveredRoom[] {
20
+ const staleAfterMs = options?.staleAfterMs ?? 3500;
21
+ const [rooms, setRooms] = useState<DiscoveredRoom[]>([]);
22
+
23
+ useEffect(() => {
24
+ const socket = dgram.createSocket('udp4');
25
+ socket.on('error', () => {});
26
+
27
+ socket.on('message', (msg, rinfo) => {
28
+ const packet = safeParse(msg);
29
+ if (!packet) return;
30
+
31
+ const now = Date.now();
32
+ setRooms((prev) => {
33
+ const key = `${rinfo.address}:${packet.tcpPort}`;
34
+ const next = prev.filter((r) => `${r.ip}:${r.tcpPort}` !== key);
35
+ next.push({
36
+ ip: rinfo.address,
37
+ hostName: packet.hostName,
38
+ roomName: packet.roomName,
39
+ tcpPort: packet.tcpPort,
40
+ lastSeenAt: now
41
+ });
42
+ return next;
43
+ });
44
+ });
45
+
46
+ socket.bind(UDP_PORT, () => {});
47
+
48
+ const prune = setInterval(() => {
49
+ const now = Date.now();
50
+ setRooms((prev) => prev.filter((r) => now - r.lastSeenAt <= staleAfterMs));
51
+ }, 500);
52
+
53
+ return () => {
54
+ clearInterval(prune);
55
+ socket.close();
56
+ };
57
+ }, [staleAfterMs]);
58
+
59
+ return rooms;
60
+ }
@@ -0,0 +1,153 @@
1
+ import {useCallback, useEffect, useRef, useState} from 'react';
2
+ import net from 'node:net';
3
+ import type {ActivityState, ConnectionStatus, TcpPacket} from '../protocol.js';
4
+ import {TCP_DEFAULT_PORT} from '../constants.js';
5
+
6
+ type HostOptions = {role: 'host'; localName: string; port?: number};
7
+ type ClientOptions = {role: 'client'; localName: string; hostIp: string; tcpPort: number; hostName?: string};
8
+ type Options = HostOptions | ClientOptions;
9
+
10
+ function writePacket(socket: net.Socket, packet: TcpPacket) {
11
+ socket.write(`${JSON.stringify(packet)}\n`, 'utf8');
12
+ }
13
+
14
+ export function useTcpSync(options: Options): {
15
+ status: ConnectionStatus;
16
+ listenPort?: number;
17
+ peerName?: string;
18
+ remoteState?: ActivityState;
19
+ sendStatus: (state: ActivityState) => void;
20
+ } {
21
+ const [status, setStatus] = useState<ConnectionStatus>(options.role === 'host' ? 'waiting' : 'connecting');
22
+ const [listenPort, setListenPort] = useState<number | undefined>(undefined);
23
+ const [peerName, setPeerName] = useState<string | undefined>(undefined);
24
+ const [remoteState, setRemoteState] = useState<ActivityState | undefined>(undefined);
25
+
26
+ const socketRef = useRef<net.Socket | null>(null);
27
+ const lastSeenRef = useRef<number>(Date.now());
28
+ const heartbeatRef = useRef<NodeJS.Timeout | null>(null);
29
+
30
+ const cleanupSocket = useCallback(() => {
31
+ if (heartbeatRef.current) clearInterval(heartbeatRef.current);
32
+ heartbeatRef.current = null;
33
+
34
+ const s = socketRef.current;
35
+ socketRef.current = null;
36
+ if (s && !s.destroyed) s.destroy();
37
+ }, []);
38
+
39
+ const attachSocket = useCallback(
40
+ (s: net.Socket) => {
41
+ cleanupSocket();
42
+ socketRef.current = s;
43
+ lastSeenRef.current = Date.now();
44
+
45
+ setStatus('connected');
46
+ setRemoteState('IDLE');
47
+
48
+ let buf = '';
49
+ s.setNoDelay(true);
50
+ s.setEncoding('utf8');
51
+
52
+ const onData = (chunk: string) => {
53
+ buf += chunk;
54
+ while (true) {
55
+ const idx = buf.indexOf('\n');
56
+ if (idx === -1) break;
57
+ const line = buf.slice(0, idx).trim();
58
+ buf = buf.slice(idx + 1);
59
+ if (!line) continue;
60
+ try {
61
+ const packet = JSON.parse(line) as TcpPacket;
62
+ lastSeenRef.current = Date.now();
63
+ if (packet.type === 'hello') {
64
+ if (options.role === 'host') setPeerName(packet.clientName);
65
+ else setPeerName(packet.hostName);
66
+ }
67
+ if (packet.type === 'status') setRemoteState(packet.state);
68
+ if (packet.type === 'ping') writePacket(s, {type: 'pong', sentAt: Date.now()});
69
+ if (packet.type === 'pong') {
70
+ // no-op
71
+ }
72
+ } catch {
73
+ // ignore
74
+ }
75
+ }
76
+ };
77
+
78
+ s.on('data', onData);
79
+ s.on('close', () => {
80
+ setStatus(options.role === 'host' ? 'waiting' : 'disconnected');
81
+ setRemoteState('OFFLINE');
82
+ cleanupSocket();
83
+ });
84
+ s.on('error', () => {
85
+ setStatus(options.role === 'host' ? 'waiting' : 'disconnected');
86
+ setRemoteState('OFFLINE');
87
+ });
88
+
89
+ // Hello handshake.
90
+ writePacket(s, {
91
+ type: 'hello',
92
+ hostName: options.role === 'host' ? options.localName : options.hostName ?? 'Host',
93
+ clientName: options.role === 'client' ? options.localName : 'Client',
94
+ sentAt: Date.now()
95
+ });
96
+
97
+ heartbeatRef.current = setInterval(() => {
98
+ const sock = socketRef.current;
99
+ if (!sock || sock.destroyed) return;
100
+ writePacket(sock, {type: 'ping', sentAt: Date.now()});
101
+ const age = Date.now() - lastSeenRef.current;
102
+ if (age > 6000) {
103
+ setStatus('disconnected');
104
+ setRemoteState('OFFLINE');
105
+ cleanupSocket();
106
+ }
107
+ }, 2000);
108
+ },
109
+ [cleanupSocket, options]
110
+ );
111
+
112
+ useEffect(() => {
113
+ if (options.role === 'host') {
114
+ const server = net.createServer((socket) => {
115
+ attachSocket(socket);
116
+ });
117
+
118
+ server.on('error', () => {});
119
+
120
+ server.listen(options.port ?? TCP_DEFAULT_PORT, () => {
121
+ const address = server.address();
122
+ if (address && typeof address === 'object') setListenPort(address.port);
123
+ });
124
+
125
+ return () => {
126
+ cleanupSocket();
127
+ server.close();
128
+ };
129
+ }
130
+
131
+ setStatus('connecting');
132
+ const socket = net.createConnection({host: options.hostIp, port: options.tcpPort}, () => {
133
+ attachSocket(socket);
134
+ });
135
+ socket.on('error', () => {
136
+ setStatus('disconnected');
137
+ setRemoteState('OFFLINE');
138
+ });
139
+
140
+ return () => {
141
+ socket.destroy();
142
+ cleanupSocket();
143
+ };
144
+ }, [attachSocket, cleanupSocket, options]);
145
+
146
+ const sendStatus = useCallback((state: ActivityState) => {
147
+ const socket = socketRef.current;
148
+ if (!socket || socket.destroyed) return;
149
+ writePacket(socket, {type: 'status', state, sentAt: Date.now()});
150
+ }, []);
151
+
152
+ return {status, listenPort, peerName, remoteState, sendStatus};
153
+ }
@@ -0,0 +1,32 @@
1
+ import os from 'node:os';
2
+
3
+ function ipv4ToInt(ip: string) {
4
+ return ip
5
+ .split('.')
6
+ .map((n) => Number.parseInt(n, 10))
7
+ .reduce((acc, n) => ((acc << 8) | (n & 255)) >>> 0, 0);
8
+ }
9
+
10
+ function intToIpv4(n: number) {
11
+ return [24, 16, 8, 0].map((shift) => String((n >>> shift) & 255)).join('.');
12
+ }
13
+
14
+ export function getBroadcastTargets(): string[] {
15
+ const out = new Set<string>(['255.255.255.255']);
16
+
17
+ const ifaces = os.networkInterfaces();
18
+ for (const entries of Object.values(ifaces)) {
19
+ if (!entries) continue;
20
+ for (const e of entries) {
21
+ if (e.family !== 'IPv4') continue;
22
+ if (e.internal) continue;
23
+ if (!e.address || !e.netmask) continue;
24
+ const ip = ipv4ToInt(e.address);
25
+ const mask = ipv4ToInt(e.netmask);
26
+ const broadcast = (ip | (~mask >>> 0)) >>> 0;
27
+ out.add(intToIpv4(broadcast));
28
+ }
29
+ }
30
+
31
+ return [...out];
32
+ }
@@ -0,0 +1,2 @@
1
+ export {getBroadcastTargets} from './broadcast.js';
2
+
@@ -0,0 +1,18 @@
1
+ export type ActivityState = 'TYPING' | 'IDLE' | 'OFFLINE';
2
+
3
+ export type DiscoveryPacket = {
4
+ type: 'termbuddy_discovery';
5
+ version: number;
6
+ hostName: string;
7
+ roomName: string;
8
+ tcpPort: number;
9
+ sentAt: number;
10
+ };
11
+
12
+ export type TcpPacket =
13
+ | {type: 'hello'; hostName: string; clientName: string; sentAt: number}
14
+ | {type: 'status'; state: ActivityState; sentAt: number}
15
+ | {type: 'ping'; sentAt: number}
16
+ | {type: 'pong'; sentAt: number};
17
+
18
+ export type ConnectionStatus = 'waiting' | 'connecting' | 'connected' | 'disconnected';
package/src/types.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type DiscoveredRoom = {
2
+ ip: string;
3
+ hostName: string;
4
+ roomName: string;
5
+ tcpPort: number;
6
+ lastSeenAt: number;
7
+ };
8
+
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import {Box, Text, useInput} from 'ink';
3
+
4
+ export function MainMenu(props: {onHost: () => void; onJoin: () => void; onExit: () => void}) {
5
+ useInput((input, key) => {
6
+ if (key.escape || input === 'q') props.onExit();
7
+ if (input === '1') props.onHost();
8
+ if (input === '2') props.onJoin();
9
+ });
10
+
11
+ return (
12
+ <Box flexDirection="column" padding={1}>
13
+ <Text>
14
+ {String.raw`
15
+ ████████╗███████╗██████╗ ███╗ ███╗██████╗ ██╗ ██╗██████╗ ██████╗ ██╗ ██╗
16
+ ╚══██╔══╝██╔════╝██╔══██╗████╗ ████║██╔══██╗██║ ██║██╔══██╗██╔══██╗╚██╗ ██╔╝
17
+ ██║ █████╗ ██████╔╝██╔████╔██║██████╔╝██║ ██║██║ ██║██║ ██║ ╚████╔╝
18
+ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══██╗██║ ██║██║ ██║██║ ██║ ╚██╔╝
19
+ ██║ ███████╗██║ ██║██║ ╚═╝ ██║██████╔╝╚██████╔╝██████╔╝██████╔╝ ██║
20
+ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
21
+ `}
22
+ </Text>
23
+ <Box flexDirection="column" marginTop={1}>
24
+ <Text>Terminal Body Doubling — 极简 / 极客 / 私密</Text>
25
+ <Text> </Text>
26
+ <Text>
27
+ <Text color="cyan">[1]</Text> 建房 (Host)
28
+ </Text>
29
+ <Text>
30
+ <Text color="cyan">[2]</Text> 加入 (Join)
31
+ </Text>
32
+ <Text>
33
+ <Text color="cyan">[q]</Text> 退出
34
+ </Text>
35
+ </Box>
36
+ </Box>
37
+ );
38
+ }
@@ -0,0 +1,47 @@
1
+ import React, {useMemo} from 'react';
2
+ import {Box, Text, useInput} from 'ink';
3
+ import {useScanner} from '../hooks/index.js';
4
+ import type {DiscoveredRoom} from '../types.js';
5
+
6
+ export function RoomScanner(props: {
7
+ onSelectRoom: (room: DiscoveredRoom) => void;
8
+ onBack: () => void;
9
+ onExit: () => void;
10
+ }) {
11
+ const rooms = useScanner();
12
+
13
+ const sortedRooms = useMemo(() => {
14
+ return [...rooms].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
15
+ }, [rooms]);
16
+
17
+ useInput((input, key) => {
18
+ if (key.escape || input === 'b') props.onBack();
19
+ if (input === 'q') props.onExit();
20
+
21
+ const index = Number.parseInt(input, 10);
22
+ if (Number.isNaN(index)) return;
23
+ const room = sortedRooms[index - 1];
24
+ if (!room) return;
25
+ props.onSelectRoom(room);
26
+ });
27
+
28
+ return (
29
+ <Box flexDirection="column" padding={1}>
30
+ <Text>
31
+ <Text color="yellow">正在扫描局域网...</Text> (按 <Text color="cyan">b</Text> 返回,{' '}
32
+ <Text color="cyan">q</Text> 退出)
33
+ </Text>
34
+ <Box flexDirection="column" marginTop={1}>
35
+ {sortedRooms.length === 0 ? (
36
+ <Text color="gray">暂无房间广播。</Text>
37
+ ) : (
38
+ sortedRooms.map((room, i) => (
39
+ <Text key={`${room.ip}:${room.tcpPort}`}>
40
+ <Text color="cyan">[{i + 1}]</Text> {room.roomName} — {room.hostName} @ {room.ip}:{room.tcpPort}
41
+ </Text>
42
+ ))
43
+ )}
44
+ </Box>
45
+ </Box>
46
+ );
47
+ }
@@ -0,0 +1,127 @@
1
+ import React, {useCallback, useEffect, useMemo, useState} from 'react';
2
+ import {Box, Text, useInput} from 'ink';
3
+ import type {ActivityState} from '../protocol.js';
4
+ import {AiConsole, BuddyAvatar, StatusHeader} from '../components/index.js';
5
+ import {useActivityMonitor, useBroadcaster, useCountdown, useTcpSync} from '../hooks/index.js';
6
+
7
+ export function Session(
8
+ props:
9
+ | {role: 'host'; localName: string; onExit: () => void}
10
+ | {
11
+ role: 'client';
12
+ localName: string;
13
+ onExit: () => void;
14
+ hostIp: string;
15
+ tcpPort: number;
16
+ roomName: string;
17
+ hostName: string;
18
+ }
19
+ ) {
20
+ const roomName = useMemo(() => `${props.localName}'s Room`, [props.localName]);
21
+
22
+ const [showAi, setShowAi] = useState(false);
23
+ const countdown = useCountdown();
24
+
25
+ const tcpOptions = useMemo(() => {
26
+ return props.role === 'host'
27
+ ? ({role: 'host', localName: props.localName} as const)
28
+ : ({
29
+ role: 'client',
30
+ localName: props.localName,
31
+ hostIp: props.hostIp,
32
+ tcpPort: props.tcpPort,
33
+ hostName: props.hostName
34
+ } as const);
35
+ }, [
36
+ props.role,
37
+ props.localName,
38
+ props.role === 'client' ? props.hostIp : '',
39
+ props.role === 'client' ? props.tcpPort : 0,
40
+ props.role === 'client' ? props.hostName : ''
41
+ ]);
42
+
43
+ const tcp = useTcpSync(tcpOptions);
44
+
45
+ const broadcasterOptions = useMemo(() => {
46
+ return props.role === 'host'
47
+ ? ({
48
+ enabled: true,
49
+ hostName: props.localName,
50
+ roomName,
51
+ tcpPort: tcp.listenPort
52
+ } as const)
53
+ : ({enabled: false} as const);
54
+ }, [props.role, props.localName, roomName, tcp.listenPort]);
55
+
56
+ useBroadcaster(broadcasterOptions);
57
+
58
+ const localActivity = useActivityMonitor();
59
+
60
+ const remoteActivity: ActivityState = tcp.remoteState ?? 'OFFLINE';
61
+
62
+ const onToggleAi = useCallback(() => setShowAi((v) => !v), []);
63
+ const onCloseAi = useCallback(() => setShowAi(false), []);
64
+
65
+ useInput(
66
+ (input, key) => {
67
+ if (input === 'q') props.onExit();
68
+ if (input === '/' && !key.ctrl && !key.meta) onToggleAi();
69
+ },
70
+ {isActive: !showAi}
71
+ );
72
+
73
+ const buddyName =
74
+ props.role === 'host'
75
+ ? tcp.peerName ?? 'Waiting...'
76
+ : `${props.hostName ?? 'Host'} (${props.roomName ?? 'Room'})`;
77
+
78
+ const localState = localActivity.state;
79
+ const localLabel = props.role === 'host' ? `${props.localName} (Host)` : `${props.localName} (Client)`;
80
+
81
+ // Sync local activity state to peer.
82
+ useEffect(() => {
83
+ if (tcp.status !== 'connected') return;
84
+ tcp.sendStatus(localState);
85
+ }, [localState, tcp.status, tcp.sendStatus]);
86
+
87
+ return (
88
+ <Box flexDirection="column" padding={1}>
89
+ <StatusHeader
90
+ role={props.role}
91
+ status={tcp.status}
92
+ hostIp={props.role === 'client' ? props.hostIp : undefined}
93
+ tcpPort={props.role === 'client' ? props.tcpPort : tcp.listenPort}
94
+ countdownLabel={countdown.label}
95
+ />
96
+
97
+ <Box flexDirection="row" gap={4} marginTop={1}>
98
+ <Box flexDirection="column" width="50%">
99
+ <Text color="cyan">{localLabel}</Text>
100
+ <BuddyAvatar state={localState} />
101
+ </Box>
102
+
103
+ <Box flexDirection="column" width="50%">
104
+ <Text color="magenta">{buddyName}</Text>
105
+ <BuddyAvatar state={remoteActivity} />
106
+ </Box>
107
+ </Box>
108
+
109
+ <Box marginTop={1}>
110
+ <Text color="gray">
111
+ 按 <Text color="cyan">/</Text> 召唤 AI Console,按 <Text color="cyan">q</Text> 返回菜单。
112
+ </Text>
113
+ </Box>
114
+
115
+ {showAi ? (
116
+ <Box marginTop={1}>
117
+ <AiConsole
118
+ onClose={onCloseAi}
119
+ onStartCountdown={countdown.start}
120
+ localName={props.localName}
121
+ peerName={tcp.peerName ?? (props.role === 'client' ? props.hostName : undefined) ?? 'Buddy'}
122
+ />
123
+ </Box>
124
+ ) : null}
125
+ </Box>
126
+ );
127
+ }
@@ -0,0 +1,4 @@
1
+ export {MainMenu} from './MainMenu.js';
2
+ export {RoomScanner} from './RoomScanner.js';
3
+ export {Session} from './Session.js';
4
+
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022"],
7
+ "jsx": "react-jsx",
8
+ "types": ["node"],
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true
12
+ },
13
+ "include": ["src", "tsup.config.ts"]
14
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,15 @@
1
+ import {defineConfig} from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/cli.tsx'],
5
+ format: ['esm'],
6
+ platform: 'node',
7
+ target: 'node18',
8
+ sourcemap: true,
9
+ clean: true,
10
+ bundle: true,
11
+ splitting: false,
12
+ banner: {
13
+ js: '#!/usr/bin/env node'
14
+ }
15
+ });