sintbot 1.4.16 → 1.4.18

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,447 @@
1
+ 'use strict'
2
+ // Setup mode: `sintbot start --ui` with no configuration serves a local page
3
+ // that runs the scan-to-connect flow in this process. The page collects the
4
+ // inputs `sintbot connect` would take as flags, shows the QR code, streams the
5
+ // step notices, and offers the resume paths (existing app, hand-made group)
6
+ // as buttons. Once the configuration is written and initialized, the server
7
+ // closes and the caller continues into the normal start.
8
+ //
9
+ // Everything that decides the flow stays in src/connect.js and the core; this
10
+ // module only adapts it to HTTP: arguments in, captured notices out.
11
+ const http = require('node:http')
12
+ const path = require('node:path')
13
+ const { spawnSync } = require('node:child_process')
14
+ const { runConnect, qrPage, probeRunners: connectProbe, EXIT_INTERRUPTED } = require('./connect')
15
+
16
+ const QR_URL = /^https:\/\/[^\s]+/
17
+ const APP_ID_LINE = /^应用 ID:(cli_[A-Za-z0-9]{6,64})$/m
18
+ const CHAT_HINT = /--chat <oc_…>/
19
+ const APP_ID = /^cli_[A-Za-z0-9]{6,64}$/
20
+ const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
21
+ const PROJECT_NAME = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,59}$/u
22
+ const RUNNERS = Object.freeze(['codex', 'claude'])
23
+ const BODY_LIMIT = 16 * 1024
24
+
25
+ // The same probe runConnect uses, so the page never offers a runner the run
26
+ // would then refuse.
27
+ function probeRunners(spawn = spawnSync, env = process.env) {
28
+ try { return connectProbe(spawn, env) } catch { return Object.fromEntries(RUNNERS.map(r => [r, false])) }
29
+ }
30
+
31
+ function svgOf(url) {
32
+ const m = /<svg[\s\S]*?<\/svg>/.exec(qrPage(url))
33
+ return m ? m[0] : ''
34
+ }
35
+
36
+ // One attempt = one `runConnect` run with captured output. The state object
37
+ // is what the page polls; it carries no secrets (the app secret never reaches
38
+ // stdout/stderr by design of runConnect).
39
+ function createSetupSession({ configFile, dataDir, home, version, runners, connect = runConnect, deps = {},
40
+ initialize = (file, options) => require('./c2install').initializeInstallation(file, options), log = () => {} }) {
41
+ const state = {
42
+ phase: 'form', // form | running | waitingChat | done | failed
43
+ lines: [], // notices in order (stderr of runConnect)
44
+ qr: null, // inline SVG once the scan link is known
45
+ appId: null, // printed by runConnect after the scan; reused by every retry
46
+ notice: '', // last notice when stopped or failed
47
+ groupAppId: null, // app owning the confirmed group
48
+ chatId: null, // a confirmed created group, reused after cancellation or retry
49
+ initOnly: false, // the configuration is written; only the journals failed
50
+ modelDefaults: null, // the live recommendation confirmed before scanning
51
+ runners, // which executors answered --version
52
+ defaults: { name: 'sintbot', projectsRoot: path.join(home, 'Projects') },
53
+ startedAt: null, endedAt: null,
54
+ }
55
+ let signalHandlers = new Map()
56
+ let current = null
57
+ let finish
58
+ const completed = new Promise(resolve => { finish = resolve })
59
+
60
+ const argvFor = (input) => {
61
+ const argv = ['--config', configFile, '--no-open']
62
+ if (dataDir) argv.push('--data-dir', dataDir)
63
+ if (input.workdir) argv.push('--workdir', input.workdir)
64
+ else argv.push('--name', input.name || 'sintbot')
65
+ if (input.provider) argv.push('--provider', input.provider)
66
+ if (input.basic) argv.push('--basic')
67
+ if (input.appId) argv.push('--app-id', input.appId)
68
+ if (input.chatId) argv.push('--chat', input.chatId)
69
+ return argv
70
+ }
71
+
72
+ const validate = (input) => {
73
+ if (!input || typeof input !== 'object') throw new Error('请填写接入信息')
74
+ const out = {}
75
+ if (input.workdir !== undefined && input.workdir !== '') {
76
+ if (typeof input.workdir !== 'string' || !path.isAbsolute(input.workdir)) throw new Error('项目目录必须是绝对路径')
77
+ out.workdir = path.resolve(input.workdir)
78
+ } else {
79
+ const name = input.name === undefined || input.name === '' ? 'sintbot' : String(input.name)
80
+ if (!PROJECT_NAME.test(name) || name === '.' || name === '..') throw new Error('项目名只能用字母、数字、"."、"_" 和 "-"(最长 60)')
81
+ out.name = name
82
+ }
83
+ if (input.provider !== undefined && input.provider !== '' && input.provider !== 'auto') {
84
+ if (!RUNNERS.includes(input.provider)) throw new Error('执行器只能是 codex 或 claude')
85
+ out.provider = input.provider
86
+ }
87
+ out.basic = input.basic === true
88
+ if (input.appId !== undefined && input.appId !== '') {
89
+ if (!APP_ID.test(input.appId)) throw new Error('应用 ID 应形如 cli_…')
90
+ out.appId = input.appId
91
+ }
92
+ if (input.chatId !== undefined && input.chatId !== '') {
93
+ if (!CHAT_ID.test(input.chatId)) throw new Error('群 ID 应形如 oc_…')
94
+ out.chatId = input.chatId
95
+ }
96
+ return out
97
+ }
98
+
99
+ function start(rawInput) {
100
+ if (state.phase === 'running') throw new Error('接入正在进行')
101
+ if (state.phase === 'done') throw new Error('接入已完成')
102
+ if (state.initOnly) return retryInit()
103
+ // A resume (hand-made group) keeps the directory and options of the run
104
+ // that failed, even if the page was reloaded in between.
105
+ if (rawInput && rawInput.chatId && state.input) {
106
+ const keep = state.input
107
+ rawInput = { name: keep.name || undefined, workdir: keep.workdir || undefined, provider: keep.provider || undefined, basic: keep.basic, ...rawInput }
108
+ for (const k of ['name', 'workdir', 'provider']) if (rawInput[k] === '' || rawInput[k] === null) rawInput[k] = keep[k] || undefined
109
+ }
110
+ const input = validate(rawInput)
111
+ // Every retry reuses the app the first scan created: no second app in the
112
+ // organization, and the same --app-id resume the terminal route offers.
113
+ if (!input.appId && state.appId) input.appId = state.appId
114
+ if (!input.chatId && state.chatId && input.appId === state.groupAppId) input.chatId = state.chatId
115
+ state.phase = 'running'
116
+ state.modelDefaults = null
117
+ state.lines = []
118
+ state.qr = null
119
+ state.notice = ''
120
+ state.startedAt = Date.now()
121
+ state.endedAt = null
122
+ state.input = { name: input.name || null, workdir: input.workdir || null, provider: input.provider || null, basic: input.basic }
123
+ signalHandlers = new Map()
124
+ const stderrText = { buf: '' }
125
+ const write = (sink) => (chunk) => {
126
+ sink.buf += String(chunk)
127
+ let nl
128
+ while ((nl = sink.buf.indexOf('\n')) >= 0) {
129
+ const line = sink.buf.slice(0, nl)
130
+ sink.buf = sink.buf.slice(nl + 1)
131
+ sink.onLine(line)
132
+ }
133
+ }
134
+ const stdout = { buf: '', onLine: line => {
135
+ if (QR_URL.test(line)) { if (!state.qr) { state.qr = svgOf(line); state.lines.push('请用手机飞书扫描页面上的二维码。') }; return }
136
+ // The final notice arrives on stdout; the start command does not apply here.
137
+ if (line.trim() && !/\bstart --config\b/.test(line)) state.lines.push(line)
138
+ } }
139
+ stderrText.onLine = line => {
140
+ if (!line.trim()) return
141
+ state.lines.push(line)
142
+ const m = APP_ID_LINE.exec(line)
143
+ if (m) { state.appId = m[1]; state.qr = null }
144
+ }
145
+ const run = (async () => {
146
+ let code
147
+ try {
148
+ code = await connect(argvFor(input), { ...deps, stdout: { write: write(stdout) }, stderr: { write: write(stderrText) },
149
+ isTTY: false, version, env: process.env, home,
150
+ onModelDefaults: value => { state.modelDefaults = { provider: value.provider, model: value.model, effort: value.effort, name: value.name, source: value.source } },
151
+ onGroupReady: ({ appId, chatId }) => { state.appId = appId; state.groupAppId = appId; state.chatId = chatId },
152
+ signals: { on: (sig, fn) => signalHandlers.set(sig, fn), off: (sig) => signalHandlers.delete(sig) } })
153
+ } catch (error) {
154
+ code = 1
155
+ state.lines.push(error?.message || String(error))
156
+ }
157
+ if (stdout.buf.trim()) stdout.onLine(stdout.buf)
158
+ if (stderrText.buf.trim()) stderrText.onLine(stderrText.buf)
159
+ if (code === EXIT_INTERRUPTED) state.lines.push('已取消,没有写入任何文件。')
160
+ state.qr = null
161
+ state.endedAt = Date.now()
162
+ const last = [...state.lines].reverse().find(l => l.trim()) || ''
163
+ state.notice = last
164
+ if (code === 0) {
165
+ state.phase = 'done'
166
+ log('setup: configuration written and initialized')
167
+ finish(true)
168
+ } else if (state.appId && state.lines.some(l => CHAT_HINT.test(l))) {
169
+ state.phase = 'waitingChat'
170
+ } else {
171
+ state.phase = 'failed'
172
+ // The configuration exists but the journals do not: retry only that step.
173
+ if (state.lines.some(l => /^初始化失败/.test(l))) state.initOnly = true
174
+ }
175
+ current = null
176
+ })()
177
+ current = run
178
+ return state
179
+ }
180
+
181
+ function retryInit() {
182
+ state.phase = 'running'; state.notice = ''; state.startedAt = Date.now()
183
+ const run = (async () => {
184
+ try {
185
+ initialize(configFile, { dataDir })
186
+ state.initOnly = false
187
+ state.lines.push('初始化完成。')
188
+ state.phase = 'done'
189
+ state.endedAt = Date.now()
190
+ finish(true)
191
+ } catch (error) {
192
+ state.lines.push(`初始化失败(${error?.message || error})。`)
193
+ state.notice = state.lines[state.lines.length - 1]
194
+ state.phase = 'failed'
195
+ state.endedAt = Date.now()
196
+ }
197
+ current = null
198
+ })()
199
+ current = run
200
+ return state
201
+ }
202
+
203
+ // Signals the run exactly like Ctrl-C; runConnect stops at its next step
204
+ // boundary and writes nothing. False when nothing can be cancelled.
205
+ function cancel() {
206
+ if (state.phase !== 'running') return false
207
+ const handler = signalHandlers.get('SIGINT') || signalHandlers.get('SIGTERM')
208
+ if (!handler) return false
209
+ handler()
210
+ return true
211
+ }
212
+
213
+ function reset() {
214
+ if (state.phase === 'running' || state.phase === 'done') return false
215
+ state.phase = 'form'; state.lines = []; state.qr = null; state.notice = ''
216
+ return true
217
+ }
218
+
219
+ const snapshot = () => ({ ...state, lines: state.lines.slice(-60) })
220
+ void EXIT_INTERRUPTED
221
+ return { state, snapshot, start, cancel, reset, completed, wait: () => current }
222
+ }
223
+
224
+ function createSetupServer(options) {
225
+ const { host = '127.0.0.1', port = 4600, version = '', log = () => {}, page = setupPage } = options
226
+ if (!['127.0.0.1', 'localhost', '::1'].includes(host)) throw new Error('Setup mode only listens on this Mac')
227
+ const session = createSetupSession(options)
228
+ const json = (res, status, body) => {
229
+ const text = JSON.stringify(body)
230
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(text) })
231
+ res.end(text)
232
+ }
233
+ const readBody = (req) => new Promise((resolve, reject) => {
234
+ let size = 0, raw = ''
235
+ if (Number(req.headers['content-length']) > BODY_LIMIT) { const e = new Error('请求过大'); e.status = 413; return reject(e) }
236
+ req.on('data', chunk => { size += chunk.length; if (size > BODY_LIMIT) { const e = new Error('请求过大'); e.status = 413; reject(e) } else raw += chunk })
237
+ req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}) } catch { reject(new Error('请求不是 JSON')) } })
238
+ req.on('error', reject)
239
+ })
240
+ let boundPort = port
241
+ const localHost = value => /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(value || '') &&
242
+ (Number((value.match(/:(\d+)$/) || [])[1] || 80) === boundPort)
243
+ const server = http.createServer(async (req, res) => {
244
+ try {
245
+ // Local page only: the Host must be this listener (no DNS rebinding) and
246
+ // a browser Origin, when present, must be the same host and port.
247
+ if (!localHost(req.headers.host)) return json(res, 403, { error: 'forbidden' })
248
+ const origin = req.headers.origin
249
+ if (origin) {
250
+ let originHost = null
251
+ try { const parsed = new URL(origin); if (parsed.protocol === 'http:') originHost = parsed.host } catch {}
252
+ if (!originHost || originHost !== req.headers.host) return json(res, 403, { error: 'forbidden' })
253
+ }
254
+ const url = new URL(req.url, 'http://127.0.0.1')
255
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/setup')) {
256
+ const html = page({ version })
257
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
258
+ return res.end(html)
259
+ }
260
+ if (req.method === 'GET' && url.pathname === '/api/setup/state') return json(res, 200, session.snapshot())
261
+ if (req.method === 'POST' && url.pathname === '/api/setup/start') {
262
+ if (!/^application\/json/.test(req.headers['content-type'] || '')) return json(res, 415, { error: '需要 JSON' })
263
+ const body = await readBody(req)
264
+ return json(res, 200, { ok: true, state: session.start(body) && session.snapshot() })
265
+ }
266
+ if (req.method === 'POST' && url.pathname === '/api/setup/cancel') return json(res, 200, { ok: session.cancel() })
267
+ if (req.method === 'POST' && url.pathname === '/api/setup/reset') return json(res, 200, { ok: session.reset() })
268
+ if (req.method === 'GET') { res.writeHead(302, { location: '/' }); return res.end() }
269
+ return json(res, 404, { error: 'not found' })
270
+ } catch (error) {
271
+ if (res.headersSent || res.destroyed) return
272
+ return json(res, error?.status || 400, { error: error?.message || String(error) })
273
+ }
274
+ })
275
+ server.keepAliveTimeout = 1000
276
+ server.on('clientError', (error, socket) => {
277
+ if (!socket.destroyed) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
278
+ })
279
+ return {
280
+ server, session,
281
+ listen: () => new Promise((resolve, reject) => {
282
+ server.once('error', reject)
283
+ server.listen(port, host, () => {
284
+ server.off('error', reject)
285
+ boundPort = server.address().port
286
+ server.on('error', error => log(`setup server error: ${error.message}`))
287
+ resolve(server.address())
288
+ })
289
+ }),
290
+ // Resolves once the configuration is written; the server is then closed
291
+ // after a short grace so the page can read the final state.
292
+ completed: session.completed,
293
+ close: () => new Promise(resolve => { server.closeAllConnections?.(); server.close(() => resolve()) }),
294
+ log,
295
+ }
296
+ }
297
+
298
+ // Display-only translations: the core's original notices still drive the session.
299
+ // Unknown upstream details retain machine-readable error codes without mixing UI languages.
300
+ function translateSetupNotice(value, lang = 'en') {
301
+ const line = String(value || '')
302
+ if (lang === 'zh' && /^Could not read live model defaults for /.test(line)) return '无法读取执行器实时推荐的型号和思考深度。请检查执行器登录和网络后重试;本次没有新建飞书应用。'
303
+ if (lang === 'zh' && /^The live model catalog did not provide/.test(line)) return '实时模型清单没有提供有效的型号和思考深度,请检查执行器登录后重试。'
304
+ if (lang === 'zh') return line.replace(/然后执行 sintbot connect.*$/, '然后在此页面填写群 ID 并继续。')
305
+ .replace(/重新执行 sintbot connect(?: --app-id \S+)?[。]?/g, '在此页面重试。')
306
+ .replace(/可手动执行 sintbot init 重试。/, '请点击「重试初始化」。')
307
+ const exact = {
308
+ '请填写接入信息': 'Enter the project details.',
309
+ '项目目录必须是绝对路径': 'Enter an absolute path for the project folder.',
310
+ '项目名只能用字母、数字、"."、"_" 和 "-"(最长 60)': 'Use letters, digits, ".", "_" or "-" for the project name (up to 60 characters).',
311
+ '执行器只能是 codex 或 claude': 'Choose Codex or Claude Code.',
312
+ '应用 ID 应形如 cli_…': 'An app ID must start with cli_.',
313
+ '群 ID 应形如 oc_…': 'A group ID must start with oc_.',
314
+ '正在读取执行器推荐的型号和思考深度…': 'Reading the executor’s recommended model and thinking depth…',
315
+ '接入正在进行': 'Setup is already running.', '接入已完成': 'Setup is already complete.',
316
+ '请求过大': 'The request is too large.', '请求不是 JSON': 'The request is not valid JSON.', '需要 JSON': 'Send a JSON request.',
317
+ '请用手机飞书扫描页面上的二维码。': 'Scan this QR code with Feishu on your phone.',
318
+ '请先确认手机飞书当前在企业/团队身份下;没有组织的个人身份扫不了,需先新建一个团队(免费)。': 'Switch Feishu on your phone to a company or team account. A personal account without an organization cannot connect; create a free team first.',
319
+ '等待扫码…': 'Waiting for you to scan…', '等待扫码…(已放慢轮询)': 'Waiting for you to scan… (checking less often)',
320
+ '已切换到 Lark 国际版域名。': 'Switched to the international Lark service.',
321
+ '已取消,没有写入任何文件。': 'Cancelled. No configuration or project files were written.',
322
+ '已获取应用凭证。': 'App credentials received.', '应用已启用。': 'The app is enabled.',
323
+ '当前是个人身份,飞书不允许它调用接口。请新建一个团队,切换过去后重新扫码。': 'Feishu does not allow this personal account to use the API. Create a team, switch to it, and scan again.',
324
+ '已提交,等组织管理员审核通过。': 'Submitted. Waiting for your organization administrator to approve the app.',
325
+ '应用未启用:请到飞书开发者后台启用该应用后重新执行 sintbot connect。': 'Enable the app in the Feishu developer console, then retry here.',
326
+ '自检通过。': 'Readiness checks passed.', '基础模式,跳过权限开通。': 'Basic setup: skipping permission changes.',
327
+ '基础模式,跳过发布。': 'Basic setup: skipping publication.', '权限已开通。': 'Permissions enabled.',
328
+ '已提交发布。': 'App publication submitted.', '使用你提供的群。': 'Using the group you provided.',
329
+ '群已创建并拉入机器人。': 'Group created and the bot added.', '初始化完成。': 'Initialization complete.',
330
+ '连接完成。': 'Connected.', '连接完成,去飞书群里 @机器人 试试吧。': 'Connected. Send your first task to the bot in the Feishu group.',
331
+ }
332
+ if (exact[line]) return exact[line]
333
+ let match
334
+ if ((match = /^新群默认:(.*) · (.*?)。接入后可在控制台修改。$/.exec(line))) return `New group defaults: ${match[1]} · ${match[2]}. You can change them in the console after setup.`
335
+ if ((match = /^二维码 (\d+) 秒内有效。$/.exec(line))) return `The QR code expires in ${match[1]} seconds.`
336
+ if ((match = /^应用 ID:(.*)$/.exec(line))) return `App ID: ${match[1]}`
337
+ if ((match = /^项目目录 (.*) 就绪。$/.exec(line))) return `Project folder ready: ${match[1]}`
338
+ if ((match = /^配置已写入 (.*)。$/.exec(line))) return `Configuration saved to ${match[1]}.`
339
+ if ((match = /^目录 (.*) 已存在且非空。/.exec(line))) return `The folder ${match[1]} already contains files. Choose another project name, or enter that folder in the existing-project field.`
340
+ if (/^目录 /.test(line)) return 'This folder is too broad to use as a project. Choose a specific project folder.'
341
+ const codes = [...line.matchAll(/(?:code[ ::]+|HTTP\s+|()(\d{3,})/g)].map(m => m[1])
342
+ const suffix = codes.length ? ` (code ${[...new Set(codes)].join(', ')})` : ''
343
+ const detail = /(([^)]+))/.exec(line)?.[1]
344
+ const extra = detail && !/[\u3400-\u9fff]/.test(detail) ? ` (${detail})` : suffix
345
+ if (/^二维码页面未能打开/.test(line)) return 'The separate QR window could not open. Use the QR code on this page.'
346
+ if (/^获取 tenant_access_token 失败/.test(line)) return `Could not obtain an app access token${extra}. Check that the app is available, then retry.`
347
+ if (/^查询应用状态失败/.test(line)) return `Could not read the app status${extra}. Retry after checking the app in the Feishu developer console.`
348
+ if (/^审核超时/.test(line)) return 'Approval is still pending. Retry after your organization administrator approves the app.'
349
+ if (/^自检未通过|^自检失败/.test(line)) return `Readiness checks failed${extra}. Review the bot, event subscriptions and permissions in the Feishu developer console, then retry.`
350
+ if (/^自检有提醒/.test(line)) return `Readiness checks reported warnings${extra}. Setup can continue, but some bot features may be unavailable. Review permissions and event subscriptions in the Feishu developer console.`
351
+ if (/^权限开通失败/.test(line)) return `Could not enable permissions${extra}. Setup can continue; ask an administrator to enable messaging and card permissions in the Feishu developer console.`
352
+ if (/^发布失败(210304)/.test(line)) return 'Publication failed (210304). Enable the bot as the default app ability in the Feishu developer console, then create and publish a version. Until publication, only the developer can use it.'
353
+ if (/^发布失败/.test(line)) return `Publication failed${extra}. Setup can continue. Publish a version in the Feishu developer console; until then, only the developer can use the bot.`
354
+ if (/^应用可用范围不含你|^自动建群失败/.test(line)) return `Could not create the group${extra}. Create a Feishu group, add the bot, then enter its group ID here to continue.`
355
+ if (/^创建项目目录失败/.test(line)) return `Could not create the project folder${extra}. Check folder permissions and retry.`
356
+ if (/^写入 .* 失败/.test(line)) return `Could not save the configuration${extra}. Check folder permissions and retry.`
357
+ if (/^初始化失败/.test(line)) return `Initialization failed${extra}. Your configuration is saved. Choose Retry initialization.`
358
+ if (/[\u3400-\u9fff]/.test(line)) return `Setup could not complete this step${suffix}. Check the app in the Feishu developer console and retry.`
359
+ return line
360
+ }
361
+
362
+ function setupPage({ version = '' } = {}) {
363
+ return String.raw`<!doctype html>
364
+ <html lang="en" data-sintbot-setup="true"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
365
+ <title>SintBot · Connect Feishu</title>
366
+ <style>
367
+ :root{--ink:#13171c;--muted:#6b7280;--line:#e5e7eb;--paper:#f6f5f1;--copper:#dd7d3d;--fail:#b91c1c;--ok:#15803d}
368
+ *{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.6 -apple-system,"PingFang SC","Helvetica Neue",sans-serif}
369
+ .wrap{max-width:720px;margin:0 auto;padding:40px 20px 60px}h1{font-size:24px;margin:0 0 6px}.sub{color:var(--muted);margin:0 0 26px}
370
+ .card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:22px 24px;margin-bottom:18px}
371
+ label.f{display:block;font-size:13px;font-weight:600;margin:14px 0 6px}input[type=text]{width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:8px;font:inherit}
372
+ .hint{color:var(--muted);font-size:13px;margin:6px 0 0}.row{display:flex;gap:16px;flex-wrap:wrap;align-items:center}
373
+ .btn{display:inline-block;padding:10px 18px;border-radius:8px;border:1px solid var(--line);background:#fff;font:inherit;cursor:pointer;min-height:40px}
374
+ .btn.primary{background:var(--copper);border-color:var(--copper);color:#fff}.btn:disabled{opacity:.5;cursor:default}
375
+ .qr{display:flex;justify-content:center;padding:10px 0}.qr svg{width:min(300px,100%);height:auto;aspect-ratio:1}
376
+ .log{font-size:13px;line-height:1.7;max-height:320px;overflow:auto;background:var(--paper);border-radius:8px;padding:12px 14px;white-space:pre-wrap;word-break:break-word}
377
+ .status{font-weight:600}.status.failed{color:var(--fail)}.status.done{color:var(--ok)}
378
+ .steps{margin:0;padding-left:20px;color:var(--muted);font-size:13px}.hidden{display:none}
379
+ </style></head><body><div class="wrap">
380
+ <div class="row" style="justify-content:space-between"><h1 data-i18n="title">Connect your project to Feishu</h1><label class="hint">Language <select id="language" aria-label="Language"><option value="en">English</option><option value="zh">简体中文</option></select></label></div><p class="sub">SintBot ${String(version).replace(/[&<>"\']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c])} · <span data-i18n="intro">This page is only available on this Mac. After you confirm the scan, setup creates the app, project folder and group, then saves your configuration.</span></p>
381
+
382
+ <div class="card" id="form">
383
+ <ol class="steps"><li data-i18n="step1">Name a new project, or choose an existing folder</li><li data-i18n="step2">Scan with Feishu on your phone and confirm (a company or team account is required)</li><li data-i18n="step3">Wait for setup to create the app and group, then send your first task in the new group</li></ol>
384
+ <label class="f" for="name" data-i18n="name">Project name</label><input type="text" id="name" value="sintbot" autocomplete="off">
385
+ <div class="hint"><span data-i18n="rootBefore">A new folder with a README will be created under</span> <span id="root"></span><span data-i18n="rootAfter">.</span></div>
386
+ <label class="f" for="workdir" data-i18n="workdir">Or use an existing project (absolute path; leave blank to create one)</label><input type="text" id="workdir" placeholder="/Users/you/Projects/my-app" autocomplete="off">
387
+ <label class="f" data-i18n="executor">Executor</label>
388
+ <div class="row" id="providers"></div>
389
+ <div class="hint"><span data-i18n="executorHint">Choose if both are installed. A single available executor is selected automatically.</span></div>
390
+ <div class="hint" data-i18n="modelHint">Before scanning, SintBot reads the executor’s live recommended model and thinking depth and saves them for the new group. You can change them in the console after setup.</div>
391
+ <label class="f"><input type="checkbox" id="basic"> <span data-i18n="basic">I am not an organization administrator (basic setup)</span></label>
392
+ <div class="hint"><span data-i18n="basicHint">Skip availability changes and publication. Ask an administrator to finish these steps in the Feishu developer console.</span></div>
393
+ <div class="row" style="margin-top:18px"><button class="btn primary" id="start" disabled data-i18n="start">Start scan</button><span class="hint" id="formErr"></span></div>
394
+ </div>
395
+
396
+ <div class="card hidden" id="run">
397
+ <div class="row" style="justify-content:space-between"><span class="status" id="status">Preparing…</span><button class="btn" id="cancel" data-i18n="cancel">Cancel</button></div>
398
+ <div class="hint hidden" id="modelDefaults" style="margin:10px 0;overflow-wrap:anywhere"></div>
399
+ <div class="qr" id="qr"></div>
400
+ <div class="log" id="log"></div>
401
+ <div id="chat" class="hidden">
402
+ <div class="hint" style="margin-top:12px"><span data-i18n="chatHint">The group could not be created automatically. Create a Feishu group, add the bot, then paste its group ID (oc_…) here:</span></div>
403
+ <div class="row" style="margin-top:8px"><input type="text" id="chatId" placeholder="oc_…" style="flex:1;min-width:0;width:100%"><button class="btn primary" id="resume" data-i18n="resume">Continue</button></div>
404
+ </div>
405
+ <div id="retry" class="hidden"><div class="row" style="margin-top:12px"><button class="btn" id="back" data-i18n="back">Edit details</button><button class="btn primary" id="again" data-i18n="again">Retry</button></div></div>
406
+ <div id="chatBack" class="hidden"><div class="row" style="margin-top:8px"><button class="btn" id="back2" data-i18n="back2">Back to project details</button></div></div>
407
+ <div id="doneBox" class="hidden"><div class="hint" style="margin-top:12px"><span data-i18n="doneHint">Your configuration is saved. SintBot is connecting to Feishu and this page will open the console. Try asking the bot in the new group to read the first line of README.</span></div></div>
408
+ </div>
409
+ </div>
410
+ <script>
411
+ (function(){
412
+ const $=s=>document.querySelector(s);
413
+ const translate=${translateSetupNotice.toString()};
414
+ const words={en:{modelHint:'Before scanning, SintBot reads the executor’s live recommended model and thinking depth and saves them for the new group. You can change them in the console after setup.',modelDefaults:'New group defaults',thinking:'thinking depth',title:'Connect your project to Feishu',intro:'This page is only available on this Mac. After you confirm the scan, setup creates the app, project folder and group, then saves your configuration.',step1:'Name a new project, or choose an existing folder',step2:'Scan with Feishu on your phone and confirm (a company or team account is required)',step3:'Wait for setup to create the app and group, then send your first task in the new group',name:'Project name',rootBefore:'A new folder with a README will be created under',rootAfter:'.',workdir:'Or use an existing project (absolute path; leave blank to create one)',executor:'Executor',executorHint:'Choose if both are installed. A single available executor is selected automatically.',basic:'I am not an organization administrator (basic setup)',basicHint:'Skip availability changes and publication. Ask an administrator to finish these steps in the Feishu developer console.',start:'Start scan',cancel:'Cancel',chatHint:'The group could not be created automatically. Create a Feishu group, add the bot, then paste its group ID (oc_…) here:',resume:'Continue',back:'Edit details',again:'Retry',back2:'Back to project details',doneHint:'Your configuration is saved. SintBot is connecting to Feishu and this page will open the console. Try asking the bot in the new group to read the first line of README.',noRunner:'No available executor was detected. Install Codex or Claude Code and sign in, then reopen this page.',waiting:'Waiting for scan confirmation…',preparing:'Preparing…',chat:'Create a group to continue',done:'Connected',failed:'Setup did not complete',init:'Retry initialization',launching:'Connected. Starting the console…',switching:'Opening the console…',cannotCancel:'This step cannot be cancelled yet. Please wait.',cancelling:'Cancelling at the next step boundary…',offline:'Cannot reach setup. Check that SintBot is still running, then reload this page.',handoffFailed:'The console did not become ready. Check the terminal for a startup error, then reload this page.',retryConnection:'Reload page',requestFailed:'The request failed. Please retry.'},
415
+ zh:{modelHint:'扫码前读取执行器实时推荐的型号和思考深度,并保存为新群的初始设置;接入后可在控制台修改。',modelDefaults:'新群默认',thinking:'思考深度',title:'把项目接进飞书',intro:'这一页只在这台 Mac 上可见。扫码确认后会自动创建机器人应用、项目目录和群,并写好配置。',step1:'填项目名,或选一个已有目录',step2:'用手机飞书扫码并确认(需要企业或团队身份)',step3:'等它自动建应用、建群、写配置,然后在新群里发第一条任务',name:'项目名',rootBefore:'会在',rootAfter:'下新建这个目录并放一份 README。',workdir:'或者接入已有项目(绝对路径,留空则按上面新建)',executor:'执行器',executorHint:'两个都装了才需要选;只装了一个会自动用它。',basic:'我不是组织管理员(精简接入)',basicHint:'跳过放开范围和发布版本,之后请管理员在开放平台补这两步。',start:'开始扫码',cancel:'取消',chatHint:'自动建群没有成功。请在飞书里手动建一个群,把机器人拉进去,复制群 ID(oc_…)粘贴到这里继续:',resume:'继续',back:'返回修改',again:'重试',back2:'返回项目设置',doneHint:'配置已写好,SintBot 正在连接飞书。这一页几秒后会切到控制台;去新群里发「读取 README 第一行」试试。',noRunner:'未检测到可用执行器,请先安装 Codex 或 Claude Code 并登录,再重新打开此页面。',waiting:'等待扫码确认…',preparing:'正在准备…',chat:'需要你手动建群',done:'接入完成',failed:'接入没有完成',init:'重试初始化',launching:'接入完成,正在启动控制台…',switching:'正在切换到控制台…',cannotCancel:'现在这一步不能取消,请稍等。',cancelling:'正在取消,等待当前步骤结束…',offline:'暂时无法连接接入页。请确认 SintBot 仍在运行,然后刷新页面。',handoffFailed:'控制台未能就绪,请查看终端中的启动错误后刷新页面。',retryConnection:'刷新页面',requestFailed:'请求失败,请重试。'}};
416
+ let lang='en';try{const saved=localStorage.getItem('sbot-lang');lang=saved==='zh'||saved==='en'?saved:(navigator.language||'').toLowerCase().startsWith('zh')?'zh':'en'}catch(e){lang=(navigator.language||'').toLowerCase().startsWith('zh')?'zh':'en'}
417
+ const t=k=>words[lang][k]||k;
418
+ const api=async(p,b)=>{const r=await fetch(p,b?{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b),signal:AbortSignal.timeout(5000)}:{cache:'no-store',signal:AbortSignal.timeout(5000)});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||String(r.status));return j};
419
+ let last=null,timer=null,formError='',actionError='',actionKey='',polling=false,handoffStarted=false;
420
+ function displayError(error){return translate(error&&error.message||String(error),lang)||t('requestFailed')}
421
+ function renderForm(st){$('#root').textContent=st.defaults.projectsRoot;const box=$('#providers');const chosen=document.querySelector('input[name=prov]:checked')?.value;box.replaceChildren();const avail=Object.keys(st.runners||{}).filter(k=>st.runners[k]);$('#start').disabled=!avail.length;
422
+ if(!avail.length){const hint=document.createElement('span');hint.className='hint';hint.style.color='var(--fail)';hint.textContent=t('noRunner');box.appendChild(hint);return}
423
+ if(avail.length===1){box.textContent=avail[0]==='claude'?'Claude Code':'Codex';return}
424
+ for(const r of avail){const l=document.createElement('label'),radio=document.createElement('input');radio.type='radio';radio.name='prov';radio.value=r;radio.checked=r===(chosen||avail[0]);l.append(radio,document.createTextNode(' '+(r==='claude'?'Claude Code':'Codex')));box.appendChild(l)}}
425
+ function restore(st){if(!st.input)return;$('#name').value=st.input.name||'sintbot';$('#workdir').value=st.input.workdir||'';$('#basic').checked=!!st.input.basic;if(st.input.provider){const r=document.querySelector('input[name=prov][value="'+st.input.provider+'"]');if(r)r.checked=true}}
426
+ function render(st){last=st;if(st.phase!=='running')actionKey='';const running=st.phase!=='form';$('#form').classList.toggle('hidden',running);$('#run').classList.toggle('hidden',!running);$('#formErr').textContent=formError?translate(formError,lang):'';if(!running)return;
427
+ $('#status').textContent=t(st.phase==='running'?(st.qr?'waiting':'preparing'):st.phase==='waitingChat'?'chat':st.phase==='done'?'done':'failed');
428
+ $('#modelDefaults').classList.toggle('hidden',!st.modelDefaults);$('#modelDefaults').textContent=st.modelDefaults?t('modelDefaults')+': '+(st.modelDefaults.name||st.modelDefaults.model)+' · '+t('thinking')+': '+st.modelDefaults.effort:'';
429
+ $('#status').className='status '+st.phase;$('#qr').innerHTML=st.phase==='running'&&st.qr?st.qr:'';$('#log').textContent=st.lines.map(l=>translate(l,lang)).concat(actionKey?[t(actionKey)]:actionError?[translate(actionError,lang)]:[]).join('\n');$('#log').scrollTop=1e9;
430
+ $('#cancel').classList.toggle('hidden',st.phase!=='running');$('#chat').classList.toggle('hidden',st.phase!=='waitingChat');$('#chatBack').classList.toggle('hidden',st.phase!=='waitingChat');$('#retry').classList.toggle('hidden',st.phase!=='failed');$('#doneBox').classList.toggle('hidden',st.phase!=='done');$('#again').textContent=t(st.initOnly?'init':'again')}
431
+ function setLanguage(value){lang=value==='zh'?'zh':'en';$('#language').value=lang;document.documentElement.lang=lang==='zh'?'zh-CN':'en';document.title='SintBot · '+(lang==='zh'?'接入飞书':'Connect Feishu');document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n)});if(last){renderForm(last);render(last)}try{localStorage.setItem('sbot-lang',lang)}catch(e){}}
432
+ $('#language').onchange=()=>setLanguage($('#language').value);setLanguage(lang);
433
+ // A valid console response must be HTML without the setup marker. A failed
434
+ // request can mean normal handoff or a stopped process; the wait is bounded.
435
+ async function handoff(){if(handoffStarted)return;handoffStarted=true;clearInterval(timer);$('#status').textContent=t(last?.phase==='done'?'launching':'switching');for(let i=0;i<60;i++){try{const r=await fetch('/',{cache:'no-store',signal:AbortSignal.timeout(3000)});if(r.ok&&/text\/html/.test(r.headers.get('content-type')||'')){const html=await r.text();if(!html.includes('data-sintbot-setup=')&&html.includes('<html')){location.replace('/');return}}}catch(e){}await new Promise(r=>setTimeout(r,2000))}actionError=t('handoffFailed');$('#status').textContent=t('failed');$('#log').textContent+='\n'+actionError;const reload=document.createElement('button');reload.className='btn';reload.textContent=t('retryConnection');reload.onclick=()=>location.reload();$('#run').appendChild(reload)}
436
+ let gone=0;async function poll(){if(polling||handoffStarted)return;polling=true;try{const st=await api('/api/setup/state');if(!st.phase){await handoff();return}gone=0;if(!last){renderForm(st);restore(st)}formError='';render(st);if(st.phase==='done')void handoff()}catch(e){if(last&&last.phase!=='form'&&++gone>=2)void handoff();else if(!last||last.phase==='form'){formError=t('offline');$('#formErr').textContent=formError}}finally{polling=false}}
437
+ async function start(extra){formError='';actionError='';actionKey='';$('#formErr').textContent='';const prov=document.querySelector('input[name=prov]:checked');const input={name:$('#name').value.trim(),workdir:$('#workdir').value.trim(),provider:prov?prov.value:'',basic:$('#basic').checked,...extra};
438
+ try{const r=await api('/api/setup/start',input);render(r.state)}catch(e){formError=e.message;actionError=e.message;if(last)render(last);else $('#formErr').textContent=displayError(e)}}
439
+ $('#start').onclick=()=>start({});$('#again').onclick=()=>start({});$('#resume').onclick=()=>start({chatId:$('#chatId').value.trim()});
440
+ $('#cancel').onclick=async()=>{try{const r=await api('/api/setup/cancel',{});actionKey=r.ok?'cancelling':'cannotCancel';render(last)}catch(e){actionError=e.message;render(last)}};
441
+ const back=async()=>{try{await api('/api/setup/reset',{});formError='';actionError='';actionKey='';render(await api('/api/setup/state'))}catch(e){actionError=e.message;render(last)}};$('#back').onclick=back;$('#back2').onclick=back;
442
+ api('/api/setup/state').then(st=>{renderForm(st);restore(st);render(st)}).catch(e=>{formError=t('offline');$('#formErr').textContent=formError});timer=setInterval(poll,1500);
443
+ })();
444
+ </script></body></html>`
445
+ }
446
+
447
+ module.exports = { createSetupServer, createSetupSession, probeRunners, setupPage, svgOf, translateSetupNotice }