tapback-cli 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.
- package/.claude/commands/commit-push.md +67 -0
- package/.claude/settings.local.json +43 -0
- package/.github/workflows/ci.yml +19 -0
- package/.github/workflows/release.yml +29 -0
- package/.prettierrc +6 -0
- package/CLAUDE.md +84 -0
- package/README.md +71 -0
- package/bin/cli.js +59 -0
- package/package.json +30 -0
- package/src/claudeStatus.js +113 -0
- package/src/config.js +26 -0
- package/src/html.js +594 -0
- package/src/proxy.js +99 -0
- package/src/server.js +214 -0
- package/src/tmux.js +31 -0
package/src/proxy.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const httpProxy = require('http-proxy');
|
|
3
|
+
|
|
4
|
+
function isTextContent(ct) {
|
|
5
|
+
const l = ct.toLowerCase();
|
|
6
|
+
return (
|
|
7
|
+
l.includes('text/') ||
|
|
8
|
+
l.includes('application/javascript') ||
|
|
9
|
+
l.includes('application/json') ||
|
|
10
|
+
l.includes('application/xml') ||
|
|
11
|
+
l.includes('application/xhtml') ||
|
|
12
|
+
l.includes('+json') ||
|
|
13
|
+
l.includes('+xml')
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function rewriteLocalhost(text, macIP, allProxyPorts) {
|
|
18
|
+
let result = text;
|
|
19
|
+
for (const [targetPort, externalPort] of Object.entries(allProxyPorts)) {
|
|
20
|
+
const replacements = [
|
|
21
|
+
[`http://localhost:${targetPort}`, `http://${macIP}:${externalPort}`],
|
|
22
|
+
[`https://localhost:${targetPort}`, `https://${macIP}:${externalPort}`],
|
|
23
|
+
[`http://127.0.0.1:${targetPort}`, `http://${macIP}:${externalPort}`],
|
|
24
|
+
[`https://127.0.0.1:${targetPort}`, `https://${macIP}:${externalPort}`],
|
|
25
|
+
[`//localhost:${targetPort}`, `//${macIP}:${externalPort}`],
|
|
26
|
+
[`//127.0.0.1:${targetPort}`, `//${macIP}:${externalPort}`],
|
|
27
|
+
[`'localhost:${targetPort}`, `'${macIP}:${externalPort}`],
|
|
28
|
+
[`"localhost:${targetPort}`, `"${macIP}:${externalPort}`],
|
|
29
|
+
[`'127.0.0.1:${targetPort}`, `'${macIP}:${externalPort}`],
|
|
30
|
+
[`"127.0.0.1:${targetPort}`, `"${macIP}:${externalPort}`],
|
|
31
|
+
[`\`localhost:${targetPort}`, `\`${macIP}:${externalPort}`],
|
|
32
|
+
[`\`127.0.0.1:${targetPort}`, `\`${macIP}:${externalPort}`],
|
|
33
|
+
];
|
|
34
|
+
for (const [from, to] of replacements) {
|
|
35
|
+
result = result.split(from).join(to);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createProxyServer(targetPort, externalPort, macIP, allProxyPorts) {
|
|
42
|
+
const proxy = httpProxy.createProxyServer({
|
|
43
|
+
target: `http://127.0.0.1:${targetPort}`,
|
|
44
|
+
selfHandleResponse: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
48
|
+
const ct = proxyRes.headers['content-type'] || '';
|
|
49
|
+
const chunks = [];
|
|
50
|
+
proxyRes.on('data', (chunk) => chunks.push(chunk));
|
|
51
|
+
proxyRes.on('end', () => {
|
|
52
|
+
let body = Buffer.concat(chunks);
|
|
53
|
+
if (isTextContent(ct)) {
|
|
54
|
+
let text = body.toString('utf8');
|
|
55
|
+
text = rewriteLocalhost(text, macIP, allProxyPorts);
|
|
56
|
+
body = Buffer.from(text, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
// Copy headers, skip problematic ones
|
|
59
|
+
for (const [key, value] of Object.entries(proxyRes.headers)) {
|
|
60
|
+
const lk = key.toLowerCase();
|
|
61
|
+
if (!['transfer-encoding', 'connection', 'keep-alive', 'content-encoding'].includes(lk)) {
|
|
62
|
+
res.setHeader(key, value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
res.setHeader('content-length', body.length);
|
|
66
|
+
res.writeHead(proxyRes.statusCode);
|
|
67
|
+
res.end(body);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
proxy.on('error', (err, req, res) => {
|
|
72
|
+
const errorHTML = `<!DOCTYPE html>
|
|
73
|
+
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
74
|
+
<title>Proxy Error</title>
|
|
75
|
+
<style>body{font-family:sans-serif;background:#0d1117;color:#c9d1d9;min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;padding:20px}
|
|
76
|
+
.e{color:#f85149;font-size:1.2rem;margin-bottom:1rem}.m{color:#8b949e;font-size:0.9rem}</style></head>
|
|
77
|
+
<body><div><div class="e">localhost:${targetPort} に接続できません</div><div class="m">${err.message}</div></div></body></html>`;
|
|
78
|
+
res.writeHead(502, { 'Content-Type': 'text/html' });
|
|
79
|
+
res.end(errorHTML);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const server = http.createServer((req, res) => {
|
|
83
|
+
// Rewrite headers for localhost target
|
|
84
|
+
req.headers.host = `localhost:${targetPort}`;
|
|
85
|
+
req.headers.origin = `http://localhost:${targetPort}`;
|
|
86
|
+
req.headers.referer = `http://localhost:${targetPort}/`;
|
|
87
|
+
req.headers['x-forwarded-host'] = macIP;
|
|
88
|
+
req.headers['x-forwarded-proto'] = 'http';
|
|
89
|
+
proxy.web(req, res);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
server.listen(externalPort, '0.0.0.0', () => {
|
|
93
|
+
console.log(`[Tapback] Proxy: port ${externalPort} -> localhost:${targetPort}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return server;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { createProxyServer };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const { WebSocketServer } = require('ws');
|
|
4
|
+
const cookieParser = require('cookie-parser');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const tmux = require('./tmux');
|
|
8
|
+
const html = require('./html');
|
|
9
|
+
const { ClaudeStatusStore } = require('./claudeStatus');
|
|
10
|
+
const config = require('./config');
|
|
11
|
+
|
|
12
|
+
function getLocalIP() {
|
|
13
|
+
const interfaces = os.networkInterfaces();
|
|
14
|
+
const en0 = interfaces.en0;
|
|
15
|
+
if (en0) {
|
|
16
|
+
for (const info of en0) {
|
|
17
|
+
if (info.family === 'IPv4' && !info.internal) return info.address;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Fallback: first non-internal IPv4
|
|
21
|
+
for (const iface of Object.values(interfaces)) {
|
|
22
|
+
for (const info of iface) {
|
|
23
|
+
if (info.family === 'IPv4' && !info.internal) return info.address;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return '127.0.0.1';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function escapeJson(str) {
|
|
30
|
+
return str
|
|
31
|
+
.replace(/\\/g, '\\\\')
|
|
32
|
+
.replace(/"/g, '\\"')
|
|
33
|
+
.replace(/\n/g, '\\n')
|
|
34
|
+
.replace(/\r/g, '\\r')
|
|
35
|
+
.replace(/\t/g, '\\t');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createServer({ port = 9876, pinEnabled = true, quickButtons = [], appURL = null } = {}) {
|
|
39
|
+
const pin = String(Math.floor(Math.random() * 10000)).padStart(4, '0');
|
|
40
|
+
const settingsPin = String(Math.floor(Math.random() * 10000)).padStart(4, '0');
|
|
41
|
+
const authToken = crypto.randomUUID();
|
|
42
|
+
const settingsAuthToken = crypto.randomUUID();
|
|
43
|
+
const macIP = getLocalIP();
|
|
44
|
+
const statusStore = new ClaudeStatusStore();
|
|
45
|
+
const wsClients = new Set();
|
|
46
|
+
|
|
47
|
+
const app = express();
|
|
48
|
+
app.use(cookieParser());
|
|
49
|
+
app.use(express.json());
|
|
50
|
+
app.use(express.urlencoded({ extended: true }));
|
|
51
|
+
|
|
52
|
+
// Auth middleware for main page
|
|
53
|
+
function requireAuth(req, res, next) {
|
|
54
|
+
if (!config.load().pinEnabled) return next();
|
|
55
|
+
if (req.cookies.tapback_auth === authToken) return next();
|
|
56
|
+
return res.redirect('/auth');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Auth middleware for settings (always required, separate PIN)
|
|
60
|
+
function requireSettingsAuth(req, res, next) {
|
|
61
|
+
if (req.cookies.tapback_settings_auth === settingsAuthToken) return next();
|
|
62
|
+
return res.redirect('/settings/auth');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
app.get('/', requireAuth, (req, res) => {
|
|
66
|
+
res.type('html').send(html.mainPage(appURL, quickButtons));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
app.get('/auth', (req, res) => {
|
|
70
|
+
if (!config.load().pinEnabled) return res.redirect('/');
|
|
71
|
+
if (req.cookies.tapback_auth === authToken) return res.redirect('/');
|
|
72
|
+
res.type('html').send(html.pinPage(null, '/auth'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
app.post('/auth', (req, res) => {
|
|
76
|
+
if (!config.load().pinEnabled) return res.redirect('/');
|
|
77
|
+
if (req.body.pin === pin) {
|
|
78
|
+
res.cookie('tapback_auth', authToken, { maxAge: 86400000, httpOnly: true });
|
|
79
|
+
return res.redirect('/');
|
|
80
|
+
}
|
|
81
|
+
res.status(401).type('html').send(html.pinPage('Invalid PIN', '/auth'));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
app.get('/settings/auth', (req, res) => {
|
|
85
|
+
if (req.cookies.tapback_settings_auth === settingsAuthToken) return res.redirect('/settings');
|
|
86
|
+
res.type('html').send(html.pinPage(null, '/settings/auth'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
app.post('/settings/auth', (req, res) => {
|
|
90
|
+
if (req.body.pin === settingsPin) {
|
|
91
|
+
res.cookie('tapback_settings_auth', settingsAuthToken, { maxAge: 86400000, httpOnly: true });
|
|
92
|
+
return res.redirect('/settings');
|
|
93
|
+
}
|
|
94
|
+
res.status(401).type('html').send(html.pinPage('Invalid PIN', '/settings/auth'));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
app.get('/api/sessions', async (req, res) => {
|
|
98
|
+
const sessions = await tmux.listSessions();
|
|
99
|
+
res.json(sessions.map((name) => ({ name })));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
app.post('/api/claude-status', (req, res) => {
|
|
103
|
+
const { session_id, status, project_dir, model } = req.body;
|
|
104
|
+
if (!session_id) return res.status(400).json({ error: 'missing session_id' });
|
|
105
|
+
|
|
106
|
+
const statusObj = {
|
|
107
|
+
session_id,
|
|
108
|
+
status,
|
|
109
|
+
project_dir,
|
|
110
|
+
model,
|
|
111
|
+
timestamp: new Date().toISOString(),
|
|
112
|
+
};
|
|
113
|
+
statusStore.update(statusObj);
|
|
114
|
+
|
|
115
|
+
// Broadcast to all WebSocket clients
|
|
116
|
+
const msg = JSON.stringify({ t: 'status', d: statusObj });
|
|
117
|
+
for (const ws of wsClients) {
|
|
118
|
+
if (ws.readyState === 1) ws.send(msg);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
res.json({ ok: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
app.get('/api/claude-status', (req, res) => {
|
|
125
|
+
res.json(statusStore.getAll());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Settings page and API (separate PIN auth)
|
|
129
|
+
app.get('/settings', requireSettingsAuth, (req, res) => {
|
|
130
|
+
res.type('html').send(html.settingsPage(config.load()));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
app.get('/api/settings', requireSettingsAuth, (req, res) => {
|
|
134
|
+
res.json({ ...config.load(), pin });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
app.put('/api/settings', requireSettingsAuth, (req, res) => {
|
|
138
|
+
const cfg = config.load();
|
|
139
|
+
const body = req.body;
|
|
140
|
+
|
|
141
|
+
if (typeof body.pinEnabled === 'boolean') {
|
|
142
|
+
cfg.pinEnabled = body.pinEnabled;
|
|
143
|
+
console.log(`[Tapback] PIN ${body.pinEnabled ? 'enabled' : 'disabled'} (PIN: ${pin})`);
|
|
144
|
+
}
|
|
145
|
+
if (body.addProxy) {
|
|
146
|
+
cfg.proxyPorts[String(body.addProxy.target)] = body.addProxy.external;
|
|
147
|
+
}
|
|
148
|
+
if (typeof body.delProxy === 'number') {
|
|
149
|
+
delete cfg.proxyPorts[String(body.delProxy)];
|
|
150
|
+
}
|
|
151
|
+
if (body.addButton && body.addButton.label && body.addButton.command) {
|
|
152
|
+
cfg.quickButtons.push({ label: body.addButton.label, command: body.addButton.command });
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
typeof body.delButton === 'number' &&
|
|
156
|
+
body.delButton >= 0 &&
|
|
157
|
+
body.delButton < cfg.quickButtons.length
|
|
158
|
+
) {
|
|
159
|
+
cfg.quickButtons.splice(body.delButton, 1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
config.save(cfg);
|
|
163
|
+
res.json({ ok: true });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const server = http.createServer(app);
|
|
167
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
168
|
+
|
|
169
|
+
wss.on('connection', async (ws) => {
|
|
170
|
+
wsClients.add(ws);
|
|
171
|
+
|
|
172
|
+
// Send initial output for all tmux sessions
|
|
173
|
+
const sessions = await tmux.listSessions();
|
|
174
|
+
for (const name of sessions) {
|
|
175
|
+
const [output, cpath] = await Promise.all([tmux.capture(name), tmux.getCurrentPath(name)]);
|
|
176
|
+
ws.send(JSON.stringify({ t: 'o', id: name, c: output, path: cpath || '' }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Send initial Claude statuses
|
|
180
|
+
for (const s of statusStore.getAll()) {
|
|
181
|
+
ws.send(JSON.stringify({ t: 'status', d: s }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
ws.on('message', async (data) => {
|
|
185
|
+
try {
|
|
186
|
+
const msg = JSON.parse(data);
|
|
187
|
+
if (msg.t === 'i' && msg.id) {
|
|
188
|
+
await tmux.sendKeys(msg.id, msg.c || '');
|
|
189
|
+
}
|
|
190
|
+
} catch {}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
ws.on('close', () => wsClients.delete(ws));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Periodic tmux output broadcast
|
|
197
|
+
setInterval(async () => {
|
|
198
|
+
if (wsClients.size === 0) return;
|
|
199
|
+
const sessions = await tmux.listSessions();
|
|
200
|
+
for (const name of sessions) {
|
|
201
|
+
const [output, cpath] = await Promise.all([tmux.capture(name), tmux.getCurrentPath(name)]);
|
|
202
|
+
const msg = JSON.stringify({ t: 'o', id: name, c: output, path: cpath || '' });
|
|
203
|
+
for (const ws of wsClients) {
|
|
204
|
+
if (ws.readyState === 1) ws.send(msg);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}, 1000);
|
|
208
|
+
|
|
209
|
+
server.listen(port, '0.0.0.0');
|
|
210
|
+
|
|
211
|
+
return { server, pin, settingsPin, macIP, port };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = { createServer, getLocalIP };
|
package/src/tmux.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { execFile } = require('child_process');
|
|
2
|
+
|
|
3
|
+
const ENV = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH}` };
|
|
4
|
+
|
|
5
|
+
function run(args) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
execFile('tmux', args, { env: ENV, timeout: 5000 }, (err, stdout) => {
|
|
8
|
+
resolve(err ? '' : stdout);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
exports.listSessions = async () => {
|
|
14
|
+
const out = await run(['list-sessions', '-F', '#{session_name}']);
|
|
15
|
+
return out.split('\n').filter(Boolean);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
exports.capture = (session) => run(['capture-pane', '-t', `${session}:0.0`, '-p', '-S', '-300']);
|
|
19
|
+
|
|
20
|
+
exports.sendKeys = async (session, text) => {
|
|
21
|
+
if (text) {
|
|
22
|
+
await run(['send-keys', '-t', `${session}:0.0`, '-l', text]);
|
|
23
|
+
}
|
|
24
|
+
await run(['send-keys', '-t', `${session}:0.0`, 'Enter']);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
exports.getCurrentPath = async (session) => {
|
|
28
|
+
const out = await run(['display-message', '-t', `${session}:0.0`, '-p', '#{pane_current_path}']);
|
|
29
|
+
const trimmed = out.trim();
|
|
30
|
+
return trimmed || null;
|
|
31
|
+
};
|