openplexer 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/LICENSE +21 -0
- package/dist/acp-client.d.ts +13 -0
- package/dist/acp-client.d.ts.map +1 -0
- package/dist/acp-client.js +88 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +308 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +26 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +4 -0
- package/dist/git.d.ts +14 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +60 -0
- package/dist/lock.d.ts +9 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +55 -0
- package/dist/notion.d.ts +59 -0
- package/dist/notion.d.ts.map +1 -0
- package/dist/notion.js +154 -0
- package/dist/startup-service.d.ts +9 -0
- package/dist/startup-service.d.ts.map +1 -0
- package/dist/startup-service.js +150 -0
- package/dist/sync.d.ts +7 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +139 -0
- package/dist/worker.d.ts +6 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +151 -0
- package/package.json +66 -0
- package/src/acp-client.ts +124 -0
- package/src/cli.ts +387 -0
- package/src/config.ts +63 -0
- package/src/env.ts +9 -0
- package/src/git.ts +84 -0
- package/src/lock.ts +65 -0
- package/src/notion.ts +233 -0
- package/src/startup-service.ts +175 -0
- package/src/sync.ts +193 -0
- package/src/worker.ts +187 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// openplexer CLI entrypoint.
|
|
4
|
+
// Syncs ACP sessions (from OpenCode or Claude Code) to Notion board databases.
|
|
5
|
+
// Uses goke for CLI parsing and clack for interactive prompts.
|
|
6
|
+
|
|
7
|
+
import { goke } from 'goke'
|
|
8
|
+
import {
|
|
9
|
+
intro,
|
|
10
|
+
outro,
|
|
11
|
+
note,
|
|
12
|
+
cancel,
|
|
13
|
+
isCancel,
|
|
14
|
+
confirm,
|
|
15
|
+
log,
|
|
16
|
+
multiselect,
|
|
17
|
+
select,
|
|
18
|
+
spinner,
|
|
19
|
+
} from '@clack/prompts'
|
|
20
|
+
import crypto from 'node:crypto'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import { exec } from 'node:child_process'
|
|
23
|
+
import { readConfig, writeConfig, type OpenplexerConfig, type OpenplexerBoard, type AcpClient } from './config.ts'
|
|
24
|
+
import { connectAcp, listAllSessions, type AcpConnection } from './acp-client.ts'
|
|
25
|
+
import { getRepoInfo } from './git.ts'
|
|
26
|
+
import { createNotionClient, createBoardDatabase, getRootPages } from './notion.ts'
|
|
27
|
+
import { evictExistingInstance, getLockPort, startLockServer } from './lock.ts'
|
|
28
|
+
import { startSyncLoop } from './sync.ts'
|
|
29
|
+
import {
|
|
30
|
+
enableStartupService,
|
|
31
|
+
disableStartupService,
|
|
32
|
+
isStartupServiceEnabled,
|
|
33
|
+
getServiceLocationDescription,
|
|
34
|
+
} from './startup-service.ts'
|
|
35
|
+
|
|
36
|
+
const OPENPLEXER_URL = 'https://openplexer.com'
|
|
37
|
+
|
|
38
|
+
process.title = 'openplexer'
|
|
39
|
+
|
|
40
|
+
const cli = goke('openplexer')
|
|
41
|
+
|
|
42
|
+
// Default command: start sync if boards exist, otherwise run connect wizard
|
|
43
|
+
cli
|
|
44
|
+
.command('', 'Sync coding sessions to Notion boards')
|
|
45
|
+
.action(async () => {
|
|
46
|
+
const config = readConfig()
|
|
47
|
+
if (!config || config.boards.length === 0) {
|
|
48
|
+
await connectFlow()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
await startDaemon(config)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Connect command: add a new board
|
|
55
|
+
cli.command('connect', 'Connect a new Notion board').action(async () => {
|
|
56
|
+
await connectFlow()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// Status command: show current sync state
|
|
60
|
+
cli.command('status', 'Show sync state').action(async () => {
|
|
61
|
+
const config = readConfig()
|
|
62
|
+
if (!config || config.boards.length === 0) {
|
|
63
|
+
console.log('No boards configured. Run `openplexer connect` to add one.')
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
console.log(`Clients: ${config.clients.join(', ')}`)
|
|
67
|
+
console.log(`Boards: ${config.boards.length}`)
|
|
68
|
+
config.boards.forEach((board, i) => {
|
|
69
|
+
console.log(
|
|
70
|
+
` ${i + 1}. ${board.notionWorkspaceName} — ${board.trackedRepos.length} repos, ${Object.keys(board.syncedSessions).length} synced sessions`,
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Stop command: kill running daemon via lock port
|
|
76
|
+
cli.command('stop', 'Stop the running openplexer daemon').action(async () => {
|
|
77
|
+
const port = getLockPort()
|
|
78
|
+
const probe = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
79
|
+
signal: AbortSignal.timeout(1000),
|
|
80
|
+
}).catch(() => {
|
|
81
|
+
return undefined
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
if (!probe) {
|
|
85
|
+
console.log('No running daemon found.')
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const body = (await probe.json().catch(() => ({}))) as { pid?: number }
|
|
90
|
+
if (body.pid) {
|
|
91
|
+
process.kill(body.pid, 'SIGTERM')
|
|
92
|
+
console.log(`Stopped daemon (PID ${body.pid})`)
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Boards command: list boards
|
|
97
|
+
cli.command('boards', 'List configured boards').action(async () => {
|
|
98
|
+
const config = readConfig()
|
|
99
|
+
if (!config || config.boards.length === 0) {
|
|
100
|
+
console.log('No boards configured.')
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
config.boards.forEach((board, i) => {
|
|
104
|
+
console.log(`${i + 1}. ${board.notionWorkspaceName}`)
|
|
105
|
+
console.log(` Page: https://notion.so/${board.notionPageId.replace(/-/g, '')}`)
|
|
106
|
+
console.log(` Repos: ${board.trackedRepos.join(', ') || '(all)'}`)
|
|
107
|
+
console.log(` Synced: ${Object.keys(board.syncedSessions).length} sessions`)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Startup command: manage startup registration
|
|
112
|
+
cli.command('startup', 'Show startup registration status').action(async () => {
|
|
113
|
+
const enabled = await isStartupServiceEnabled()
|
|
114
|
+
if (enabled) {
|
|
115
|
+
console.log(`Registered: ${getServiceLocationDescription()}`)
|
|
116
|
+
} else {
|
|
117
|
+
console.log('Not registered to run on login.')
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
cli
|
|
122
|
+
.command('startup enable', 'Register openplexer to run on login')
|
|
123
|
+
.action(async () => {
|
|
124
|
+
const openplexerBin = path.resolve(process.argv[1])
|
|
125
|
+
await enableStartupService({ command: process.execPath, args: [openplexerBin] })
|
|
126
|
+
console.log(`Registered at ${getServiceLocationDescription()}`)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
cli
|
|
130
|
+
.command('startup disable', 'Unregister openplexer from login')
|
|
131
|
+
.action(async () => {
|
|
132
|
+
await disableStartupService()
|
|
133
|
+
console.log('Unregistered from login startup.')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
cli.parse()
|
|
137
|
+
|
|
138
|
+
// --- Connect wizard ---
|
|
139
|
+
|
|
140
|
+
async function connectFlow(): Promise<void> {
|
|
141
|
+
intro('openplexer — connect a Notion board')
|
|
142
|
+
|
|
143
|
+
const config = readConfig() || ({ clients: [], boards: [] } as OpenplexerConfig)
|
|
144
|
+
|
|
145
|
+
// Step 1: Choose ACP clients (only on first run)
|
|
146
|
+
if (config.clients.length === 0) {
|
|
147
|
+
const clientChoice = await multiselect({
|
|
148
|
+
message: 'Which coding agents do you use?',
|
|
149
|
+
options: [
|
|
150
|
+
{ value: 'opencode' as const, label: 'OpenCode' },
|
|
151
|
+
{ value: 'claude' as const, label: 'Claude Code' },
|
|
152
|
+
],
|
|
153
|
+
required: true,
|
|
154
|
+
})
|
|
155
|
+
if (isCancel(clientChoice)) {
|
|
156
|
+
cancel('Setup cancelled')
|
|
157
|
+
process.exit(0)
|
|
158
|
+
}
|
|
159
|
+
config.clients = clientChoice
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Step 2: Spawn ACP for each client and discover projects
|
|
163
|
+
const s = spinner()
|
|
164
|
+
const clientLabel = config.clients.join(' + ')
|
|
165
|
+
s.start(`Connecting to ${clientLabel}...`)
|
|
166
|
+
|
|
167
|
+
let repoSlugs: string[] = []
|
|
168
|
+
const connectedClients: AcpClient[] = []
|
|
169
|
+
|
|
170
|
+
for (const client of config.clients) {
|
|
171
|
+
try {
|
|
172
|
+
const acp = await connectAcp({ client })
|
|
173
|
+
const sessions = await listAllSessions({ connection: acp.connection })
|
|
174
|
+
|
|
175
|
+
// Extract unique repos from session cwds
|
|
176
|
+
const cwds = [...new Set(sessions.map((sess) => sess.cwd).filter(Boolean))] as string[]
|
|
177
|
+
const repoInfos = await Promise.all(cwds.map((cwd) => getRepoInfo({ cwd })))
|
|
178
|
+
repoSlugs.push(...repoInfos.filter(Boolean).map((r) => r!.slug))
|
|
179
|
+
|
|
180
|
+
acp.kill()
|
|
181
|
+
connectedClients.push(client)
|
|
182
|
+
log.info(`${client}: ${sessions.length} sessions`)
|
|
183
|
+
} catch {
|
|
184
|
+
log.warn(`Could not connect to ${client}. Make sure "${client}" is installed and in PATH.`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
repoSlugs = [...new Set(repoSlugs)]
|
|
189
|
+
s.stop(`Found ${repoSlugs.length} repos from ${connectedClients.join(' + ')}`)
|
|
190
|
+
|
|
191
|
+
if (connectedClients.length === 0) {
|
|
192
|
+
log.error('Could not connect to any ACP agent.')
|
|
193
|
+
process.exit(1)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Step 3: Select repos to track
|
|
197
|
+
let trackedRepos: string[] = []
|
|
198
|
+
if (repoSlugs.length > 0) {
|
|
199
|
+
note(
|
|
200
|
+
'Select specific repos if you plan to collaborate.\nThis avoids showing personal projects on the shared board.',
|
|
201
|
+
'Repo selection',
|
|
202
|
+
)
|
|
203
|
+
const repoChoice = await multiselect({
|
|
204
|
+
message: 'Which repos to track?',
|
|
205
|
+
options: [
|
|
206
|
+
{ value: '*', label: '* All repos', hint: 'sync every repo with a git remote' },
|
|
207
|
+
...repoSlugs.map((slug) => ({
|
|
208
|
+
value: slug,
|
|
209
|
+
label: slug,
|
|
210
|
+
})),
|
|
211
|
+
],
|
|
212
|
+
required: false,
|
|
213
|
+
})
|
|
214
|
+
if (isCancel(repoChoice)) {
|
|
215
|
+
cancel('Setup cancelled')
|
|
216
|
+
process.exit(0)
|
|
217
|
+
}
|
|
218
|
+
trackedRepos = repoChoice.includes('*') ? [] : repoChoice
|
|
219
|
+
} else {
|
|
220
|
+
log.warn('No git repos found in sessions. All future sessions will be tracked.')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Step 4: Notion OAuth
|
|
224
|
+
const state = crypto.randomBytes(16).toString('hex')
|
|
225
|
+
const authUrl = `${OPENPLEXER_URL}/auth/notion?state=${state}`
|
|
226
|
+
|
|
227
|
+
note(`Opening browser to connect Notion.\nAuthorize the integration and select a page to share.\n\n${authUrl}`, 'Notion')
|
|
228
|
+
|
|
229
|
+
// Open browser
|
|
230
|
+
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
|
|
231
|
+
exec(`${openCmd} "${authUrl}"`)
|
|
232
|
+
|
|
233
|
+
s.start('Waiting for Notion authorization...')
|
|
234
|
+
|
|
235
|
+
// Poll for token
|
|
236
|
+
type AuthResult = {
|
|
237
|
+
accessToken: string
|
|
238
|
+
botId: string
|
|
239
|
+
workspaceId: string
|
|
240
|
+
workspaceName: string
|
|
241
|
+
notionUserId?: string
|
|
242
|
+
notionUserName?: string
|
|
243
|
+
}
|
|
244
|
+
let authResult: AuthResult | undefined
|
|
245
|
+
const maxAttempts = 150 // 5 minutes at 2s intervals
|
|
246
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
247
|
+
await new Promise((resolve) => {
|
|
248
|
+
setTimeout(resolve, 2000)
|
|
249
|
+
})
|
|
250
|
+
const resp = await fetch(`${OPENPLEXER_URL}/auth/status?state=${state}`, {
|
|
251
|
+
signal: AbortSignal.timeout(3000),
|
|
252
|
+
}).catch(() => {
|
|
253
|
+
return undefined
|
|
254
|
+
})
|
|
255
|
+
if (resp?.ok) {
|
|
256
|
+
authResult = (await resp.json()) as AuthResult
|
|
257
|
+
break
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!authResult) {
|
|
262
|
+
s.stop('Timed out waiting for Notion authorization')
|
|
263
|
+
process.exit(1)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
s.stop(`Connected to ${authResult.workspaceName}`)
|
|
267
|
+
|
|
268
|
+
// Step 5: Select Notion page from root pages
|
|
269
|
+
const notion = createNotionClient({ token: authResult.accessToken })
|
|
270
|
+
s.start('Fetching Notion pages...')
|
|
271
|
+
const rootPages = await getRootPages({ notion })
|
|
272
|
+
s.stop(`Found ${rootPages.length} root pages`)
|
|
273
|
+
|
|
274
|
+
if (rootPages.length === 0) {
|
|
275
|
+
log.error('No root pages found in your Notion workspace. Create a page first.')
|
|
276
|
+
process.exit(1)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Filter out pages already used by other boards
|
|
280
|
+
const usedPageIds = new Set(config.boards.map((b) => b.notionPageId))
|
|
281
|
+
const availablePages = rootPages.filter((p) => !usedPageIds.has(p.id))
|
|
282
|
+
|
|
283
|
+
if (availablePages.length === 0) {
|
|
284
|
+
log.error('All root pages are already connected to boards.')
|
|
285
|
+
process.exit(1)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const pageId: string = await (async () => {
|
|
289
|
+
if (availablePages.length === 1) {
|
|
290
|
+
log.info(`Auto-selected page: ${availablePages[0].icon} ${availablePages[0].title}`)
|
|
291
|
+
return availablePages[0].id
|
|
292
|
+
}
|
|
293
|
+
const pageChoice = await select({
|
|
294
|
+
message: 'Which Notion page should hold the board?',
|
|
295
|
+
options: availablePages.map((p) => ({
|
|
296
|
+
value: p.id,
|
|
297
|
+
label: `${p.icon} ${p.title}`.trim(),
|
|
298
|
+
hint: usedPageIds.has(p.id) ? 'already used' : undefined,
|
|
299
|
+
})),
|
|
300
|
+
})
|
|
301
|
+
if (isCancel(pageChoice)) {
|
|
302
|
+
cancel('Setup cancelled')
|
|
303
|
+
process.exit(0)
|
|
304
|
+
}
|
|
305
|
+
return pageChoice
|
|
306
|
+
})()
|
|
307
|
+
|
|
308
|
+
// Step 6: Create database
|
|
309
|
+
s.start('Creating board database...')
|
|
310
|
+
const { databaseId } = await createBoardDatabase({ notion, pageId })
|
|
311
|
+
s.stop('Board database created')
|
|
312
|
+
|
|
313
|
+
log.success('Open the database in Notion and click "+ Add a view" → Board, grouped by Status.')
|
|
314
|
+
|
|
315
|
+
// Step 7: Save to config
|
|
316
|
+
const board: OpenplexerBoard = {
|
|
317
|
+
notionToken: authResult.accessToken,
|
|
318
|
+
notionUserId: authResult.notionUserId || '',
|
|
319
|
+
notionUserName: authResult.notionUserName || '',
|
|
320
|
+
notionWorkspaceId: authResult.workspaceId,
|
|
321
|
+
notionWorkspaceName: authResult.workspaceName,
|
|
322
|
+
notionPageId: pageId,
|
|
323
|
+
notionDatabaseId: databaseId,
|
|
324
|
+
trackedRepos,
|
|
325
|
+
syncedSessions: {},
|
|
326
|
+
connectedAt: new Date().toISOString(),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
config.boards.push(board)
|
|
330
|
+
writeConfig(config)
|
|
331
|
+
|
|
332
|
+
// Resolve absolute path to the CLI script so startup service and
|
|
333
|
+
// detached spawn work regardless of cwd at login/invocation time.
|
|
334
|
+
const openplexerBin = path.resolve(process.argv[1])
|
|
335
|
+
|
|
336
|
+
// Step 8: Offer startup registration
|
|
337
|
+
const alreadyEnabled = await isStartupServiceEnabled()
|
|
338
|
+
if (!alreadyEnabled) {
|
|
339
|
+
const registerStartup = await confirm({
|
|
340
|
+
message: 'Register openplexer to run on login?',
|
|
341
|
+
})
|
|
342
|
+
if (!isCancel(registerStartup) && registerStartup) {
|
|
343
|
+
await enableStartupService({ command: process.execPath, args: [openplexerBin] })
|
|
344
|
+
log.success(`Registered at ${getServiceLocationDescription()}`)
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
log.info(`Already registered at ${getServiceLocationDescription()}`)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Step 9: Spawn daemon in background so syncing starts immediately
|
|
351
|
+
const { spawn: spawnProcess } = await import('node:child_process')
|
|
352
|
+
const child = spawnProcess(process.execPath, [openplexerBin], {
|
|
353
|
+
detached: true,
|
|
354
|
+
stdio: 'ignore',
|
|
355
|
+
})
|
|
356
|
+
child.unref()
|
|
357
|
+
|
|
358
|
+
outro('Board connected! Sync daemon started in background.')
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// --- Daemon ---
|
|
362
|
+
|
|
363
|
+
async function startDaemon(config: OpenplexerConfig): Promise<void> {
|
|
364
|
+
const port = getLockPort()
|
|
365
|
+
await evictExistingInstance({ port })
|
|
366
|
+
startLockServer({ port })
|
|
367
|
+
|
|
368
|
+
console.log(`openplexer daemon started (PID ${process.pid}, port ${port})`)
|
|
369
|
+
|
|
370
|
+
const connections: AcpConnection[] = []
|
|
371
|
+
for (const client of config.clients) {
|
|
372
|
+
try {
|
|
373
|
+
const acp = await connectAcp({ client })
|
|
374
|
+
connections.push(acp)
|
|
375
|
+
console.log(`Connected to ${client} via ACP`)
|
|
376
|
+
} catch {
|
|
377
|
+
console.error(`Failed to connect to ${client}, skipping`)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (connections.length === 0) {
|
|
382
|
+
console.error('Could not connect to any ACP agent.')
|
|
383
|
+
process.exit(1)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await startSyncLoop({ config, acpConnections: connections })
|
|
387
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Typed config for openplexer, stored at ~/.openplexer/config.json.
|
|
2
|
+
// Supports multiple boards — each board is a separate Notion database
|
|
3
|
+
// that this CLI syncs ACP sessions to.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
|
|
9
|
+
export type OpenplexerBoard = {
|
|
10
|
+
/** Notion OAuth access token */
|
|
11
|
+
notionToken: string
|
|
12
|
+
/** Notion user ID of this machine's user */
|
|
13
|
+
notionUserId: string
|
|
14
|
+
/** Notion user name */
|
|
15
|
+
notionUserName: string
|
|
16
|
+
/** Notion workspace ID */
|
|
17
|
+
notionWorkspaceId: string
|
|
18
|
+
/** Notion workspace name */
|
|
19
|
+
notionWorkspaceName: string
|
|
20
|
+
/** Notion page ID where database was created */
|
|
21
|
+
notionPageId: string
|
|
22
|
+
/** Notion database ID (created by CLI) */
|
|
23
|
+
notionDatabaseId: string
|
|
24
|
+
/** Git repo URLs to track (e.g. ["owner/repo1", "owner/repo2"]) */
|
|
25
|
+
trackedRepos: string[]
|
|
26
|
+
/** Map of ACP session ID → Notion page ID (already synced) */
|
|
27
|
+
syncedSessions: Record<string, string>
|
|
28
|
+
/** ISO timestamp of when this board was connected. Only sessions
|
|
29
|
+
* created or last updated after this time are synced. */
|
|
30
|
+
connectedAt: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AcpClient = 'opencode' | 'claude'
|
|
34
|
+
|
|
35
|
+
export type OpenplexerConfig = {
|
|
36
|
+
/** ACP clients to connect to (user may use both opencode and claude) */
|
|
37
|
+
clients: AcpClient[]
|
|
38
|
+
/** Multiple boards this CLI syncs to */
|
|
39
|
+
boards: OpenplexerBoard[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CONFIG_DIR = path.join(os.homedir(), '.openplexer')
|
|
43
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
|
|
44
|
+
|
|
45
|
+
export function getConfigDir(): string {
|
|
46
|
+
return CONFIG_DIR
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readConfig(): OpenplexerConfig | undefined {
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8')
|
|
52
|
+
return JSON.parse(raw) as OpenplexerConfig
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function writeConfig(config: OpenplexerConfig): void {
|
|
59
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
60
|
+
const tmpFile = CONFIG_FILE + '.tmp'
|
|
61
|
+
fs.writeFileSync(tmpFile, JSON.stringify(config, null, 2))
|
|
62
|
+
fs.renameSync(tmpFile, CONFIG_FILE)
|
|
63
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Typed environment variables for the Cloudflare Worker.
|
|
2
|
+
// NOTION_CLIENT_ID and NOTION_CLIENT_SECRET are the openplexer Notion
|
|
3
|
+
// integration's OAuth2 credentials, used to exchange auth codes for tokens.
|
|
4
|
+
|
|
5
|
+
export type Env = {
|
|
6
|
+
OPENPLEXER_KV: KVNamespace
|
|
7
|
+
NOTION_CLIENT_ID: string
|
|
8
|
+
NOTION_CLIENT_SECRET: string
|
|
9
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Extract git repo info from session cwd paths.
|
|
2
|
+
// Parses the remote origin URL to get owner/repo.
|
|
3
|
+
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
|
|
6
|
+
export type RepoInfo = {
|
|
7
|
+
owner: string
|
|
8
|
+
repo: string
|
|
9
|
+
/** e.g. "owner/repo" */
|
|
10
|
+
slug: string
|
|
11
|
+
/** Full GitHub URL */
|
|
12
|
+
url: string
|
|
13
|
+
/** Current branch name */
|
|
14
|
+
branch: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getRepoInfo({ cwd }: { cwd: string }): Promise<RepoInfo | undefined> {
|
|
18
|
+
const remoteUrl = await execAsync('git', ['-C', cwd, 'remote', 'get-url', 'origin']).catch(
|
|
19
|
+
() => {
|
|
20
|
+
return undefined
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
if (!remoteUrl) {
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parsed = parseGitRemoteUrl(remoteUrl.trim())
|
|
28
|
+
if (!parsed) {
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const branch = await execAsync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD']).catch(
|
|
33
|
+
() => {
|
|
34
|
+
return 'main'
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
...parsed,
|
|
40
|
+
branch: branch.trim(),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseGitRemoteUrl(url: string): { owner: string; repo: string; slug: string; url: string } | undefined {
|
|
45
|
+
// SSH: git@github.com:owner/repo.git
|
|
46
|
+
const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/.]+)/)
|
|
47
|
+
if (sshMatch) {
|
|
48
|
+
const owner = sshMatch[1]
|
|
49
|
+
const repo = sshMatch[2]
|
|
50
|
+
return {
|
|
51
|
+
owner,
|
|
52
|
+
repo,
|
|
53
|
+
slug: `${owner}/${repo}`,
|
|
54
|
+
url: `https://github.com/${owner}/${repo}`,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
59
|
+
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/)
|
|
60
|
+
if (httpsMatch) {
|
|
61
|
+
const owner = httpsMatch[1]
|
|
62
|
+
const repo = httpsMatch[2]
|
|
63
|
+
return {
|
|
64
|
+
owner,
|
|
65
|
+
repo,
|
|
66
|
+
slug: `${owner}/${repo}`,
|
|
67
|
+
url: `https://github.com/${owner}/${repo}`,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function execAsync(cmd: string, args: string[]): Promise<string> {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
execFile(cmd, args, { timeout: 5000 }, (error, stdout) => {
|
|
77
|
+
if (error) {
|
|
78
|
+
reject(error)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
resolve(stdout)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
}
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Single-instance enforcement via lock port.
|
|
2
|
+
// Same pattern as kimaki's hrana-server.ts: probe /health, SIGTERM, SIGKILL.
|
|
3
|
+
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_LOCK_PORT = 29990
|
|
7
|
+
|
|
8
|
+
export function getLockPort(): number {
|
|
9
|
+
const envPort = process.env['OPENPLEXER_LOCK_PORT']
|
|
10
|
+
if (envPort) {
|
|
11
|
+
const parsed = Number.parseInt(envPort, 10)
|
|
12
|
+
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) {
|
|
13
|
+
return parsed
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return DEFAULT_LOCK_PORT
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function evictExistingInstance({ port }: { port: number }): Promise<void> {
|
|
20
|
+
const url = `http://127.0.0.1:${port}/health`
|
|
21
|
+
const probe = await fetch(url, { signal: AbortSignal.timeout(1000) }).catch(() => {
|
|
22
|
+
return undefined
|
|
23
|
+
})
|
|
24
|
+
if (!probe) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const body = (await probe.json().catch(() => ({}))) as { pid?: number }
|
|
29
|
+
const targetPid = body.pid
|
|
30
|
+
if (!targetPid || targetPid === process.pid) {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
process.kill(targetPid, 'SIGTERM')
|
|
35
|
+
await new Promise((resolve) => {
|
|
36
|
+
setTimeout(resolve, 1000)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const secondProbe = await fetch(url, { signal: AbortSignal.timeout(500) }).catch(() => {
|
|
40
|
+
return undefined
|
|
41
|
+
})
|
|
42
|
+
if (!secondProbe) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
process.kill(targetPid, 'SIGKILL')
|
|
47
|
+
await new Promise((resolve) => {
|
|
48
|
+
setTimeout(resolve, 1000)
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function startLockServer({ port }: { port: number }): http.Server {
|
|
53
|
+
const server = http.createServer((req, res) => {
|
|
54
|
+
if (req.url === '/health') {
|
|
55
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
56
|
+
res.end(JSON.stringify({ pid: process.pid, status: 'ok' }))
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
res.writeHead(404)
|
|
60
|
+
res.end()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
server.listen(port, '127.0.0.1')
|
|
64
|
+
return server
|
|
65
|
+
}
|