@realsee/vr-cli 0.1.5 → 0.2.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,564 @@
1
+ import { lstat, readFile, rm } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { currentProjectClientName } from './project-identity.mjs'
4
+ import {
5
+ normalizeGatewayCredentials,
6
+ writeLocalConfig,
7
+ writePrivateFile,
8
+ } from './setup-config.mjs'
9
+
10
+ // First-phase pairing runs against the anonymous Portal endpoints only. The
11
+ // base URL is overridable for staging/lan testing; protocol paths never change.
12
+ export const DEFAULT_PORTAL_BASE_URL = 'https://open.realsee.cn'
13
+ export const PORTAL_BASE_URL_ENV_KEY = 'REALSEE_PORTAL_BASE_URL'
14
+ export const CONTINUATION_RELATIVE_PATH = '.realsee/pairing-continuation.json'
15
+ export const CONTINUATION_SCHEMA_VERSION = 1
16
+ export const NEXT_STEP_COMMAND = 'npm run setup -- --complete'
17
+ export const CLIENT_VERSION = 'vr-cli/{{TOOL_VERSION}}'
18
+ // The pairing start request carries the documented environment/capabilities
19
+ // fields; `production` is the only value the protocol defines today.
20
+ export const PAIRING_ENVIRONMENT = 'production'
21
+
22
+ export const PAIRING_EXIT_CODES = Object.freeze({
23
+ OK: 0,
24
+ ERROR: 1,
25
+ PENDING: 2,
26
+ DENIED: 3,
27
+ EXPIRED: 4,
28
+ UNAVAILABLE: 5,
29
+ })
30
+
31
+ const MIN_INTERVAL_SECONDS = 5
32
+ const MAX_INTERVAL_SECONDS = 60
33
+ const SLOW_DOWN_STEP_SECONDS = 5
34
+ const MIN_WINDOW_SECONDS = 60
35
+ const MAX_WINDOW_SECONDS = 3_600
36
+ const REQUEST_TIMEOUT_MS = 30_000
37
+
38
+ const START_PATH = '/api/cli/pairing/start'
39
+ const POLL_PATH = '/api/cli/pairing/poll'
40
+
41
+ export class PairingError extends Error {
42
+ constructor({ code, message, hint, exitCode = PAIRING_EXIT_CODES.ERROR }) {
43
+ super(message)
44
+ this.name = 'PairingError'
45
+ this.code = code
46
+ this.hint = hint
47
+ this.exitCode = exitCode
48
+ }
49
+ }
50
+
51
+ function configurationError(message, hint) {
52
+ return new PairingError({
53
+ code: 'configuration_invalid',
54
+ message,
55
+ hint,
56
+ })
57
+ }
58
+
59
+ function unavailableError(message, hint) {
60
+ return new PairingError({
61
+ code: 'server_unavailable',
62
+ message,
63
+ hint,
64
+ exitCode: PAIRING_EXIT_CODES.UNAVAILABLE,
65
+ })
66
+ }
67
+
68
+ function interruptedError(message, hint) {
69
+ return new PairingError({
70
+ code: 'network_unavailable',
71
+ message,
72
+ hint,
73
+ exitCode: PAIRING_EXIT_CODES.UNAVAILABLE,
74
+ })
75
+ }
76
+
77
+ function clampIntervalSeconds(value) {
78
+ const seconds = Number(value)
79
+ if (!Number.isFinite(seconds)) return MIN_INTERVAL_SECONDS
80
+ return Math.min(
81
+ MAX_INTERVAL_SECONDS,
82
+ Math.max(MIN_INTERVAL_SECONDS, Math.floor(seconds)),
83
+ )
84
+ }
85
+
86
+ function clampWindowSeconds(value) {
87
+ const seconds = Number(value)
88
+ if (!Number.isFinite(seconds)) return MAX_WINDOW_SECONDS
89
+ return Math.min(
90
+ MAX_WINDOW_SECONDS,
91
+ Math.max(MIN_WINDOW_SECONDS, Math.floor(seconds)),
92
+ )
93
+ }
94
+
95
+ function isLoopbackHost(host) {
96
+ return (
97
+ host === 'localhost' ||
98
+ host === '127.0.0.1' ||
99
+ host === '[::1]' ||
100
+ host === '::1'
101
+ )
102
+ }
103
+
104
+ export function resolvePortalBaseUrl(env = process.env) {
105
+ const raw = env[PORTAL_BASE_URL_ENV_KEY]?.trim()
106
+ if (!raw) return DEFAULT_PORTAL_BASE_URL
107
+ let url
108
+ try {
109
+ url = new URL(raw)
110
+ } catch {
111
+ throw configurationError(
112
+ `${PORTAL_BASE_URL_ENV_KEY} 不是合法 URL:${raw}`,
113
+ `请修正或删除 ${PORTAL_BASE_URL_ENV_KEY} 后重试。`,
114
+ )
115
+ }
116
+ const loopback = isLoopbackHost(url.hostname)
117
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
118
+ throw configurationError(
119
+ `${PORTAL_BASE_URL_ENV_KEY} 必须使用 HTTPS(本机回环地址除外):${raw}`,
120
+ `请修正或删除 ${PORTAL_BASE_URL_ENV_KEY} 后重试。`,
121
+ )
122
+ }
123
+ const basePath = url.pathname.replace(/\/+$/u, '')
124
+ return `${url.origin}${basePath}`
125
+ }
126
+
127
+ function parseJson(text) {
128
+ try {
129
+ return JSON.parse(text)
130
+ } catch {
131
+ return null
132
+ }
133
+ }
134
+
135
+ async function requestJson(url, options = {}) {
136
+ let response
137
+ try {
138
+ response = await fetch(url, {
139
+ ...options,
140
+ headers: { accept: 'application/json', ...(options.headers ?? {}) },
141
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
142
+ })
143
+ } catch (error) {
144
+ throw interruptedError(
145
+ `无法连接配对服务:${error instanceof Error ? error.message : '网络错误'}`,
146
+ '请确认网络与 Portal 可用后重新运行 npm run setup。',
147
+ )
148
+ }
149
+
150
+ const payload = parseJson(await response.text().catch(() => ''))
151
+ if (
152
+ payload === null ||
153
+ typeof payload !== 'object' ||
154
+ Array.isArray(payload)
155
+ ) {
156
+ throw unavailableError(
157
+ `配对服务返回了无法解析的响应(HTTP ${response.status})。`,
158
+ '请稍后重试;若持续出现请联系 Portal 维护者。',
159
+ )
160
+ }
161
+ if (payload.ok === true && !response.ok) {
162
+ throw unavailableError(
163
+ `配对服务返回 HTTP ${response.status}。`,
164
+ '请稍后重试;若持续出现请联系 Portal 维护者。',
165
+ )
166
+ }
167
+ return payload
168
+ }
169
+
170
+ function requireString(value, label) {
171
+ if (typeof value !== 'string' || value.trim() === '') {
172
+ throw unavailableError(
173
+ `配对服务响应缺少 ${label}。`,
174
+ '请稍后重试;若持续出现请联系 Portal 维护者。',
175
+ )
176
+ }
177
+ return value
178
+ }
179
+
180
+ function optionalString(value) {
181
+ return typeof value === 'string' && value !== '' ? value : undefined
182
+ }
183
+
184
+ async function startPairingSession({ baseUrl, clientName, clientVersion }) {
185
+ const payload = await requestJson(`${baseUrl}${START_PATH}`, {
186
+ method: 'POST',
187
+ headers: { 'content-type': 'application/json' },
188
+ body: JSON.stringify({
189
+ client_name: clientName,
190
+ client_version: clientVersion,
191
+ environment: PAIRING_ENVIRONMENT,
192
+ capabilities: ['poll'],
193
+ }),
194
+ })
195
+ if (payload.ok !== true) {
196
+ const code = payload.error?.code ?? 'unknown_error'
197
+ throw unavailableError(
198
+ `配对服务拒绝了发起请求(${code})。`,
199
+ '请稍后重试;若持续出现请联系 Portal 维护者。',
200
+ )
201
+ }
202
+ const data = payload.data
203
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
204
+ throw unavailableError(
205
+ '配对服务响应缺少 data。',
206
+ '请稍后重试;若持续出现请联系 Portal 维护者。',
207
+ )
208
+ }
209
+ return {
210
+ userCode: requireString(data.user_code, 'user_code'),
211
+ verificationUriComplete: requireString(
212
+ data.verification_uri_complete,
213
+ 'verification_uri_complete',
214
+ ),
215
+ pollSecret: requireString(data.poll_secret, 'poll_secret'),
216
+ intervalSeconds: clampIntervalSeconds(data.interval),
217
+ expiresAtMs: Date.now() + clampWindowSeconds(data.expires_in) * 1_000,
218
+ }
219
+ }
220
+
221
+ async function pollPairingRequest({ baseUrl, pollSecret }) {
222
+ return requestJson(`${baseUrl}${POLL_PATH}`, {
223
+ method: 'POST',
224
+ headers: {
225
+ authorization: `Bearer ${pollSecret}`,
226
+ 'content-type': 'application/json',
227
+ },
228
+ body: '{}',
229
+ })
230
+ }
231
+
232
+ function sleepFor(milliseconds) {
233
+ if (milliseconds <= 0) return Promise.resolve()
234
+ return new Promise((resolve) => setTimeout(resolve, milliseconds))
235
+ }
236
+
237
+ async function waitForPairingResult({ baseUrl, session }) {
238
+ let intervalSeconds = session.intervalSeconds
239
+
240
+ for (;;) {
241
+ if (Date.now() >= session.expiresAtMs) return { status: 'expired' }
242
+
243
+ const payload = await pollPairingRequest({
244
+ baseUrl,
245
+ pollSecret: session.pollSecret,
246
+ })
247
+ if (payload.ok === true) {
248
+ const data = payload.data
249
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
250
+ throw unavailableError(
251
+ '配对服务响应缺少 data。',
252
+ '请稍后重试;若持续出现请联系 Portal 维护者。',
253
+ )
254
+ }
255
+ return {
256
+ status: 'approved',
257
+ credentials: {
258
+ appKey: requireString(data.app_key, 'app_key'),
259
+ appSecret: requireString(data.app_secret, 'app_secret'),
260
+ resourceCode: optionalString(data.resource_code),
261
+ gatewayEndpoint: optionalString(data.gateway_endpoint),
262
+ },
263
+ }
264
+ }
265
+
266
+ const code = payload.error?.code
267
+ if (code === 'authorization_pending') {
268
+ await sleepFor(
269
+ Math.min(intervalSeconds * 1_000, session.expiresAtMs - Date.now()),
270
+ )
271
+ continue
272
+ }
273
+ if (code === 'slow_down') {
274
+ intervalSeconds = clampIntervalSeconds(
275
+ intervalSeconds + SLOW_DOWN_STEP_SECONDS,
276
+ )
277
+ await sleepFor(
278
+ Math.min(intervalSeconds * 1_000, session.expiresAtMs - Date.now()),
279
+ )
280
+ continue
281
+ }
282
+ if (code === 'access_denied') return { status: 'denied' }
283
+ if (code === 'expired_token') return { status: 'expired' }
284
+ throw unavailableError(
285
+ `配对服务返回了未知状态:${code ?? '未知'}。`,
286
+ '请稍后重试;若持续出现请联系 Portal 维护者。',
287
+ )
288
+ }
289
+ }
290
+
291
+ export function maskAppKey(appKey) {
292
+ if (typeof appKey !== 'string' || appKey.length < 12) return '****'
293
+ return `${appKey.slice(0, 3)}****${appKey.slice(-4)}`
294
+ }
295
+
296
+ function formatTimestamp(milliseconds) {
297
+ return new Date(milliseconds).toISOString().replace(/\.\d{3}Z$/u, 'Z')
298
+ }
299
+
300
+ function successEnvelope(data) {
301
+ return { ok: true, data }
302
+ }
303
+
304
+ function failureEnvelope(error) {
305
+ return {
306
+ ok: false,
307
+ error: {
308
+ code: error.code,
309
+ message: error.message,
310
+ ...(error.hint ? { hint: error.hint } : {}),
311
+ },
312
+ }
313
+ }
314
+
315
+ function writeJsonLine(stream, value) {
316
+ stream.write(`${JSON.stringify(value)}\n`)
317
+ }
318
+
319
+ function writeHumanLines(enabled, lines) {
320
+ if (!enabled) return
321
+ for (const line of lines) process.stderr.write(`${line}\n`)
322
+ }
323
+
324
+ export function continuationPath(root = process.cwd()) {
325
+ return path.join(path.resolve(root), ...CONTINUATION_RELATIVE_PATH.split('/'))
326
+ }
327
+
328
+ async function hasLocalConfig(root = process.cwd()) {
329
+ try {
330
+ await lstat(path.join(path.resolve(root), '.env.development'))
331
+ return true
332
+ } catch (error) {
333
+ if (error?.code === 'ENOENT') return false
334
+ throw error
335
+ }
336
+ }
337
+
338
+ export async function writePairingContinuation({
339
+ root = process.cwd(),
340
+ baseUrl,
341
+ session,
342
+ ...writeOptions
343
+ }) {
344
+ const content = `${JSON.stringify(
345
+ {
346
+ schemaVersion: CONTINUATION_SCHEMA_VERSION,
347
+ portalBaseUrl: baseUrl,
348
+ userCode: session.userCode,
349
+ verificationUriComplete: session.verificationUriComplete,
350
+ pollSecret: session.pollSecret,
351
+ intervalSeconds: session.intervalSeconds,
352
+ expiresAt: new Date(session.expiresAtMs).toISOString(),
353
+ createdAt: new Date().toISOString(),
354
+ },
355
+ null,
356
+ 2,
357
+ )}\n`
358
+ return writePrivateFile(continuationPath(root), content, writeOptions)
359
+ }
360
+
361
+ function continuationInvalid(message) {
362
+ return new PairingError({
363
+ code: 'continuation_invalid',
364
+ message: `配对续跑文件 ${CONTINUATION_RELATIVE_PATH} ${message}`,
365
+ hint: '请删除该文件后重新运行 npm run setup。',
366
+ })
367
+ }
368
+
369
+ function continuationString(value, label) {
370
+ if (typeof value !== 'string' || value.trim() === '') {
371
+ throw continuationInvalid(`缺少有效的 ${label}。`)
372
+ }
373
+ return value
374
+ }
375
+
376
+ export async function readPairingContinuation({ root = process.cwd() } = {}) {
377
+ let text
378
+ try {
379
+ text = await readFile(continuationPath(root), 'utf8')
380
+ } catch (error) {
381
+ if (error?.code === 'ENOENT') {
382
+ throw new PairingError({
383
+ code: 'continuation_missing',
384
+ message: `未找到配对续跑文件 ${CONTINUATION_RELATIVE_PATH}。`,
385
+ hint: '请先运行 npm run setup -- --no-wait --json 或 npm run setup。',
386
+ })
387
+ }
388
+ throw error
389
+ }
390
+
391
+ let parsed
392
+ try {
393
+ parsed = JSON.parse(text)
394
+ } catch {
395
+ parsed = null
396
+ }
397
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
398
+ throw continuationInvalid('已损坏。')
399
+ }
400
+ if (parsed.schemaVersion !== CONTINUATION_SCHEMA_VERSION) {
401
+ throw continuationInvalid('使用了不支持的 schemaVersion。')
402
+ }
403
+ const expiresAtMs = Date.parse(parsed.expiresAt)
404
+ if (Number.isNaN(expiresAtMs)) {
405
+ throw continuationInvalid('缺少有效的 expiresAt。')
406
+ }
407
+ return {
408
+ baseUrl: resolvePortalBaseUrl({
409
+ [PORTAL_BASE_URL_ENV_KEY]: continuationString(
410
+ parsed.portalBaseUrl,
411
+ 'portalBaseUrl',
412
+ ),
413
+ }),
414
+ session: {
415
+ userCode: continuationString(parsed.userCode, 'userCode'),
416
+ verificationUriComplete: continuationString(
417
+ parsed.verificationUriComplete,
418
+ 'verificationUriComplete',
419
+ ),
420
+ pollSecret: continuationString(parsed.pollSecret, 'pollSecret'),
421
+ intervalSeconds: clampIntervalSeconds(parsed.intervalSeconds),
422
+ expiresAtMs,
423
+ },
424
+ }
425
+ }
426
+
427
+ export async function clearPairingContinuation({ root = process.cwd() } = {}) {
428
+ await rm(continuationPath(root), { force: true })
429
+ }
430
+
431
+ async function persistCredentials(credentials) {
432
+ const config = normalizeGatewayCredentials({
433
+ appKey: credentials.appKey,
434
+ appSecret: credentials.appSecret,
435
+ ...(credentials.resourceCode === undefined
436
+ ? {}
437
+ : { resourceCode: credentials.resourceCode }),
438
+ ...(credentials.gatewayEndpoint === undefined
439
+ ? {}
440
+ : { gatewayEndpoint: credentials.gatewayEndpoint }),
441
+ })
442
+ try {
443
+ await writeLocalConfig(config)
444
+ } catch (error) {
445
+ throw new PairingError({
446
+ code: 'write_failed',
447
+ message: `写入 .env.development 失败:${
448
+ error instanceof Error ? error.message : '未知错误'
449
+ }`,
450
+ hint: '请检查当前目录权限后重新运行 npm run setup。',
451
+ })
452
+ }
453
+ return config
454
+ }
455
+
456
+ async function finishPairingResult(result, { json }) {
457
+ if (result.status === 'approved') {
458
+ const config = await persistCredentials(result.credentials)
459
+ await clearPairingContinuation()
460
+ writeHumanLines(json !== true, [
461
+ `配对完成,已原子写入 .env.development(AK ${maskAppKey(config.appKey)})。请回到 AI 助手并回复“已完成”。`,
462
+ ])
463
+ writeJsonLine(
464
+ process.stdout,
465
+ successEnvelope({
466
+ status: 'paired',
467
+ app_key: maskAppKey(config.appKey),
468
+ path: '.env.development',
469
+ }),
470
+ )
471
+ return PAIRING_EXIT_CODES.OK
472
+ }
473
+
474
+ await clearPairingContinuation()
475
+ if (result.status === 'denied') {
476
+ throw new PairingError({
477
+ code: 'access_denied',
478
+ message: '用户在 Portal 拒绝了本次配对请求。',
479
+ hint: '请勿自动重发;请与用户确认原因后重新运行 npm run setup。',
480
+ exitCode: PAIRING_EXIT_CODES.DENIED,
481
+ })
482
+ }
483
+ throw new PairingError({
484
+ code: 'expired_token',
485
+ message: '配对请求已过期。',
486
+ hint: '请重新运行 npm run setup 发起新的配对请求。',
487
+ exitCode: PAIRING_EXIT_CODES.EXPIRED,
488
+ })
489
+ }
490
+
491
+ export function reportPairingFailure(error, { json = false } = {}) {
492
+ const pairingError =
493
+ error instanceof PairingError
494
+ ? error
495
+ : new PairingError({
496
+ code: 'unexpected_error',
497
+ message: error instanceof Error ? error.message : '未知错误。',
498
+ hint: '请重新运行 npm run setup,或改用 npm run setup -- --paste。',
499
+ })
500
+ writeHumanLines(json !== true, [`配对失败:${pairingError.message}`])
501
+ writeJsonLine(process.stderr, failureEnvelope(pairingError))
502
+ return pairingError.exitCode
503
+ }
504
+
505
+ export async function runPairingCommand({ noWait = false, json = false } = {}) {
506
+ try {
507
+ const baseUrl = resolvePortalBaseUrl()
508
+ const clientName = await currentProjectClientName()
509
+ const replacingConfig = await hasLocalConfig()
510
+ const session = await startPairingSession({
511
+ baseUrl,
512
+ clientName,
513
+ clientVersion: CLIENT_VERSION,
514
+ })
515
+ await writePairingContinuation({ baseUrl, session })
516
+ const expiresAt = formatTimestamp(session.expiresAtMs)
517
+
518
+ const closingLine = noWait
519
+ ? `用户确认后运行:${NEXT_STEP_COMMAND}`
520
+ : '等待用户在 Portal 确认…'
521
+ writeHumanLines(json !== true, [
522
+ `已发起配对请求,有效期至 ${expiresAt}。`,
523
+ `项目与发起环境:${clientName}`,
524
+ ...(replacingConfig
525
+ ? ['确认成功后仅替换当前项目的本地配置,其他项目不受影响。']
526
+ : []),
527
+ '请在浏览器中打开并确认(不要把该链接转发给他人):',
528
+ session.verificationUriComplete,
529
+ `核对短码:${session.userCode}`,
530
+ closingLine,
531
+ ])
532
+
533
+ if (noWait) {
534
+ writeJsonLine(
535
+ process.stdout,
536
+ successEnvelope({
537
+ status: 'pending',
538
+ client_name: clientName,
539
+ replacing_config: replacingConfig,
540
+ user_code: session.userCode,
541
+ verification_uri_complete: session.verificationUriComplete,
542
+ expires_at: expiresAt,
543
+ next_step: NEXT_STEP_COMMAND,
544
+ }),
545
+ )
546
+ return PAIRING_EXIT_CODES.PENDING
547
+ }
548
+
549
+ const result = await waitForPairingResult({ baseUrl, session })
550
+ return await finishPairingResult(result, { json })
551
+ } catch (error) {
552
+ return reportPairingFailure(error, { json })
553
+ }
554
+ }
555
+
556
+ export async function runPairingCompleteCommand({ json = false } = {}) {
557
+ try {
558
+ const { baseUrl, session } = await readPairingContinuation()
559
+ const result = await waitForPairingResult({ baseUrl, session })
560
+ return await finishPairingResult(result, { json })
561
+ } catch (error) {
562
+ return reportPairingFailure(error, { json })
563
+ }
564
+ }