@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.
- package/README.md +45 -24
- package/agent-assets/AGENTS.md +7 -5
- package/dist/cli.mjs +5 -5
- package/package.json +1 -1
- package/skills/realsee-vr-app/SKILL.md +1 -1
- package/skills/realsee-vr-app/references/dnalogel-openapi-recipes.md +90 -26
- package/skills/realsee-vr-app/references/five-runtime.md +2 -2
- package/skills/realsee-vr-app/scripts/verify-project.mjs +186 -15
- package/templates/base/.env.example +2 -2
- package/templates/base/.gitignore.template +2 -0
- package/templates/base/.prettierignore +2 -0
- package/templates/base/README.md +32 -6
- package/templates/base/deploy.json +11 -0
- package/templates/base/eslint.config.mjs +2 -0
- package/templates/base/package-lock.json +11 -0
- package/templates/base/package.json +4 -1
- package/templates/base/scripts/package-vibe-release.mjs +569 -0
- package/templates/base/scripts/pairing.mjs +564 -0
- package/templates/base/scripts/pairing.test.mjs +227 -0
- package/templates/base/scripts/project-identity.mjs +160 -0
- package/templates/base/scripts/project-identity.test.mjs +119 -0
- package/templates/base/scripts/setup-config.mjs +96 -23
- package/templates/base/scripts/setup-config.test.mjs +49 -9
- package/templates/base/scripts/setup.mjs +161 -7
- package/templates/base/server/app.ts +1 -1
- package/templates/base/server/config.ts +5 -4
- package/templates/base/server/types.ts +1 -1
- package/templates/base/server/work-route.ts +14 -3
|
@@ -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
|
+
}
|