thinkpool-pair 0.7.2 → 0.7.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/account.mjs +140 -0
- package/auth-store.mjs +43 -0
- package/package.json +3 -1
package/account.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/* account.mjs — account-mode bridge (Phase 1).
|
|
2
|
+
One `npx thinkpool-pair` per ACCOUNT instead of per room:
|
|
3
|
+
login — link this device to your ThinkPool account (realtime handoff).
|
|
4
|
+
bind — map a room code → a working directory on THIS machine.
|
|
5
|
+
account — discover your code sessions and serve each one (a headless child
|
|
6
|
+
bridge per room, run in its bound directory). Reuses the proven
|
|
7
|
+
single-room bridge verbatim; the supervisor only adds discovery.
|
|
8
|
+
Spec: docs/specs/2026-06-16-account-bridge.md */
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { randomUUID } from 'node:crypto'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import { createClient } from '@supabase/supabase-js'
|
|
15
|
+
import os from 'node:os'
|
|
16
|
+
import { saveAuth, loadAuth, clearAuth, loadDirs, bindDir } from './auth-store.mjs'
|
|
17
|
+
|
|
18
|
+
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
19
|
+
|
|
20
|
+
const BRIDGE = fileURLToPath(new URL('./bridge.mjs', import.meta.url))
|
|
21
|
+
|
|
22
|
+
// ── login: web hands the bridge a session over an ephemeral realtime channel ──
|
|
23
|
+
// The bridge prints a one-time URL with a high-entropy code; the signed-in web
|
|
24
|
+
// page broadcasts {refresh_token,…} to tplink:<code> after the user approves.
|
|
25
|
+
// (Phase-1 handoff — an edge-function device-code flow can harden this later.)
|
|
26
|
+
export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
|
|
27
|
+
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false } })
|
|
28
|
+
const code = randomUUID().replace(/-/g, '').slice(0, 12)
|
|
29
|
+
const ch = sb.channel(`tplink:${code}`, { config: { broadcast: { self: false } } })
|
|
30
|
+
let done = false
|
|
31
|
+
ch.on('broadcast', { event: 'session' }, async ({ payload }) => {
|
|
32
|
+
if (done || !payload?.refresh_token) return
|
|
33
|
+
done = true
|
|
34
|
+
saveAuth(payload)
|
|
35
|
+
process.stderr.write(`\n ◆ linked as ${payload.email || 'your account'}. You can close the browser tab.\n Now run: npx thinkpool-pair\n`)
|
|
36
|
+
try { await sb.removeChannel(ch) } catch { /* noop */ }
|
|
37
|
+
process.exit(0)
|
|
38
|
+
})
|
|
39
|
+
await new Promise((res) => ch.subscribe((s) => { if (s === 'SUBSCRIBED') res() }))
|
|
40
|
+
const url = `${WEB_BASE}/code/link?c=${code}`
|
|
41
|
+
process.stderr.write(`\n ◆ Link this device to your ThinkPool account.\n Open (signed in) and approve:\n\n ${url}\n\n Code: ${code} — waiting up to 5 min…\n`)
|
|
42
|
+
setTimeout(() => { if (!done) { process.stderr.write('\n ◇ link timed out — run `npx thinkpool-pair login` again.\n'); process.exit(1) } }, 5 * 60 * 1000)
|
|
43
|
+
await new Promise(() => {})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Exchange the stored refresh token for a live session + authed client.
|
|
47
|
+
export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
|
|
48
|
+
const a = loadAuth()
|
|
49
|
+
if (!a?.refresh_token) return null
|
|
50
|
+
const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false, autoRefreshToken: false } })
|
|
51
|
+
try {
|
|
52
|
+
const { data, error } = await sb.auth.refreshSession({ refresh_token: a.refresh_token })
|
|
53
|
+
if (error || !data?.session) return null
|
|
54
|
+
saveAuth(data.session) // rotate the refresh token
|
|
55
|
+
return { sb, session: data.session }
|
|
56
|
+
} catch { return null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function runBind(room, dir) {
|
|
60
|
+
const r = (room || '').toUpperCase().trim()
|
|
61
|
+
if (!r) { console.error('usage: npx thinkpool-pair bind <ROOM> <directory>'); process.exit(1) }
|
|
62
|
+
const abs = path.resolve(dir || '.')
|
|
63
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) { console.error(`✗ not a directory: ${abs}`); process.exit(1) }
|
|
64
|
+
bindDir(r, abs)
|
|
65
|
+
console.error(`✓ bound ${r} → ${abs}\n Run \`npx thinkpool-pair\` (or restart it) to serve this session.`)
|
|
66
|
+
process.exit(0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── account supervisor: a headless child bridge per bound room, in its dir ──
|
|
70
|
+
export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
71
|
+
const auth = await authedClient(SUPABASE_URL, SUPABASE_ANON)
|
|
72
|
+
if (!auth) {
|
|
73
|
+
clearAuth()
|
|
74
|
+
console.error('\n Not linked (or the link expired). Run:\n\n npx thinkpool-pair login\n')
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
const { sb, session } = auth
|
|
78
|
+
const email = session.user?.email || 'your account'
|
|
79
|
+
|
|
80
|
+
// Owner-gate (Contract C-CODE-2): hosting a code session — spawning the agent and
|
|
81
|
+
// burning compute on this machine — requires Pro or Enterprise. This is the layer
|
|
82
|
+
// with teeth on cost; the start_code_session RPC gates the web create flow, this
|
|
83
|
+
// stops a crafted-row bypass from ever serving. Invited partners don't run a bridge.
|
|
84
|
+
let plan = 'free'
|
|
85
|
+
try {
|
|
86
|
+
const { data } = await sb.from('profiles').select('plan').eq('id', session.user.id).single()
|
|
87
|
+
plan = data?.plan || 'free'
|
|
88
|
+
} catch { /* transient read — fall through to the gate, fail closed */ }
|
|
89
|
+
if (plan !== 'pro' && plan !== 'enterprise') {
|
|
90
|
+
process.stderr.write(`\n ◇ ThinkPool Code requires a Pro or Enterprise plan to host a session.\n Your account (${email}) is on the ${plan} plan.\n Upgrade: https://thinkpool.io/pro\n\n (Anyone you invite can still join and steer a Pro host's session for free.)\n`)
|
|
91
|
+
process.exit(1)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n Serving your code sessions. Add a session's directory with \`bind\`.\n`)
|
|
95
|
+
|
|
96
|
+
const children = new Map() // room -> child process
|
|
97
|
+
const warned = new Set()
|
|
98
|
+
|
|
99
|
+
// Account presence — so the web dashboard can show "● Bridge active" for this
|
|
100
|
+
// account without per-room probing. We TRACK this machine (name + served room
|
|
101
|
+
// codes + version) on a per-account channel; the dashboard subscribes read-only.
|
|
102
|
+
const machine = os.hostname().replace(/\.local$/, '')
|
|
103
|
+
const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine } } })
|
|
104
|
+
const pushPresence = () => { try { acct.track({ name: machine, version: VERSION, rooms: [...children.keys()], ts: Date.now() }) } catch { /* noop */ } }
|
|
105
|
+
await new Promise((res) => acct.subscribe((st) => { if (st === 'SUBSCRIBED') { pushPresence(); res() } }))
|
|
106
|
+
|
|
107
|
+
const tick = async () => {
|
|
108
|
+
let rooms = []
|
|
109
|
+
try {
|
|
110
|
+
const { data } = await sb.from('code_sessions').select('code,name,last_active_at').order('last_active_at', { ascending: false })
|
|
111
|
+
rooms = data || []
|
|
112
|
+
} catch { /* transient — keep existing children, retry next tick */ }
|
|
113
|
+
const dirs = loadDirs()
|
|
114
|
+
for (const r of rooms) {
|
|
115
|
+
const room = r.code
|
|
116
|
+
if (!room || children.has(room)) continue
|
|
117
|
+
const dir = dirs[room]
|
|
118
|
+
if (!dir) {
|
|
119
|
+
if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ session ${r.name ? `"${r.name}" ` : ''}(${room}) needs a directory:\n npx thinkpool-pair bind ${room} <path>\n`) }
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (!fs.existsSync(dir)) {
|
|
123
|
+
if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
warned.delete(room)
|
|
127
|
+
process.stderr.write(`\n ◆ serving ${room}${r.name ? ` "${r.name}"` : ''} → ${dir}\n`)
|
|
128
|
+
const child = spawn(process.execPath, [BRIDGE, room, '--headless'], { cwd: dir, stdio: 'inherit' })
|
|
129
|
+
children.set(room, child)
|
|
130
|
+
child.on('exit', () => { children.delete(room) }) // re-served on the next tick
|
|
131
|
+
}
|
|
132
|
+
pushPresence()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await tick()
|
|
136
|
+
const iv = setInterval(tick, 15000)
|
|
137
|
+
const stop = (sig) => { clearInterval(iv); try { acct.untrack(); sb.removeChannel(acct) } catch { /* noop */ } for (const c of children.values()) { try { c.kill(sig) } catch { /* noop */ } } setTimeout(() => process.exit(0), 400) }
|
|
138
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => stop(sig))
|
|
139
|
+
await new Promise(() => {})
|
|
140
|
+
}
|
package/auth-store.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* auth-store.mjs — account-mode credentials + per-session directory map for the
|
|
2
|
+
ThinkPool bridge. Files live under ~/.thinkpool-pair/ (chmod 600 for the token).
|
|
3
|
+
- auth.json: the linked account's Supabase refresh token (rotated on each use).
|
|
4
|
+
- dirs.json: per-MACHINE map of room code → absolute working directory, so one
|
|
5
|
+
account bridge can run each session's terminals in its own repo.
|
|
6
|
+
Phase 1 of the account-bridge spec (docs/specs/2026-06-16-account-bridge.md). */
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
const DIR = path.join(os.homedir(), '.thinkpool-pair')
|
|
12
|
+
const AUTH = path.join(DIR, 'auth.json')
|
|
13
|
+
const DIRS = path.join(DIR, 'dirs.json')
|
|
14
|
+
|
|
15
|
+
function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
|
|
16
|
+
|
|
17
|
+
export function saveAuth(session) {
|
|
18
|
+
if (!session?.refresh_token) return
|
|
19
|
+
ensureDir()
|
|
20
|
+
try {
|
|
21
|
+
fs.writeFileSync(AUTH, JSON.stringify({
|
|
22
|
+
refresh_token: session.refresh_token,
|
|
23
|
+
access_token: session.access_token || null,
|
|
24
|
+
email: session.user?.email || session.email || null,
|
|
25
|
+
savedAt: Date.now(),
|
|
26
|
+
}), { mode: 0o600 })
|
|
27
|
+
} catch { /* noop */ }
|
|
28
|
+
}
|
|
29
|
+
export function loadAuth() { try { return JSON.parse(fs.readFileSync(AUTH, 'utf8')) } catch { return null } }
|
|
30
|
+
export function clearAuth() { try { fs.unlinkSync(AUTH) } catch { /* noop */ } }
|
|
31
|
+
|
|
32
|
+
export function loadDirs() { try { return JSON.parse(fs.readFileSync(DIRS, 'utf8')) } catch { return {} } }
|
|
33
|
+
export function bindDir(room, dir) {
|
|
34
|
+
const d = loadDirs(); d[room] = dir
|
|
35
|
+
ensureDir()
|
|
36
|
+
try { fs.writeFileSync(DIRS, JSON.stringify(d, null, 2)) } catch { /* noop */ }
|
|
37
|
+
return d
|
|
38
|
+
}
|
|
39
|
+
export function unbindDir(room) {
|
|
40
|
+
const d = loadDirs(); delete d[room]
|
|
41
|
+
try { fs.writeFileSync(DIRS, JSON.stringify(d, null, 2)) } catch { /* noop */ }
|
|
42
|
+
return d
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"claude-session.mjs",
|
|
12
12
|
"session-store.mjs",
|
|
13
13
|
"service.mjs",
|
|
14
|
+
"account.mjs",
|
|
15
|
+
"auth-store.mjs",
|
|
14
16
|
"README.md"
|
|
15
17
|
],
|
|
16
18
|
"scripts": {
|